diff --git a/.github/workflows/preview_pages_managment.yml b/.github/workflows/preview_pages_managment.yml new file mode 100644 index 0000000..f41b238 --- /dev/null +++ b/.github/workflows/preview_pages_managment.yml @@ -0,0 +1,195 @@ +name: Preview Pages Management + +on: + issue_comment: + types: [created] + pull_request: + branches: [main] + types: [closed] + +jobs: + check-permission: + name: Check Author & Command + if: github.event.issue.pull_request && contains(github.event.comment.body, '/preview') + runs-on: ubuntu-latest + outputs: + authorized: ${{ steps.check.outputs.authorized }} + steps: + - name: Check user permissions + id: check + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const username = context.actor; + + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + const allowed = ['admin', 'write'].includes(permission.permission); + if (!allowed) { + core.setFailed(`User @${username} is not authorized to trigger preview deployments.`); + } + core.setOutput('authorized', allowed); + + notify-preview-start: + name: Post Building Comment & Cleanup Trigger + needs: check-permission + runs-on: ubuntu-latest + permissions: + pull-requests: write + outputs: + pr_num: ${{ steps.pr_data.outputs.pr_num }} + sha: ${{ steps.pr_data.outputs.sha }} + ref: ${{ steps.pr_data.outputs.ref }} + pr_author: ${{ steps.pr_data.outputs.pr_author }} + comment_id: ${{ steps.create_comment.outputs.comment_id }} + steps: + - name: Get PR Information + id: pr_data + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + core.setOutput('pr_num', pr.data.number); + core.setOutput('sha', pr.data.head.sha); + core.setOutput('ref', pr.data.head.ref); + core.setOutput('pr_author', pr.data.user.login); + + - name: Delete triggering /preview comment + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + }); + + - name: Post "Preview building" comment + id: create_comment + uses: actions/github-script@v7 + with: + script: | + const triggerUser = context.actor; + const prAuthor = "${{ steps.pr_data.outputs.pr_author }}"; + + let mentions = `@${triggerUser}`; + if (prAuthor && prAuthor !== triggerUser) { + mentions += ` @${prAuthor}`; + } + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `⏳ **Preview building...**`, + }); + core.setOutput('comment_id', comment.id); + + deploy-preview: + name: Deploy Preview to Cloudflare Pages + needs: [check-permission, notify-preview-start] + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout Engine + uses: actions/checkout@v4 + with: + path: engine + repository: ThePythonLedger/Docusaurus-engine + + - name: Checkout Curriculum (PR Code) + uses: actions/checkout@v4 + with: + path: Curriculum + repository: ThePythonLedger/Curriculum + ref: ${{ needs.notify-preview-start.outputs.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: engine/package-lock.json + + - name: Build Site Assets + env: + DOCUSAURUS_URL: https://engine-pr-${{ needs.notify-preview-start.outputs.pr_num }}.python-ledger-preview.pages.dev + DOCUSAURUS_BASE_URL: / + run: | + cd engine + npm ci + npm run build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v4 + id: cloudflare_deploy + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} + command: pages deploy engine/build --project-name=python-ledger-preview --branch=pr-${{ needs.notify-preview-start.outputs.pr_num }} + + - name: Update Preview Comment with Link + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ needs.notify-preview-start.outputs.pr_num }}; + const prAuthor = "${{ needs.notify-preview-start.outputs.pr_author }}"; + const triggerUser = context.actor; + const previewUrl = `https://pr-${prNumber}.python-ledger-preview.pages.dev`; + + let mentions = `@${triggerUser}`; + if (prAuthor && prAuthor !== triggerUser) { + mentions += ` @${prAuthor}`; + } + + const message = `🚀 **Preview Deployment Ready!**\n\ncc ${mentions}\n\nView changes live at:\n${previewUrl}`; + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: ${{ needs.notify-preview-start.outputs.comment_id }}, + body: message, + }); + + - name: Mark Preview as Failed + if: failure() + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: ${{ needs.notify-preview-start.outputs.comment_id }}, + body: `❌ **Preview build failed.** Check the [workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.`, + }); + + cleanup-preview: + name: Delete Cloudflare Pages Preview + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Delete Cloudflare Pages Branch via API + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + run: | + BRANCH_NAME="pr-${{ github.event.number }}" + PROJECT_NAME="python-ledger-preview" + + echo "Deleting Cloudflare Pages branch: $BRANCH_NAME" + + curl -s -X DELETE \ + "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/branches/${BRANCH_NAME}" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ No newline at end of file diff --git a/src/components/MemoryGraphBlock/index.js b/src/components/MemoryGraphBlock/index.js new file mode 100644 index 0000000..5723ab6 --- /dev/null +++ b/src/components/MemoryGraphBlock/index.js @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import MDXComponents from '@theme-original/MDXComponents'; +import styles from './styles.module.css'; + +const MEMORY_GRAPH_BASE_URL = 'https://memory-graph.com/'; + +/** + * Wraps a ```lang debug code block with a header bar linking out to + * Bas Terwijn's Memory Graph web debugger (memory-graph.com), so students + * can step through the exact snippet from the lesson and watch variables/ + * references change line by line. + * + * Unlike InteractivePython, this doesn't replace the block with an editor — + * the theme's normal
still renders (syntax highlighting, copy button,
+ * line numbers all keep working). We just read the raw code to build a
+ * deep link and drop a button above it.
+ *
+ * Memory Graph reads code from a URL *fragment* (`#code=...`), not a query
+ * string — that's deliberate on their end, it means the code never gets
+ * sent to their server, it's only ever interpreted client-side once the
+ * page loads. Don't "fix" this to `?code=`.
+ */
+export default function MemoryGraphBlock(props) {
+ const { children } = props;
+ const rawCode = children?.props?.children?.trim() || '';
+
+ const memoryGraphUrl = useMemo(
+ () => `${MEMORY_GRAPH_BASE_URL}#code=${encodeURIComponent(rawCode)}`,
+ [rawCode]
+ );
+
+ return (
+
+
+ Debug Mode
+
+ Open in Memory Graph ↗
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/MemoryGraphBlock/styles.module.css b/src/components/MemoryGraphBlock/styles.module.css
new file mode 100644
index 0000000..2e00a02
--- /dev/null
+++ b/src/components/MemoryGraphBlock/styles.module.css
@@ -0,0 +1,75 @@
+/* ==========================================================================
+ Reuses the site's existing purple accent (see .alert--explore in
+ custom.css) so "debug" blocks read as their own category, distinct
+ from the blue InteractivePython header.
+ ========================================================================== */
+.wrapper {
+ margin: 1.5rem 0;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 10px 16px;
+ background-color: rgba(124, 58, 237, 0.08);
+ border: 1px solid rgba(124, 58, 237, 0.35);
+ border-bottom: none;
+ border-radius: var(--ifm-global-radius) var(--ifm-global-radius) 0 0;
+}
+
+[data-theme='dark'] .header {
+ background-color: rgba(168, 85, 247, 0.15);
+ border-color: rgba(216, 180, 254, 0.3);
+}
+
+.headerText {
+ font-family: var(--ifm-font-family-base, sans-serif);
+ font-weight: 600;
+ font-size: 0.85rem;
+ letter-spacing: 0.05rem;
+ text-transform: uppercase;
+ color: #7c3aed;
+}
+
+[data-theme='dark'] .headerText {
+ color: #e9d5ff;
+}
+
+.openButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border-radius: 6px;
+ background-color: #7c3aed;
+ color: #ffffff !important; /* override Docusaurus's default link color */
+ font-family: var(--ifm-font-family-base, sans-serif);
+ font-weight: 600;
+ font-size: 0.85rem;
+ text-decoration: none !important;
+ white-space: nowrap;
+ transition: background-color 0.15s ease;
+}
+
+.openButton:hover {
+ background-color: #6d28d9;
+ color: #ffffff !important;
+}
+
+/* Butt the code block directly against the header — no gap, no double border */
+.wrapper :global(.theme-code-block) {
+ margin-top: 0 !important;
+ border-top-left-radius: 0 !important;
+ border-top-right-radius: 0 !important;
+ border-top: none !important;
+}
+
+@media (max-width: 480px) {
+ .header {
+ justify-content: center;
+ text-align: center;
+ }
+}
\ No newline at end of file
diff --git a/src/theme/MDXComponents.js b/src/theme/MDXComponents.js
index 8a26fa4..7e13a98 100644
--- a/src/theme/MDXComponents.js
+++ b/src/theme/MDXComponents.js
@@ -1,15 +1,19 @@
import MDXComponents from '@theme-original/MDXComponents';
import InteractivePython from '@site/src/components/InteractivePython';
+import MemoryGraphBlock from '@site/src/components/MemoryGraphBlock';
export default {
...MDXComponents,
// We wrap the standard 'pre' tag (which wraps code blocks)
pre: (props) => {
- const isInteractive = props.children?.props?.metastring?.includes('interactive');
-
- if (isInteractive) {
+ const metastring = props.children?.props?.metastring || '';
+
+ if (metastring.includes('interactive')) {
return ;
}
+ if (metastring.includes('debug')) {
+ return ;
+ }
return ;
},
};
\ No newline at end of file