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