diff --git a/.github/ISSUE_TEMPLATE/05-new-zine.yml b/.github/ISSUE_TEMPLATE/05-new-zine.yml new file mode 100644 index 00000000..4a06fbb7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/05-new-zine.yml @@ -0,0 +1,115 @@ +name: "📚 New Zine" +description: Submit an Activity Guide zine for the PCD Zine Library. +title: "New Zine: " +labels: + - new zine + - needs review +body: + - type: markdown + attributes: + value: | + + Everything in this issue, including the PDF links and notes, is public. Do not include private or sensitive information. We stage accepted submissions for maintainer review before publishing them. + - type: input + id: title + attributes: + label: Title + description: The public title of your activity guide. + validations: + required: true + - type: input + id: topic + attributes: + label: Topic + description: The subject or theme of the guide. + validations: + required: true + - type: input + id: tags + attributes: + label: Tags + description: Optional comma-separated tags that help describe the guide. + placeholder: beginner, p5.js, physical computing + - type: input + id: creators + attributes: + label: Creator(s) + description: The person or group to credit. + validations: + required: true + - type: input + id: creator_url + attributes: + label: Creator URL + description: Optional public website for the credited person or group. + placeholder: https://example.org/ + - type: textarea + id: summary + attributes: + label: Short summary + description: A concise description for the library card. + validations: + required: true + - type: textarea + id: description + attributes: + label: Full description + description: Public description shown on the guide page. Markdown is supported. + validations: + required: true + - type: input + id: reader_order_pdf + attributes: + label: Reader-order PDF URL + description: A PDF whose reading order follows the content for accessible reading. + placeholder: https://example.org/my-guide-reader-order.pdf + validations: + required: true + - type: input + id: print_ready_pdf + attributes: + label: Print-ready PDF URL + description: A PDF prepared for printing, folding, or other physical distribution. + placeholder: https://example.org/my-guide-print-ready.pdf + validations: + required: true + - type: input + id: activity_format + attributes: + label: Activity format + description: Optional, for example workshop, discussion, or creative exercise. + - type: input + id: duration + attributes: + label: Duration + description: Optional, for example 45 minutes or 2 hours. + - type: textarea + id: materials + attributes: + label: Materials + description: Optional materials participants need. + - type: input + id: attribution + attributes: + label: Preferred attribution + description: Optional attribution wording. + - type: textarea + id: maintainer_notes + attributes: + label: Maintainer notes + description: Optional notes for maintainers. These notes are public. + - type: checkboxes + id: license_consent + attributes: + label: License consent + options: + - label: I own or have permission to license this material under CC BY-SA 4.0. + required: true + - type: checkboxes + id: link_consent + attributes: + label: Public, stable PDF links + description: Both links must work without login and must not be short-lived or expiring. + options: + - label: I confirm both PDF links are publicly accessible without login and are stable, non-expiring links. + required: true diff --git a/.github/scripts/fixtures/zines/zine-integration-test-fixture/guide-print.pdf b/.github/scripts/fixtures/zines/zine-integration-test-fixture/guide-print.pdf new file mode 100644 index 00000000..a2ccecc9 Binary files /dev/null and b/.github/scripts/fixtures/zines/zine-integration-test-fixture/guide-print.pdf differ diff --git a/.github/scripts/fixtures/zines/zine-integration-test-fixture/guide-small.pdf b/.github/scripts/fixtures/zines/zine-integration-test-fixture/guide-small.pdf new file mode 100644 index 00000000..a2ccecc9 Binary files /dev/null and b/.github/scripts/fixtures/zines/zine-integration-test-fixture/guide-small.pdf differ diff --git a/.github/scripts/fixtures/zines/zine-integration-test-fixture/index.md b/.github/scripts/fixtures/zines/zine-integration-test-fixture/index.md new file mode 100644 index 00000000..3f893e57 --- /dev/null +++ b/.github/scripts/fixtures/zines/zine-integration-test-fixture/index.md @@ -0,0 +1,6 @@ +--- +id: zine-integration-test-fixture +order: 13 +--- + +Use a loop to draw a playful field of shapes. Try changing the colours, sizes, and spacing together. diff --git a/.github/scripts/fixtures/zines/zine-integration-test-fixture/metadata.json b/.github/scripts/fixtures/zines/zine-integration-test-fixture/metadata.json new file mode 100644 index 00000000..8f8ad711 --- /dev/null +++ b/.github/scripts/fixtures/zines/zine-integration-test-fixture/metadata.json @@ -0,0 +1,22 @@ +{ + "id": "zine-integration-test-fixture", + "title": "Loops with Shapes", + "topic": "Loops", + "tags": ["beginner", "p5.js"], + "created_by": "Integration Test Author", + "attribution": "Integration Test Author, CC BY-SA 4.0", + "format": "workshop", + "duration": "2 hours", + "materials": "Laptop and p5.js editor", + "summary": "A compact guide to making patterns with repeated shapes.", + "cover": { + "src": "cover.png", + "alt": "Loops with Shapes zine cover" + }, + "pdfs": [ + { "file": "guide-small.pdf", "label": "Read on screen", "file_size": "96 B" }, + { "file": "guide-print.pdf", "label": "Print and fold", "file_size": "96 B" } + ], + "license": "CC BY-SA 4.0", + "source_url": "https://discourse.processing.org/t/activity-guide-integration-test/1" +} diff --git a/.github/scripts/process-new-zine-issue.mjs b/.github/scripts/process-new-zine-issue.mjs new file mode 100644 index 00000000..8547c824 --- /dev/null +++ b/.github/scripts/process-new-zine-issue.mjs @@ -0,0 +1,135 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { isValidHttpUrl, parseIssueSections, required, slugify } from './event-issue-helpers.mjs'; +import { ZINE_TEMPLATE_HEADING, hasCheckedConsent, makeDraftMarkdown, makeZinePrBody, zineValidationComment } from './zine-intake-helpers.mjs'; + +const WORKSPACE = process.cwd(); +const RUNNER_TEMP = process.env.RUNNER_TEMP ?? path.join(WORKSPACE, '.tmp'); +const OUTPUT_PATH = process.env.GITHUB_OUTPUT; +const payload = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, 'utf8')); +const issue = payload.issue; +const issueNumber = issue.number; +const issueBody = issue.body ?? ''; + +async function setOutput(key, value) { + if (OUTPUT_PATH) await fs.appendFile(OUTPUT_PATH, `${key}=${String(value)}\n`); +} + +function issueUrl() { + if (issue.html_url) return issue.html_url; + const repository = payload.repository?.full_name ?? process.env.GITHUB_REPOSITORY; + return repository ? `${process.env.GITHUB_SERVER_URL ?? 'https://github.com'}/${repository}/issues/${issueNumber}` : `issue #${issueNumber}`; +} + +async function folderExists(folder) { + try { await fs.access(folder); return true; } catch { return false; } +} + +async function main() { + await fs.mkdir(RUNNER_TEMP, { recursive: true }); + if (!issueBody.includes(ZINE_TEMPLATE_HEADING)) { + console.log('[process-new-zine-issue] template heading not found — skipping'); + await setOutput('valid', 'skip'); + return; + } + + const fields = parseIssueSections(issueBody); + const errors = []; + const title = required(fields, 'Title', errors); + const topic = required(fields, 'Topic', errors); + const tags = [...new Set((fields.get('Tags') ?? '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean))]; + const createdBy = required(fields, 'Creator(s)', errors); + const createdByUrl = fields.get('Creator URL')?.trim() ?? ''; + if (createdByUrl && !isValidHttpUrl(createdByUrl)) { + errors.push({ field: 'Creator URL', found: createdByUrl, message: 'Enter a valid HTTP(S) URL.' }); + } + const summary = required(fields, 'Short summary', errors); + const description = required(fields, 'Full description', errors); + const readerOrderUrl = fields.get('Reader-order PDF URL')?.trim() ?? ''; + const printReadyUrl = fields.get('Print-ready PDF URL')?.trim() ?? ''; + if (!readerOrderUrl) errors.push({ field: 'Reader-order PDF URL', message: 'This field is required.' }); + else if (!isValidHttpUrl(readerOrderUrl)) errors.push({ field: 'Reader-order PDF URL', found: readerOrderUrl, message: 'Enter a valid HTTP(S) URL.' }); + if (!printReadyUrl) errors.push({ field: 'Print-ready PDF URL', message: 'This field is required.' }); + else if (!isValidHttpUrl(printReadyUrl)) errors.push({ field: 'Print-ready PDF URL', found: printReadyUrl, message: 'Enter a valid HTTP(S) URL.' }); + if (!hasCheckedConsent(fields.get('License consent'))) errors.push({ field: 'License consent', message: 'Confirm that the material can be licensed under CC BY-SA 4.0.' }); + if (!hasCheckedConsent(fields.get('Public, stable PDF links'))) errors.push({ field: 'Public, stable PDF links', message: 'Confirm that both links are public, stable, and non-expiring.' }); + + const slug = slugify(title); + if (!slug) errors.push({ field: 'Title', message: 'Use at least one letter or number so we can create a URL slug.' }); + const publishedDir = path.join(WORKSPACE, 'pcd-website/src/content/zines', slug); + const draftDir = path.join(WORKSPACE, 'pcd-website/src/content/zines-drafts', slug); + const reservedDraftSlugs = new Set((process.env.ZINE_RESERVED_DRAFT_SLUGS ?? '').split(',').filter(Boolean)); + if (slug && reservedDraftSlugs.has(slug)) { + errors.push({ field: 'Title', found: title, message: `The generated slug \`${slug}\` is already staged by another open zine review PR.` }); + } else if (slug && await folderExists(publishedDir)) { + errors.push({ field: 'Title', found: title, message: `The generated slug \`${slug}\` already exists in published or draft zines.` }); + } else if (slug && await folderExists(draftDir)) { + let existingIssueNumber; + try { + existingIssueNumber = JSON.parse(await fs.readFile(path.join(draftDir, 'submission.json'), 'utf8')).intake?.issue_number; + } catch { + // A malformed draft must not be overwritten by an unrelated submission. + } + if (existingIssueNumber !== issueNumber) { + errors.push({ field: 'Title', found: title, message: `The generated slug \`${slug}\` already exists in published or draft zines.` }); + } + } + + if (errors.length) { + const validationCommentPath = path.join(RUNNER_TEMP, `zine-validation-${issueNumber}.md`); + await fs.writeFile(validationCommentPath, zineValidationComment(errors)); + await setOutput('valid', 'false'); + await setOutput('validation_comment_path', validationCommentPath); + return; + } + + const submission = { + id: slug, + title, + topic, + created_by: createdBy, + summary, + source_pdfs: [ + { role: 'reader-order', url: readerOrderUrl }, + { role: 'print-ready', url: printReadyUrl }, + ], + license: 'CC BY-SA 4.0', + source_issue_url: issueUrl(), + intake: { + issue_number: issueNumber, + submitted_by_github: issue.user?.login ?? '', + submitted_date: new Date().toISOString().slice(0, 10), + maintainer_notes: fields.get('Maintainer notes')?.trim() ?? '', + }, + description, + }; + if (tags.length) submission.tags = tags; + if (createdByUrl) submission.created_by_url = createdByUrl; + for (const [field, key] of [['Activity format', 'format'], ['Duration', 'duration'], ['Materials', 'materials'], ['Preferred attribution', 'attribution']]) { + const value = fields.get(field)?.trim(); + if (value) submission[key] = value; + } + await fs.mkdir(draftDir, { recursive: true }); + await fs.writeFile(path.join(draftDir, 'submission.json'), `${JSON.stringify(submission, null, 2)}\n`); + await fs.writeFile(path.join(draftDir, 'index.md'), makeDraftMarkdown(slug, description)); + const prBodyPath = path.join(RUNNER_TEMP, `zine-pr-body-${issueNumber}.md`); + await fs.writeFile(prBodyPath, makeZinePrBody({ issueNumber, title, submitterLogin: issue.user?.login ?? '', submission })); + await setOutput('valid', 'true'); + await setOutput('branch', `automation/new-zine-${issueNumber}`); + await setOutput('commit_message', `Stage ${title} zine from issue #${issueNumber}`); + await setOutput('pr_title', `Review zine: ${title}`); + await setOutput('pr_body_path', prBodyPath); + await setOutput('zine_title', title); +} + +await main().catch(async (error) => { + console.error('[process-new-zine-issue] unhandled error:', error); + const validationCommentPath = path.join(RUNNER_TEMP, `zine-validation-${issueNumber}.md`); + await fs.writeFile(validationCommentPath, 'An unexpected error occurred while processing this zine submission. Please edit and save the issue to try again.'); + await setOutput('valid', 'false'); + await setOutput('validation_comment_path', validationCommentPath); + process.exitCode = 1; +}); diff --git a/.github/scripts/process-new-zine-issue.test.mjs b/.github/scripts/process-new-zine-issue.test.mjs new file mode 100644 index 00000000..5b37fa87 --- /dev/null +++ b/.github/scripts/process-new-zine-issue.test.mjs @@ -0,0 +1,139 @@ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const SCRIPT = new URL('./process-new-zine-issue.mjs', import.meta.url).pathname; +const ROOT = path.resolve(path.dirname(SCRIPT), '../..'); +const DRAFTS = path.join(ROOT, 'pcd-website/src/content/zines-drafts'); +const PUBLISHED = path.join(ROOT, 'pcd-website/src/content/zines'); +const created = new Set(); + +function body({ title = 'Intake Test Zine 781', tags = '', creatorUrl = '', readerUrl = 'https://example.org/reader.pdf', printUrl = 'https://example.org/print.pdf', license = true, links = true, optional = false } = {}) { + return [ + '### Title', title, '', '### Topic', 'Creative coding', '', '### Creator(s)', 'Test Creator', '', + '### Tags', tags || (optional ? 'beginner, creative coding, beginner' : '_No response_'), '', + '### Creator URL', creatorUrl || '_No response_', '', + '### Short summary', 'A concise test summary.', '', '### Full description', 'A full **test** description.', '', + '### Reader-order PDF URL', readerUrl, '', '### Print-ready PDF URL', printUrl, '', + '### Activity format', optional ? 'Workshop' : '_No response_', '', '### Duration', optional ? '90 minutes' : '_No response_', '', + '### Materials', optional ? 'Paper and pens' : '_No response_', '', '### Preferred attribution', optional ? 'Test Creator, CC BY-SA 4.0' : '_No response_', '', + '### Maintainer notes', optional ? 'Public maintainer note.' : '_No response_', '', + '### License consent', license ? '- [x] I own or have permission to license this material under CC BY-SA 4.0.' : '- [ ] I own or have permission to license this material under CC BY-SA 4.0.', '', + '### Public, stable PDF links', links ? '- [x] I confirm both PDF links are publicly accessible without login and are stable, non-expiring links.' : '- [ ] I confirm both PDF links are publicly accessible without login and are stable, non-expiring links.', + ].join('\n'); +} + +async function run(issueBody, { number = 781, login = 'zine-tester' } = {}) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'pnzi-test-')); + const eventPath = path.join(tmp, 'event.json'); + const outputPath = path.join(tmp, 'output.txt'); + await fs.writeFile(eventPath, JSON.stringify({ repository: { full_name: 'processing/processing-community-day' }, issue: { number, body: issueBody, html_url: `https://github.com/processing/processing-community-day/issues/${number}`, user: { login } } })); + await fs.writeFile(outputPath, ''); + let exitCode = 0; + try { await execFileAsync(process.execPath, [SCRIPT], { cwd: ROOT, env: { ...process.env, GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath, RUNNER_TEMP: tmp } }); } + catch (error) { exitCode = error.code ?? 1; } + const raw = await fs.readFile(outputPath, 'utf8'); + const outputs = Object.fromEntries(raw.trim().split('\n').filter(Boolean).map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)])); + return { tmp, outputs, exitCode }; +} + +after(async () => { + await Promise.all([...created].map((slug) => fs.rm(path.join(DRAFTS, slug), { recursive: true, force: true }))); +}); + +describe('process-new-zine-issue', () => { + test('stages valid submissions with exact source roles, provenance, optional metadata, and markdown', async () => { + const title = 'Intake Test Zine 781'; + const slug = 'intake-test-zine-781'; + created.add(slug); + const { tmp, outputs } = await run(body({ title, optional: true })); + assert.equal(outputs.valid, 'true'); + assert.equal(outputs.branch, 'automation/new-zine-781'); + const submission = JSON.parse(await fs.readFile(path.join(DRAFTS, slug, 'submission.json'), 'utf8')); + assert.deepEqual(submission, { + id: slug, title, topic: 'Creative coding', tags: ['beginner', 'creative coding'], created_by: 'Test Creator', summary: 'A concise test summary.', + source_pdfs: [{ role: 'reader-order', url: 'https://example.org/reader.pdf' }, { role: 'print-ready', url: 'https://example.org/print.pdf' }], + license: 'CC BY-SA 4.0', source_issue_url: 'https://github.com/processing/processing-community-day/issues/781', + intake: { issue_number: 781, submitted_by_github: 'zine-tester', submitted_date: new Date().toISOString().slice(0, 10), maintainer_notes: 'Public maintainer note.' }, + description: 'A full **test** description.', format: 'Workshop', duration: '90 minutes', materials: 'Paper and pens', attribution: 'Test Creator, CC BY-SA 4.0', + }); + assert.equal(await fs.readFile(path.join(DRAFTS, slug, 'index.md'), 'utf8'), '---\nid: "intake-test-zine-781"\n---\n\nA full **test** description.\n'); + const prBody = await fs.readFile(outputs.pr_body_path, 'utf8'); + assert.match(prBody, /Reader-order PDF/); + assert.match(prBody, /\| Tags \| beginner, creative coding \|/); + assert.match(prBody, /commit them as `reader-order.pdf` and `print-ready.pdf`/); + await fs.rm(tmp, { recursive: true, force: true }); + }); + + test('edited issues overwrite their own staged inputs and retain the stable branch', async () => { + const slug = 'intake-test-zine-781'; + const { tmp, outputs } = await run(body({ title: 'Intake Test Zine 781', readerUrl: 'https://example.org/updated-reader.pdf' })); + assert.equal(outputs.valid, 'true'); + assert.equal(outputs.branch, 'automation/new-zine-781'); + const submission = JSON.parse(await fs.readFile(path.join(DRAFTS, slug, 'submission.json'), 'utf8')); + assert.equal(submission.source_pdfs[0].url, 'https://example.org/updated-reader.pdf'); + await fs.rm(tmp, { recursive: true, force: true }); + }); + + test('stages an optional creator URL and rejects an invalid one', async () => { + const slug = 'intake-test-zine-creator-url'; + created.add(slug); + let result = await run(body({ title: 'Intake Test Zine Creator URL', creatorUrl: 'https://example.org/creator' }), { number: 785 }); + assert.equal(result.outputs.valid, 'true'); + const submission = JSON.parse(await fs.readFile(path.join(DRAFTS, slug, 'submission.json'), 'utf8')); + assert.equal(submission.created_by_url, 'https://example.org/creator'); + await fs.rm(result.tmp, { recursive: true, force: true }); + + result = await run(body({ title: 'Intake Test Zine Invalid Creator URL', creatorUrl: 'ftp://example.org/creator' }), { number: 786 }); + assert.equal(result.outputs.valid, 'false'); + const comment = await fs.readFile(result.outputs.validation_comment_path, 'utf8'); + assert.match(comment, /Creator URL/); + await fs.rm(result.tmp, { recursive: true, force: true }); + }); + + test('reports missing fields, bad links, and unchecked consents in one actionable comment', async () => { + const { tmp, outputs } = await run(body({ title: '', readerUrl: 'ftp://example.org/file.pdf', printUrl: '', license: false, links: false }), { number: 782 }); + assert.equal(outputs.valid, 'false'); + const comment = await fs.readFile(outputs.validation_comment_path, 'utf8'); + assert.match(comment, /Title/); assert.match(comment, /Reader-order PDF URL/); assert.match(comment, /Print-ready PDF URL/); + assert.match(comment, /License consent/); assert.match(comment, /Public, stable PDF links/); + await fs.rm(tmp, { recursive: true, force: true }); + }); + + test('rejects duplicate published and other-issue draft slugs, and skips unrelated templates', async () => { + const publishedSlug = 'intake-published-conflict'; + await fs.mkdir(path.join(PUBLISHED, publishedSlug), { recursive: true }); + try { + let result = await run(body({ title: 'Intake Published Conflict' }), { number: 783 }); + assert.equal(result.outputs.valid, 'false'); + await fs.rm(result.tmp, { recursive: true, force: true }); + } finally { await fs.rm(path.join(PUBLISHED, publishedSlug), { recursive: true, force: true }); } + const draftSlug = 'intake-draft-conflict'; + created.add(draftSlug); + await fs.mkdir(path.join(DRAFTS, draftSlug), { recursive: true }); + await fs.writeFile(path.join(DRAFTS, draftSlug, 'submission.json'), JSON.stringify({ intake: { issue_number: 1 } })); + let result = await run(body({ title: 'Intake Draft Conflict' }), { number: 784 }); + assert.equal(result.outputs.valid, 'false'); + await fs.rm(result.tmp, { recursive: true, force: true }); + result = await run('### Title\nNot a zine'); + assert.equal(result.outputs.valid, 'skip'); + await fs.rm(result.tmp, { recursive: true, force: true }); + }); + + test('workflow keeps edited issues on one branch and upserts one marked status comment', async () => { + const workflow = await fs.readFile(path.join(ROOT, '.github/workflows/new-zine-intake.yml'), 'utf8'); + const processor = await fs.readFile(SCRIPT, 'utf8'); + assert.match(workflow, /branch: \$\{\{ needs\.process-new-zine\.outputs\.branch \}\}/); + assert.match(processor, /automation\/new-zine-\$\{issueNumber\}/); + assert.match(processor, /ZINE_RESERVED_DRAFT_SLUGS/); + assert.match(workflow, /STATUS_COMMENT_MARKER: /); + assert.match(workflow, /pulls\.listFiles/); + assert.match(workflow, /const existing = comments\.find/); + assert.match(workflow, /issues\.updateComment/); + }); +}); diff --git a/.github/scripts/zine-build.test.mjs b/.github/scripts/zine-build.test.mjs new file mode 100644 index 00000000..b6d62dbd --- /dev/null +++ b/.github/scripts/zine-build.test.mjs @@ -0,0 +1,116 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const REPO_ROOT = resolve(new URL('../..', import.meta.url).pathname); +const WEBSITE = join(REPO_ROOT, 'pcd-website'); +const ZINES_DIR = join(WEBSITE, 'src/content/zines'); +const FIXTURE = join(REPO_ROOT, '.github/scripts/fixtures/zines/zine-integration-test-fixture'); +const SLUG = 'zine-integration-test-fixture'; +const DEST = join(ZINES_DIR, SLUG); +const DIST = join(WEBSITE, 'dist'); + +function emittedPath(href) { + assert.ok(!href.startsWith('data:'), `asset href must not be a data URI: ${href}`); + return join(DIST, new URL(href, 'https://day.processing.org').pathname.replace(/^\//, '')); +} + +function hrefForFilename(html, filename) { + const match = html.match(new RegExp(`]+href="([^"]+)"[^>]+download="${filename}"`)); + assert.ok(match, `expected a download for "${filename}"`); + return match[1]; +} + +test('a populated zine collection emits linked assets and renders entries in frontmatter order', () => { + assert.ok(!existsSync(DEST), `${DEST} already exists — refusing to overwrite`); + let created = true; + try { + cpSync(FIXTURE, DEST, { recursive: true }); + copyFileSync(join(WEBSITE, 'src/images/og-image.png'), join(DEST, 'cover.png')); + execFileSync('npm', ['run', 'build'], { cwd: WEBSITE, stdio: 'pipe' }); + + const pagePath = join(DIST, 'activity-guide', SLUG, 'index.html'); + assert.ok(existsSync(pagePath), 'the zine page should be generated'); + const page = readFileSync(pagePath, 'utf8'); + assert.match(page, /Loops with Shapes/); + assert.match(page, /View the original submission/); + const coverIndex = page.indexOf('class="activity-guide__cover"'); + const titleIndex = page.indexOf('>Loops with Shapes'); + const authorIndex = page.indexOf('class="activity-guide__author'); + const tagsIndex = page.indexOf('class="activity-guide__tags"'); + const descriptionIndex = page.indexOf('Use a loop to draw a playful field of shapes.'); + const detailsSeparatorIndex = page.indexOf(']*>Format<\/dt>\s*]*>workshop<\/dd>/); + assert.match(page, /]*>Duration<\/dt>\s*]*>2 hours<\/dd>/); + assert.match(page, /]*>Required materials<\/dt>\s*]*>Laptop and p5\.js editor<\/dd>/); + assert.match(page, /]*>Topic<\/dt>\s*]*>Loops<\/dd>/); + assert.match( + page, + /