Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"id": "zine-integration-test-fixture",
"title": "Loops with Shapes",
"topic": "Loops",
"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": "cover.png",
"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"
}
90 changes: 90 additions & 0 deletions .github/scripts/zine-build.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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(`<a[^>]+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/);

for (const filename of ['guide-small.pdf', 'guide-print.pdf']) {
assert.ok(existsSync(emittedPath(hrefForFilename(page, filename))), `${filename} should resolve to an emitted PDF`);
}
assert.match(page, /download-list__size[^>]*>96 B</);
const pageCover = page.match(/<img[^>]+src="([^"]+)"/);
assert.ok(pageCover, 'the zine page should render a cover image');
assert.ok(existsSync(emittedPath(pageCover[1])), 'the zine cover should be emitted');

const noCoverPage = readFileSync(join(DIST, 'activity-guide/zine-making-kit/index.html'), 'utf8');
assert.match(noCoverPage, /activity-guide__cover--placeholder[^>]*>Zine Making Kit</);
assert.equal(
hrefForFilename(noCoverPage, 'B230_zinemakingactivity.pdf'),
'https://guides.loc.gov/ld.php?content_id=67687837',
);
assert.match(noCoverPage, /download-list__size[^>]*>519 kB</);

const library = readFileSync(join(DIST, 'organize/activity-guides/zine-library/index.html'), 'utf8');
assert.match(library, /<ul class="guide-grid">\s*<li>\s*<a class="guide-card guide-card--zine" href="\/activity-guide\/zine-making-kit\/"/);
assert.match(library, /guide-card__cover-placeholder[^>]*>Zine Making Kit</);
assert.match(library, new RegExp(`href="/activity-guide/${SLUG}/"`));
assert.match(library, /A compact guide to making patterns with repeated shapes\./);
assert.ok(
library.indexOf('/activity-guide/zine-making-kit/') < library.indexOf('<strong>Variables</strong>'),
'order 1 should render before order 2',
);
assert.ok(
library.indexOf('<strong>Randomness</strong>') < library.indexOf(`/activity-guide/${SLUG}/`),
'order 12 should render before order 13',
);
assert.equal((library.match(/Submit a zine/g) ?? []).length, 11, 'each placeholder should render a submission button');
assert.doesNotMatch(library, /Guide wanted/);
const variablesLink = library.match(/<strong>Variables<\/strong>[\s\S]*?<a[^>]+href="([^"]+)"/);
assert.ok(variablesLink, 'the Variables placeholder should have a submission link');
const variablesUrl = new URL(variablesLink[1].replaceAll('&amp;', '&'));
assert.equal(variablesUrl.searchParams.get('title'), 'Activity Guide Submission: Variables');
assert.match(
variablesUrl.searchParams.get('body') ?? '',
/\*\*Title:\*\* Variables/,
'a placeholder submission should pre-populate its topic in the submission body',
);
assert.equal(existsSync(join(DIST, 'activity-guide/variables/index.html')), false, 'placeholders should not get detail pages');
const grid = library.match(/<ul class="guide-grid">([\s\S]*?)<\/ul>/);
assert.ok(grid, 'the library should render its grid');
assert.equal((grid[1].match(/<li>/g) ?? []).length, 14, 'the grid should contain all file-backed entries plus submission');
const gridCover = grid[1].match(/<img[^>]+src="([^"]+)"/);
assert.ok(gridCover, 'the zine card should render a cover image');
assert.ok(existsSync(emittedPath(gridCover[1])), 'the card cover should be emitted');
} finally {
if (created) rmSync(DEST, { recursive: true, force: true });
}
});
94 changes: 94 additions & 0 deletions .github/scripts/zines.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import {
assertIdentity, assertUniqueIds, parseZineMetadata,
resolveZineAssets, zineMetadataSchema,
} from '../../pcd-website/src/lib/zine-metadata.js';

const valid = () => ({
id: 'loops-with-shapes', title: 'Loops with Shapes', topic: 'Loops',
created_by: 'Guide Author', summary: 'Make patterns with repeated shapes.',
cover: 'cover.png', pdfs: [{ file: 'guide.pdf', label: 'Read on screen', file_size: '24 kB' }],
license: 'CC BY-SA 4.0',
});

describe('zine metadata', () => {
test('accepts valid metadata', () => {
assert.deepEqual(parseZineMetadata(valid(), 'loops-with-shapes'), valid());
const withoutCover = { ...valid(), cover: undefined };
assert.deepEqual(parseZineMetadata(withoutCover, 'loops-with-shapes'), withoutCover);
const externalPdf = {
...valid(), cover: undefined, license: undefined,
pdfs: [{
url: 'https://example.com/guide.pdf', label: 'Download guide',
filename: 'guide.pdf', file_size: '24 kB',
}],
};
assert.deepEqual(parseZineMetadata(externalPdf, 'loops-with-shapes'), externalPdf);
});

test('rejects non-object metadata cleanly', () => {
for (const input of [null, 'metadata', []]) {
assert.throws(() => parseZineMetadata(input, 'loops-with-shapes'), /Invalid metadata/);
}
});

test('validates required values, topics, ids, and strict object keys', () => {
const cases = [
[{ ...valid(), topic: ' ' }, /topic/],
[{ ...valid(), title: ' ' }, /title/],
[{ ...valid(), id: 'Not Kebab' }, /id/],
[{ ...valid(), unexpected: true }, /Unrecognized key/],
[{ ...valid(), cover: 'cover.PNG' }, /cover/],
[{ ...valid(), pdfs: [] }, /pdfs/],
[{ ...valid(), pdfs: [{ file: 'guide.PDF', label: 'PDF', file_size: '24 kB' }] }, /pdfs/],
[{ ...valid(), pdfs: [{ file: 'guide.txt', label: 'Text', file_size: '24 kB' }] }, /pdfs/],
[{ ...valid(), pdfs: [{ file: 'guide.pdf', file_size: '24 kB' }] }, /pdfs/],
[{ ...valid(), pdfs: [{ file: 'guide.pdf', label: 'PDF' }] }, /pdfs/],
[{ ...valid(), pdfs: [{ file: 'guide.pdf', label: 'PDF', file_size: '24 kB', extra: true }] }, /Unrecognized key/],
[{ ...valid(), pdfs: [{
url: 'javascript:alert(1)', label: 'PDF', filename: 'guide.pdf', file_size: '24 kB',
}] }, /pdfs/],
[{ ...valid(), license: 'CC BY 4.0' }, /license/],
];
for (const [input, message] of cases) {
assert.throws(() => parseZineMetadata(input, 'loops-with-shapes'), message);
}
});

test('rejects draft zines with the safe unpublished location', () => {
assert.throws(() => parseZineMetadata({ ...valid(), draft: true }, 'loops-with-shapes'), /zines-drafts/);
});

test('guards source URLs without allowing malformed values to escape', () => {
for (const source_url of ['https://example.com', 'http://example.com/path']) {
assert.equal(zineMetadataSchema.safeParse({ ...valid(), source_url }).success, true);
}
for (const source_url of ['javascript:alert(1)', 'data:text/html,test', 'not a url']) {
assert.doesNotThrow(() => zineMetadataSchema.safeParse({ ...valid(), source_url }));
assert.equal(zineMetadataSchema.safeParse({ ...valid(), source_url }).success, false);
}
});

test('checks asset existence', () => {
assert.deepEqual(resolveZineAssets('loops-with-shapes', valid(), ['cover.png', 'guide.pdf']), {
cover: 'cover.png', pdfs: ['guide.pdf'],
});
assert.deepEqual(resolveZineAssets('loops-with-shapes', {
...valid(), cover: undefined,
pdfs: [{
url: 'https://example.com/guide.pdf', label: 'Download guide',
filename: 'guide.pdf', file_size: '24 kB',
}],
}, []), { cover: undefined, pdfs: [] });
assert.throws(() => resolveZineAssets('loops-with-shapes', valid(), ['cover.png']), /guide.pdf/);
assert.throws(() => resolveZineAssets('loops-with-shapes', valid(), ['guide.pdf']), /cover.png/);
});

test('checks unique ids and three-way identity', () => {
const first = valid();
assert.throws(() => assertUniqueIds([first, { ...valid() }]), /Duplicate zine id/);
assert.throws(() => assertIdentity({ slug: 'loops-with-shapes', frontmatterId: 'loops', metadataId: 'loops-with-shapes' }), /identity mismatch/);
assert.doesNotThrow(() => assertIdentity({ slug: 'loops-with-shapes', frontmatterId: 'loops-with-shapes', metadataId: 'loops-with-shapes' }));
});
});
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ node --test .github/scripts/event-issue-helpers.test.mjs
node --test .github/scripts/process-new-event-issue.test.mjs
node --test .github/scripts/process-edit-event-issue.test.mjs
node --test .github/scripts/plus-code.test.mjs
node --test .github/scripts/zines.test.mjs
node --test .github/scripts/zine-build.test.mjs

# Requires npm run build from pcd-website/ first:
node --test .github/scripts/data-json.test.mjs
```

Need to run the tests end-to-end? `./scripts/run-tests.sh` executes the helper, intake, and plus-code suites, builds the Astro site via `npm --prefix pcd-website run build`, and then runs `data-json.test.mjs` in sequence. Run this script from the repo root after installing dependencies so you get the full battery of checks in one shot.
Need to run the tests end-to-end? `./scripts/run-tests.sh` executes the helper, intake, plus-code, and zine metadata suites; runs the zine fixture build; builds the Astro site via `npm --prefix pcd-website run build`; and then runs `data-json.test.mjs` in sequence. Run this script from the repo root after installing dependencies so you get the full battery of checks in one shot.

No install needed — `open-location-code` is already available at `pcd-website/node_modules/`.

Expand All @@ -66,6 +68,10 @@ Event data lives in `src/content/events/<event-id>/`:

`src/lib/nodes.ts` loads all events at Astro build time using `import.meta.glob()` + `getCollection('events')`, validates plus codes with `OpenLocationCode`, decodes lat/lng, and returns a sorted `Node[]` array passed as props to `<MapView>`.

Activity Guide cards live in `src/content/zines/<slug>/` and the library grid at `/organize/activity-guides/zine-library/` is built dynamically from every `*/index.md`, sorted by its required numeric `order` frontmatter. Published zines set `placeholder: false` implicitly and pair `index.md` with `metadata.json`, an optional cover image, and one or more PDF downloads. Downloads may be local sibling PDF assets or external http(s) URLs; metadata supplies the human-readable file size (and the filename for external files) used by the shared download rows. Placeholder topics contain only `index.md` with `title`, `order`, and `placeholder: true`; they render cards with a topic-prefilled “Submit a zine” link and do not generate detail pages. `src/lib/zines.ts` joins the Astro collection, metadata, and assets at build time; `src/lib/zine-metadata.js` owns the strict published-zine schema and pure validation. Zines must use `index.md` (not `content.md`) so Astro's glob loader makes the entry id equal to the folder slug. A published zine without a cover renders a grey title fallback in the library and on its detail page.

Zine PDFs are emitted from `src/` assets using `?url&no-inline`, so even small downloads become real files in `dist/`. `src/content/zines/` contains publishable zines only: it deliberately has no `draft` field because eager asset imports would make draft files public. Keep unfinished zines in `src/content/zines-drafts/`. Review PDFs for selectable text, logical reading order, document title and language, tagged headings where possible, alt text, and at least one screen-reader-friendly reading-order version.

The global Markdown pipeline runs `rehype-table-wrapper` and `rehype-heading-anchors`, which respectively wrap rendered tables in `.table-wrapper` and add permalink anchors to h2–h6. Their presentation styles live in the shared `prose.css` layer, scoped to both `.prose` and `.docs-prose`, because both plugins apply to all Markdown collections.

**If a plus_code is invalid or too short, the build fails with a clear error — this is intentional.**
Expand All @@ -87,14 +93,17 @@ The global Markdown pipeline runs `rehype-table-wrapper` and `rehype-heading-anc
| `src/components/MapView.vue` | Leaflet map, marker clustering, keyboard shortcuts |
| `src/components/NodePanel.vue` | Slide-in event detail panel with minimap, calendar links, share button |
| `src/components/LanguageSwitcher.vue` | Language selector dropdown in the top bar |
| `src/components/BackButton.astro` | Reusable button-style link for navigating from a detail page back to its parent listing |
| `src/components/CopyMarkdownButton.astro` | Copies an Organizer Kit page as Markdown with accessible success/error feedback |
| `src/components/ZineDownloads.astro` | Renders zine download rows with a button, filename, and human-readable file size |
| `src/components/Header.astro` | Shared fixed site header and primary navigation |
| `src/components/Footer.astro` | Shared site footer, policy links, community links, and sponsors |
| `src/layouts/BaseLayout.astro` | Shared HTML document shell and metadata |
| `src/layouts/MapLayout.astro` | Map-page shell and Leaflet stylesheet links |
| `src/layouts/SiteLayout.astro` | Standard static content-page shell |
| `src/layouts/DocsLayout.astro` | Organizer Kit shell with sidebar, page TOC, and footer |
| `src/lib/analytics.ts` | `trackEvent()` Fathom helper + `AnalyticsEvent` type + event-name constants |
| `src/lib/carto.ts` | Adds the optional local-development CARTO API key to basemap tile URLs |
| `src/lib/nodes.ts` | `Node` interface + `loadNodes()` |
| `src/lib/format.ts` | `formatDate()`, `formatDateRange()`, `calendarLinks()`, etc. |
| `src/lib/popup.ts` | Leaflet popup HTML generation (`makePopupContent()`) |
Expand All @@ -104,7 +113,10 @@ The global Markdown pipeline runs `rehype-table-wrapper` and `rehype-heading-anc
| `src/styles/docs/*.css` | Organizer Kit's modular Just-the-Docs-derived tokens, layout, navigation, and Markdown presentation styles |
| `src/lib/rehype-table-wrapper.mjs` | Markdown rehype plugin that wraps rendered tables for horizontal scrolling |
| `src/pages/data.json.ts` | Static JSON feed of confirmed events, served at /data.json |
| `src/content.config.ts` | Astro content collection Zod schema for events |
| `src/pages/activity-guide/[id].astro` | Standalone per-zine Activity Guide pages |
| `src/lib/zines.ts` | Build-time zine loader and topic-slot mapping |
| `src/lib/zine-metadata.js` | Zine schema and pure metadata/asset validation |
| `src/content.config.ts` | Astro content collection Zod schemas for events, legal pages, Organizer Kit, and zines |
| `src/config.ts` | Global static constants (contact email, etc.) |
| `src/i18n/index.ts` | Creates the `vue-i18n` instance and exports `syncLocale()` |
| `src/i18n/localeState.ts` | Reactive `currentLocale` ref, browser detection, localStorage persistence |
Expand Down Expand Up @@ -152,6 +164,8 @@ Use `src/config.ts` for static, non-secret values that are referenced across mul
- Anything already defined in `astro.config.mjs` (e.g. base path)
- Component-local constants that aren't shared

For local map development, `pcd-website/.env` may define `PUBLIC_CARTO_API_KEY`. During `npm run dev`, all CARTO raster tile URLs append it as the `key` query parameter; production builds do not embed it.

## UI / Styling Rules

- The site is light-mode only — there is no dark mode, no `[data-theme]` toggling, and no theme-related CSS. Do not reintroduce it without an explicit decision to do so.
Expand Down
15 changes: 14 additions & 1 deletion TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,22 @@ These tests cover the shared pure functions extracted into `event-issue-helpers.

---

## Activity Guide zines

**Files:** `.github/scripts/zines.test.mjs`, `.github/scripts/zine-build.test.mjs`
**Run:** `node --test .github/scripts/zines.test.mjs` and `node --test .github/scripts/zine-build.test.mjs`
**Requires:** The metadata suite uses the locally installed Astro dependency. The build suite owns a temporary fixture zine, builds the site, verifies emitted cover/PDF URLs and cleans up its fixture.

| Suite | Cases |
|---|---|
| `zines.test.mjs` | Schema, URL safety, draft rejection, identity/id uniqueness, optional covers, external downloads, and asset validation |
| `zine-build.test.mjs` | Published and placeholder cards loaded from files, frontmatter ordering, placeholder route exclusion, download metadata, cover/fallback rendering, emitted local assets, and source links |

---

## Single-command test run

Run `./scripts/run-tests.sh` from the repo root after installing dependencies (`pcd-website` already has `node_modules/` from `npm install`). The script executes the helper, intake, and plus-code suites, then builds the Astro site (`npm run build` inside `pcd-website/`) before running `data-json.test.mjs`. Use this single command whenever you want to verify the full test battery end to end.
Run `./scripts/run-tests.sh` from the repo root after installing dependencies (`pcd-website` already has `node_modules/` from `npm install`). The script executes the helper, intake, plus-code, and zine metadata suites; runs the zine fixture build; then builds the Astro site (`npm run build` inside `pcd-website/`) before running `data-json.test.mjs`. Use this single command whenever you want to verify the full test battery end to end.

---

Expand Down
10 changes: 10 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
to = "/og-image.png"
status = 301

[[redirects]]
from = "/organize/activity-guides/library/*"
to = "/organize/activity-guides/zine-library/"
status = 301

[[redirects]]
from = "/organize/activity-guides/what-are-activity-guides/*"
to = "/organize/activity-guides/zine-library/"
status = 301

[[headers]]
for = "/data.json"
[headers.values]
Expand Down
Loading