Skip to content

Commit a8d838d

Browse files
committed
Added MemoryGraph component and new workflow to build preview pages
1 parent 1b8cd57 commit a8d838d

4 files changed

Lines changed: 325 additions & 3 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
name: Preview Pages Management
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request:
7+
branches: [main]
8+
types: [closed]
9+
10+
jobs:
11+
check-permission:
12+
name: Check Author & Command
13+
if: github.event.issue.pull_request && contains(github.event.comment.body, '/preview')
14+
runs-on: ubuntu-latest
15+
outputs:
16+
authorized: ${{ steps.check.outputs.authorized }}
17+
steps:
18+
- name: Check user permissions
19+
id: check
20+
uses: actions/github-script@v7
21+
with:
22+
script: |
23+
const { owner, repo } = context.repo;
24+
const username = context.actor;
25+
26+
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
27+
owner,
28+
repo,
29+
username,
30+
});
31+
32+
const allowed = ['admin', 'write'].includes(permission.permission);
33+
if (!allowed) {
34+
core.setFailed(`User @${username} is not authorized to trigger preview deployments.`);
35+
}
36+
core.setOutput('authorized', allowed);
37+
38+
notify-preview-start:
39+
name: Post Building Comment & Cleanup Trigger
40+
needs: check-permission
41+
runs-on: ubuntu-latest
42+
permissions:
43+
pull-requests: write
44+
outputs:
45+
pr_num: ${{ steps.pr_data.outputs.pr_num }}
46+
sha: ${{ steps.pr_data.outputs.sha }}
47+
ref: ${{ steps.pr_data.outputs.ref }}
48+
pr_author: ${{ steps.pr_data.outputs.pr_author }}
49+
comment_id: ${{ steps.create_comment.outputs.comment_id }}
50+
steps:
51+
- name: Get PR Information
52+
id: pr_data
53+
uses: actions/github-script@v7
54+
with:
55+
script: |
56+
const pr = await github.rest.pulls.get({
57+
owner: context.repo.owner,
58+
repo: context.repo.repo,
59+
pull_number: context.issue.number,
60+
});
61+
core.setOutput('pr_num', pr.data.number);
62+
core.setOutput('sha', pr.data.head.sha);
63+
core.setOutput('ref', pr.data.head.ref);
64+
core.setOutput('pr_author', pr.data.user.login);
65+
66+
- name: Delete triggering /preview comment
67+
uses: actions/github-script@v7
68+
with:
69+
script: |
70+
await github.rest.issues.deleteComment({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
comment_id: context.payload.comment.id,
74+
});
75+
76+
- name: Post "Preview building" comment
77+
id: create_comment
78+
uses: actions/github-script@v7
79+
with:
80+
script: |
81+
const triggerUser = context.actor;
82+
const prAuthor = "${{ steps.pr_data.outputs.pr_author }}";
83+
84+
let mentions = `@${triggerUser}`;
85+
if (prAuthor && prAuthor !== triggerUser) {
86+
mentions += ` @${prAuthor}`;
87+
}
88+
89+
const { data: comment } = await github.rest.issues.createComment({
90+
owner: context.repo.owner,
91+
repo: context.repo.repo,
92+
issue_number: context.issue.number,
93+
body: `⏳ **Preview building...**`,
94+
});
95+
core.setOutput('comment_id', comment.id);
96+
97+
deploy-preview:
98+
name: Deploy Preview to Cloudflare Pages
99+
needs: [check-permission, notify-preview-start]
100+
runs-on: ubuntu-latest
101+
permissions:
102+
pull-requests: write
103+
contents: read
104+
steps:
105+
- name: Checkout Engine
106+
uses: actions/checkout@v4
107+
with:
108+
path: engine
109+
repository: ThePythonLedger/Docusaurus-engine
110+
111+
- name: Checkout Curriculum (PR Code)
112+
uses: actions/checkout@v4
113+
with:
114+
path: Curriculum
115+
repository: ThePythonLedger/Curriculum
116+
ref: ${{ needs.notify-preview-start.outputs.sha }}
117+
118+
- name: Setup Node.js
119+
uses: actions/setup-node@v4
120+
with:
121+
node-version: 20
122+
cache: 'npm'
123+
cache-dependency-path: engine/package-lock.json
124+
125+
- name: Build Site Assets
126+
env:
127+
DOCUSAURUS_URL: https://engine-pr-${{ needs.notify-preview-start.outputs.pr_num }}.python-ledger-preview.pages.dev
128+
DOCUSAURUS_BASE_URL: /
129+
run: |
130+
cd engine
131+
npm ci
132+
npm run build
133+
134+
- name: Deploy to Cloudflare Pages
135+
uses: cloudflare/wrangler-action@v4
136+
id: cloudflare_deploy
137+
with:
138+
apiToken: ${{ secrets.CF_API_TOKEN }}
139+
accountId: ${{ secrets.CF_ACCOUNT_ID }}
140+
command: pages deploy engine/build --project-name=python-ledger-preview --branch=pr-${{ needs.notify-preview-start.outputs.pr_num }}
141+
142+
- name: Update Preview Comment with Link
143+
uses: actions/github-script@v7
144+
with:
145+
script: |
146+
const prNumber = ${{ needs.notify-preview-start.outputs.pr_num }};
147+
const prAuthor = "${{ needs.notify-preview-start.outputs.pr_author }}";
148+
const triggerUser = context.actor;
149+
const previewUrl = `https://pr-${prNumber}.python-ledger-preview.pages.dev`;
150+
151+
let mentions = `@${triggerUser}`;
152+
if (prAuthor && prAuthor !== triggerUser) {
153+
mentions += ` @${prAuthor}`;
154+
}
155+
156+
const message = `🚀 **Preview Deployment Ready!**\n\ncc ${mentions}\n\nView changes live at:\n${previewUrl}`;
157+
158+
await github.rest.issues.updateComment({
159+
owner: context.repo.owner,
160+
repo: context.repo.repo,
161+
comment_id: ${{ needs.notify-preview-start.outputs.comment_id }},
162+
body: message,
163+
});
164+
165+
- name: Mark Preview as Failed
166+
if: failure()
167+
uses: actions/github-script@v7
168+
with:
169+
script: |
170+
await github.rest.issues.updateComment({
171+
owner: context.repo.owner,
172+
repo: context.repo.repo,
173+
comment_id: ${{ needs.notify-preview-start.outputs.comment_id }},
174+
body: `❌ **Preview build failed.** Check the [workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.`,
175+
});
176+
177+
cleanup-preview:
178+
name: Delete Cloudflare Pages Preview
179+
if: github.event.action == 'closed'
180+
runs-on: ubuntu-latest
181+
steps:
182+
- name: Delete Cloudflare Pages Branch via API
183+
env:
184+
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
185+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
186+
run: |
187+
BRANCH_NAME="pr-${{ github.event.number }}"
188+
PROJECT_NAME="python-ledger-preview"
189+
190+
echo "Deleting Cloudflare Pages branch: $BRANCH_NAME"
191+
192+
curl -s -X DELETE \
193+
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/branches/${BRANCH_NAME}" \
194+
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
195+
-H "Content-Type: application/json"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, { useMemo } from 'react';
2+
import MDXComponents from '@theme-original/MDXComponents';
3+
import styles from './styles.module.css';
4+
5+
const MEMORY_GRAPH_BASE_URL = 'https://memory-graph.com/';
6+
7+
/**
8+
* Wraps a ```lang debug code block with a header bar linking out to
9+
* Bas Terwijn's Memory Graph web debugger (memory-graph.com), so students
10+
* can step through the exact snippet from the lesson and watch variables/
11+
* references change line by line.
12+
*
13+
* Unlike InteractivePython, this doesn't replace the block with an editor —
14+
* the theme's normal <pre> still renders (syntax highlighting, copy button,
15+
* line numbers all keep working). We just read the raw code to build a
16+
* deep link and drop a button above it.
17+
*
18+
* Memory Graph reads code from a URL *fragment* (`#code=...`), not a query
19+
* string — that's deliberate on their end, it means the code never gets
20+
* sent to their server, it's only ever interpreted client-side once the
21+
* page loads. Don't "fix" this to `?code=`.
22+
*/
23+
export default function MemoryGraphBlock(props) {
24+
const { children } = props;
25+
const rawCode = children?.props?.children?.trim() || '';
26+
27+
const memoryGraphUrl = useMemo(
28+
() => `${MEMORY_GRAPH_BASE_URL}#code=${encodeURIComponent(rawCode)}`,
29+
[rawCode]
30+
);
31+
32+
return (
33+
<div className={styles.wrapper}>
34+
<div className={styles.header}>
35+
<span className={styles.headerText}>Debug Mode</span>
36+
<a
37+
href={memoryGraphUrl}
38+
target="_blank"
39+
rel="noopener noreferrer"
40+
className={styles.openButton}
41+
>
42+
Open in Memory Graph ↗
43+
</a>
44+
</div>
45+
<MDXComponents.pre {...props} />
46+
</div>
47+
);
48+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* ==========================================================================
2+
Reuses the site's existing purple accent (see .alert--explore in
3+
custom.css) so "debug" blocks read as their own category, distinct
4+
from the blue InteractivePython header.
5+
========================================================================== */
6+
.wrapper {
7+
margin: 1.5rem 0;
8+
}
9+
10+
.header {
11+
display: flex;
12+
align-items: center;
13+
justify-content: space-between;
14+
flex-wrap: wrap;
15+
gap: 12px;
16+
padding: 10px 16px;
17+
background-color: rgba(124, 58, 237, 0.08);
18+
border: 1px solid rgba(124, 58, 237, 0.35);
19+
border-bottom: none;
20+
border-radius: var(--ifm-global-radius) var(--ifm-global-radius) 0 0;
21+
}
22+
23+
[data-theme='dark'] .header {
24+
background-color: rgba(168, 85, 247, 0.15);
25+
border-color: rgba(216, 180, 254, 0.3);
26+
}
27+
28+
.headerText {
29+
font-family: var(--ifm-font-family-base, sans-serif);
30+
font-weight: 600;
31+
font-size: 0.85rem;
32+
letter-spacing: 0.05rem;
33+
text-transform: uppercase;
34+
color: #7c3aed;
35+
}
36+
37+
[data-theme='dark'] .headerText {
38+
color: #e9d5ff;
39+
}
40+
41+
.openButton {
42+
display: inline-flex;
43+
align-items: center;
44+
gap: 6px;
45+
padding: 6px 14px;
46+
border-radius: 6px;
47+
background-color: #7c3aed;
48+
color: #ffffff !important; /* override Docusaurus's default link color */
49+
font-family: var(--ifm-font-family-base, sans-serif);
50+
font-weight: 600;
51+
font-size: 0.85rem;
52+
text-decoration: none !important;
53+
white-space: nowrap;
54+
transition: background-color 0.15s ease;
55+
}
56+
57+
.openButton:hover {
58+
background-color: #6d28d9;
59+
color: #ffffff !important;
60+
}
61+
62+
/* Butt the code block directly against the header — no gap, no double border */
63+
.wrapper :global(.theme-code-block) {
64+
margin-top: 0 !important;
65+
border-top-left-radius: 0 !important;
66+
border-top-right-radius: 0 !important;
67+
border-top: none !important;
68+
}
69+
70+
@media (max-width: 480px) {
71+
.header {
72+
justify-content: center;
73+
text-align: center;
74+
}
75+
}

‎src/theme/MDXComponents.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import MDXComponents from '@theme-original/MDXComponents';
22
import InteractivePython from '@site/src/components/InteractivePython';
3+
import MemoryGraphBlock from '@site/src/components/MemoryGraphBlock';
34

45
export default {
56
...MDXComponents,
67
// We wrap the standard 'pre' tag (which wraps code blocks)
78
pre: (props) => {
8-
const isInteractive = props.children?.props?.metastring?.includes('interactive');
9-
10-
if (isInteractive) {
9+
const metastring = props.children?.props?.metastring || '';
10+
11+
if (metastring.includes('interactive')) {
1112
return <InteractivePython {...props} />;
1213
}
14+
if (metastring.includes('debug')) {
15+
return <MemoryGraphBlock {...props} />;
16+
}
1317
return <MDXComponents.pre {...props} />;
1418
},
1519
};

0 commit comments

Comments
 (0)