@port60/template-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PORT 60 LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @port60/template-kit
2
+
3
+ The official toolkit for building [Port60](https://port60.com) site templates — scaffold from
4
+ the starter, preview locally against the platform contract, validate with the exact checks the
5
+ platform runs at upload, and package for review.
6
+
7
+ Templates are small, versioned artifacts of [Liquid](https://liquidjs.com) renderers and CSS.
8
+ They contain no application code: the platform owns data, payments and compliance; your template
9
+ owns how a site looks.
10
+
11
+ ## Quickstart
12
+
13
+ ```bash
14
+ npx @port60/template-kit create my-template --name my-template
15
+ cd my-template && npm install
16
+ npm run dev # live preview at http://localhost:4400
17
+ npm run validate # the platform's conformance checks
18
+ npm run package # the uploadable <name>-<version>.zip
19
+ ```
20
+
21
+ ## Commands
22
+
23
+ | Command | What it does |
24
+ | --- | --- |
25
+ | `create <dir>` | A working template from the platform starter, npm scripts wired. |
26
+ | `dev [dir]` | Live preview over the contract's sample fixtures; validation re-runs on save. |
27
+ | `validate [dir] [--json]` | The exact checks the platform runs at upload. `--json` emits `{ok, errors, warnings, provenSupports}`. |
28
+ | `package [dir]` | Validate, then build the contract-shaped zip the studio accepts as-is. |
29
+
30
+ ## Building with an AI agent
31
+
32
+ Every scaffold ships `AGENTS.md` (and an identical `CLAUDE.md`) briefing any coding agent on
33
+ the contract rules and the iteration loop; `validate --json` is the machine feedback loop to
34
+ iterate against until `ok: true`. The full documentation is also published as a single
35
+ agent-consumable file at [developers.port60.com/llms-full.txt](https://developers.port60.com/llms-full.txt).
36
+
37
+ See the [AI quickstart](https://developers.port60.com/guides/ai-quickstart/).
38
+
39
+ ## The contract
40
+
41
+ The vendored contract in `src/vendor/contract` is the **Charity Platform contract v1** — the
42
+ platform's first product surface. The dialect, validation rules and this toolchain are
43
+ platform-wide; future Port60 products ship their own contract packs for the same kit.
44
+
45
+ Full documentation: [developers.port60.com](https://developers.port60.com)
46
+
47
+ ## Licence
48
+
49
+ [MIT](LICENSE) © PORT 60 LTD. Publishing a template on the Port60 marketplace is governed by the
50
+ [Developer Agreement](https://port60.com/developers/agreement/).
package/bin/cli.mjs ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // @port60/template-kit — build Port60 site templates locally. AI-agent ready: `create` scaffolds
3
+ // an AGENTS.md-briefed project; `validate --json` is the machine feedback loop; `dev` is the
4
+ // human's live preview; `package` produces the uploadable artifact.
5
+ import { create } from '../src/commands/create.mjs';
6
+ import { validate } from '../src/commands/validate.mjs';
7
+ import { dev } from '../src/commands/dev.mjs';
8
+ import { packageCmd } from '../src/commands/packageCmd.mjs';
9
+
10
+ function parseArgs(argv) {
11
+ const args = { _: [] };
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const a = argv[i];
14
+ if (a.startsWith('--')) {
15
+ const key = a.slice(2);
16
+ const next = argv[i + 1];
17
+ if (next !== undefined && !next.startsWith('--')) {
18
+ args[key] = next;
19
+ i++;
20
+ } else {
21
+ args[key] = true;
22
+ }
23
+ } else {
24
+ args._.push(a);
25
+ }
26
+ }
27
+ return args;
28
+ }
29
+
30
+ const [command, ...rest] = process.argv.slice(2);
31
+ const args = parseArgs(rest);
32
+
33
+ switch (command) {
34
+ case 'create':
35
+ create(args);
36
+ break;
37
+ case 'validate':
38
+ await validate(args);
39
+ break;
40
+ case 'dev':
41
+ await dev(args);
42
+ break;
43
+ case 'package':
44
+ await packageCmd(args);
45
+ break;
46
+ default:
47
+ console.log(`@port60/template-kit — build Port60 site templates locally
48
+
49
+ usage:
50
+ p60-template-kit create <dir> [--name my-template] [--label "My Template"]
51
+ p60-template-kit dev [dir] [--port 4400] live preview over the contract fixtures
52
+ p60-template-kit validate [dir] [--json] the platform's exact conformance checks
53
+ p60-template-kit package [dir] validate + build the uploadable zip
54
+
55
+ AI agents: the scaffold's AGENTS.md is your briefing; iterate with \`validate --json\`.
56
+ Docs: https://developers.port60.com (agents: /llms-full.txt)`);
57
+ process.exit(command ? 2 : 0);
58
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@port60/template-kit",
3
+ "version": "0.1.0",
4
+ "description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready — every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
5
+ "type": "module",
6
+ "bin": {
7
+ "p60-template-kit": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "starter"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test 'tests/*.test.mjs'"
16
+ },
17
+ "dependencies": {
18
+ "ajv": "^8.20.0",
19
+ "liquidjs": "^10.27.2"
20
+ },
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Port60Labs/template-kit.git"
25
+ },
26
+ "homepage": "https://developers.port60.com",
27
+ "bugs": {
28
+ "url": "https://github.com/Port60Labs/template-kit/issues"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
@@ -0,0 +1,73 @@
1
+ import { cpSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { resolve, join, basename } from 'node:path';
3
+ import { agentsMd } from '../lib/agentsMd.mjs';
4
+
5
+ const NAME_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
6
+
7
+ /** `create <dir> [--name x] [--label "X"]` — a working, validating template from the starter,
8
+ * briefed for AI agents (AGENTS.md + CLAUDE.md) and wired with the kit's npm scripts. */
9
+ export function create(args) {
10
+ const dir = args._[0];
11
+ if (!dir) {
12
+ console.error('usage: p60-template-kit create <dir> [--name my-template] [--label "My Template"]');
13
+ process.exit(2);
14
+ }
15
+ const target = resolve(dir);
16
+ const name = (args.name ?? basename(target)).toLowerCase();
17
+ if (!NAME_PATTERN.test(name)) {
18
+ console.error(`✗ '${name}' is not a valid template name (lowercase letters, digits, hyphens, `
19
+ + '3–50 chars, starting with a letter). Pass --name.');
20
+ process.exit(1);
21
+ }
22
+ if (existsSync(join(target, 'manifest.json'))) {
23
+ console.error(`✗ ${target} already contains a template.`);
24
+ process.exit(1);
25
+ }
26
+ const label = args.label ?? name.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
27
+
28
+ mkdirSync(target, { recursive: true });
29
+ cpSync(resolve(import.meta.dirname, '../../starter'), target, { recursive: true });
30
+
31
+ const manifest = JSON.parse(readFileSync(join(target, 'manifest.json'), 'utf8'));
32
+ manifest.name = name;
33
+ manifest.version = '0.1.0';
34
+ manifest.label = label;
35
+ manifest.description = `${label} — a Port60 site template.`;
36
+ manifest.changelog = 'First cut from the starter.';
37
+ writeFileSync(join(target, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
38
+
39
+ const briefing = agentsMd(name);
40
+ writeFileSync(join(target, 'AGENTS.md'), briefing);
41
+ writeFileSync(join(target, 'CLAUDE.md'), briefing);
42
+ writeFileSync(join(target, '.gitignore'), 'node_modules/\n*.zip\n');
43
+ writeFileSync(join(target, 'package.json'), JSON.stringify({
44
+ name: `${name}-template`,
45
+ private: true,
46
+ scripts: {
47
+ dev: 'p60-template-kit dev .',
48
+ validate: 'p60-template-kit validate .',
49
+ 'validate:json': 'p60-template-kit validate . --json',
50
+ package: 'p60-template-kit package .'
51
+ },
52
+ devDependencies: {
53
+ '@port60/template-kit': '^0.1.0'
54
+ }
55
+ }, null, 2) + '\n');
56
+ writeFileSync(join(target, 'README.md'), `# ${label}
57
+
58
+ A Port60 site template. Start with \`npm install\`, then:
59
+
60
+ - \`npm run dev\` — live preview at http://localhost:4400
61
+ - \`npm run validate\` — conformance against the platform contract
62
+ - \`npm run package\` — the uploadable zip
63
+
64
+ **Working with an AI agent?** Point it at this directory — \`AGENTS.md\` (and \`CLAUDE.md\`)
65
+ brief it on the contract, the rules and the validate loop.
66
+
67
+ Docs: https://developers.port60.com
68
+ `);
69
+
70
+ console.log(`✓ ${label} scaffolded at ${target}`);
71
+ console.log(' npm install && npm run dev → preview at http://localhost:4400');
72
+ console.log(' AGENTS.md briefs your AI agent; `npm run validate:json` is its feedback loop.');
73
+ }
@@ -0,0 +1,56 @@
1
+ import { createServer } from 'node:http';
2
+ import { watch } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { renderStudioPreview } from '../vendor/validator/preview.mjs';
5
+ import { validateArtifact } from '../vendor/validator/validate.mjs';
6
+ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
7
+
8
+ /**
9
+ * `dev <dir> [--port 4400]` — the local preview: your template over the contract's kind fixtures,
10
+ * the SAME render the studio and reviewers see (islands as placeholders, network-dead CSP).
11
+ * Files are re-read on every request, so a browser refresh is the hot reload; file changes also
12
+ * re-run validation into the terminal — the human watches the page, the agent watches the JSON.
13
+ */
14
+ export async function dev(args) {
15
+ const dir = resolve(args._[0] ?? '.');
16
+ const port = Number(args.port ?? 4400);
17
+
18
+ const server = createServer(async (req, res) => {
19
+ try {
20
+ const files = loadArtifactDir(dir);
21
+ const html = await renderStudioPreview(files);
22
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
23
+ res.end(html);
24
+ } catch (e) {
25
+ const { errors } = await validateArtifact(loadArtifactDir(dir)).catch(() => ({ errors: [] }));
26
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
27
+ res.end(`<!doctype html><meta charset="utf-8"><title>Template error</title>
28
+ <body style="font:14px/1.5 system-ui;padding:2rem;max-width:48rem;margin:auto">
29
+ <h1>The template didn't render</h1>
30
+ <p><code>${String(e instanceof Error ? e.message : e).replace(/</g, '&lt;')}</code></p>
31
+ ${errors.length ? `<h2>Validation says</h2><ul>${errors.map((x) => `<li>${String(x).replace(/</g, '&lt;')}</li>`).join('')}</ul>` : ''}
32
+ <p>Fix and refresh — files are re-read on every request.</p>`);
33
+ }
34
+ });
35
+
36
+ server.listen(port, () => {
37
+ console.log(`✓ preview at http://localhost:${port} — refresh after edits`);
38
+ console.log(' watching for changes; validation runs on save:');
39
+ });
40
+
41
+ let pending = null;
42
+ watch(dir, { recursive: true }, () => {
43
+ clearTimeout(pending);
44
+ pending = setTimeout(async () => {
45
+ const { errors, warnings } = await validateArtifact(loadArtifactDir(dir))
46
+ .catch((e) => ({ errors: [String(e)], warnings: [] }));
47
+ const stamp = new Date().toLocaleTimeString();
48
+ if (errors.length === 0) {
49
+ console.log(` [${stamp}] ✓ valid${warnings.length ? ` (${warnings.length} warning${warnings.length === 1 ? '' : 's'})` : ''}`);
50
+ } else {
51
+ console.log(` [${stamp}] ✗ ${errors.length} error${errors.length === 1 ? '' : 's'}:`);
52
+ for (const e of errors) console.log(` - ${e}`);
53
+ }
54
+ }, 200);
55
+ });
56
+ }
@@ -0,0 +1,25 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { resolve, join } from 'node:path';
3
+ import { validateArtifact } from '../vendor/validator/validate.mjs';
4
+ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
5
+ import { buildZip } from '../lib/zip.mjs';
6
+
7
+ /**
8
+ * `package <dir>` — validate first (the platform will run the identical checks, so failing here
9
+ * saves the round trip), then zip EXACTLY the contract-shaped file set into
10
+ * `<name>-<version>.zip` — the artifact the studio's upload lane accepts as-is.
11
+ */
12
+ export async function packageCmd(args) {
13
+ const dir = resolve(args._[0] ?? '.');
14
+ const files = loadArtifactDir(dir);
15
+ const { errors, manifest } = await validateArtifact(files);
16
+ if (errors.length > 0) {
17
+ console.error(`✗ not packaging — ${errors.length} validation error${errors.length === 1 ? '' : 's'}:`);
18
+ for (const e of errors) console.error(` - ${e}`);
19
+ process.exit(1);
20
+ }
21
+ const out = join(dir, `${manifest.name}-${manifest.version}.zip`);
22
+ writeFileSync(out, buildZip(Object.entries(files).map(([path, content]) => ({ path, content }))));
23
+ console.log(`✓ ${out}`);
24
+ console.log(' Upload it from your studio (tenant admin → Studio → your template → Versions).');
25
+ }
@@ -0,0 +1,38 @@
1
+ import { resolve } from 'node:path';
2
+ import { validateArtifact } from '../vendor/validator/validate.mjs';
3
+ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
4
+
5
+ /**
6
+ * `validate <dir> [--json]` — the exact checks the platform runs at upload, plus the T3 promise:
7
+ * **supports as an OUTPUT**. On a clean pass the manifest's supports block IS the proven set —
8
+ * the validator's two-way honesty checks (declared ⇒ renders the fixture, renders ⇒ declared)
9
+ * are what turn a declaration into proof. `--json` is the AI agent's feedback loop: iterate
10
+ * until {ok: true}.
11
+ */
12
+ export async function validate(args) {
13
+ const dir = args._[0] ?? '.';
14
+ const files = loadArtifactDir(resolve(dir));
15
+ const { errors, warnings, manifest } = await validateArtifact(files);
16
+ const ok = errors.length === 0;
17
+
18
+ if (args.json) {
19
+ console.log(JSON.stringify({
20
+ ok,
21
+ errors,
22
+ warnings,
23
+ manifest: manifest == null ? null : { name: manifest.name, version: manifest.version },
24
+ // Proven by behaviour, not claimed: only a clean pass earns the supports set.
25
+ provenSupports: ok ? manifest.supports : null
26
+ }, null, 2));
27
+ process.exit(ok ? 0 : 1);
28
+ }
29
+
30
+ for (const w of warnings) console.warn(` ⚠ ${w}`);
31
+ if (!ok) {
32
+ console.error(`\n✗ ${manifest?.name ?? dir} FAILED conformance (${errors.length} error${errors.length === 1 ? '' : 's'}):`);
33
+ for (const e of errors) console.error(` - ${e}`);
34
+ process.exit(1);
35
+ }
36
+ console.log(`\n✓ ${manifest.name}@${manifest.version} conforms to ${manifest.format}`);
37
+ console.log(` proven supports: ${JSON.stringify(manifest.supports)}`);
38
+ }
@@ -0,0 +1,81 @@
1
+ // The scaffold's AI-agent briefing (T3, user requirement: the kit must let people put an AI
2
+ // engine on template work and be productive immediately). Written for ANY coding agent —
3
+ // AGENTS.md is the cross-tool convention and CLAUDE.md carries the identical content for tools
4
+ // that read that name. The briefing is the contract's rules + the iteration loop, so an agent's
5
+ // first move is always the same: read this, edit, validate --json, repeat until clean.
6
+
7
+ export function agentsMd(name) {
8
+ return `# Working on the "${name}" Port60 template
9
+
10
+ You are working on a **Port60 site template** — a small, versioned artifact of Liquid renderers
11
+ and CSS that a charity's site is rendered through. It contains **no application code**: no
12
+ JavaScript, no API calls, no payment logic. Templates decide how a site *looks*; the platform
13
+ owns what it *does*.
14
+
15
+ ## The iteration loop (use this constantly)
16
+
17
+ \`\`\`bash
18
+ npm run validate # human-readable conformance check
19
+ npm run validate:json # machine-readable: {ok, errors[], warnings[], provenSupports}
20
+ npm run dev # local preview at http://localhost:4400 (re-renders on refresh)
21
+ npm run package # validate + produce the uploadable <name>-<version>.zip
22
+ \`\`\`
23
+
24
+ **After every meaningful edit, run \`npm run validate:json\` and fix every error before moving
25
+ on.** The validator is the exact code the platform runs at upload — if it passes here, the
26
+ platform accepts it; if it fails here, the upload will fail identically.
27
+
28
+ ## The file layout (nothing else is accepted)
29
+
30
+ - \`manifest.json\` — identity + what you support. \`name\` and \`version\` are immutable
31
+ identity; bump \`version\` (semver) for every published change.
32
+ - \`layout.liquid\` — the page chrome (header/nav/footer). Must contain **exactly one**
33
+ \`{% content %}\` slot. Only needed when \`supports.layout\` is true.
34
+ - \`sections/<type>.liquid\` — one renderer per section type you declare in
35
+ \`supports.sections\`. Charities compose pages from a **closed catalogue** of section types
36
+ (see \`npm run validate\` output or the docs) — you cannot invent new types.
37
+ - \`pages/<page>.liquid\` — optional full-page templates for \`supports.pageTemplates\`.
38
+ - \`assets/theme.css\` — required, your entire look. More \`.css\` files under \`assets/\` are
39
+ allowed. **No images, no JS** — they are refused at upload.
40
+
41
+ ## The rules the validator enforces (do not fight them)
42
+
43
+ 1. **Escape-by-default.** Every output is HTML-escaped unless you use \`| raw\` — and the only
44
+ values you may pass through raw are the contract's sanitised richtext fields.
45
+ 2. **The dialect is a whitelist.** \`{% include %}\`, \`{% render %}\`, \`{% layout %}\` and
46
+ several other tags are excluded and fail at parse. Unknown filters throw.
47
+ 3. **Islands are placed, never implemented.** Live functionality (donations, sign-in, events) is
48
+ \`{% island 'donation_widget' %}\` etc. Every island you place must be declared in
49
+ \`supports.islands\` and exist in the platform registry. Style them via their stable class
50
+ API; never reimplement them.
51
+ 4. **Declared ⇒ rendered, rendered ⇒ declared.** Supports flags are PROVEN behaviourally: if you
52
+ declare \`supports.worship\` the layout must actually render the worship fixture's times, must
53
+ hide the rail when \`worship\` is null, and rendering it undeclared is equally an error. The
54
+ same honesty applies across the contract.
55
+ 5. **Render budgets are real.** Runaway loops are killed (~1s per render). Keep renderers simple.
56
+ 6. **Context is a whitelist.** Sections see \`{section, brand}\`; layouts see
57
+ \`{brand, nav, socials, worship}\`; page templates see their fixture + \`brand\`. Nothing else
58
+ exists — do not invent variables.
59
+
60
+ ## What to build with
61
+
62
+ - Theme via CSS custom properties and the settings knobs you declare in
63
+ \`manifest.settings.schema\` — they surface in the charity's Appearance editor as
64
+ \`--p60s-<key>\` variables and \`data-p60s-<key>\` body attributes.
65
+ - "Looks" = named one-click bundles of knob values in \`manifest.looks\`.
66
+ - Fonts: only families from the platform font catalogue, declared with the weights you use.
67
+
68
+ ## Which contract this is
69
+
70
+ The section catalogue, islands and fixtures here are the **Charity Platform contract v1** — the
71
+ platform's first product surface. The dialect, the rules above and this toolchain are
72
+ platform-wide; other Port60 products will ship their own contract packs. Do not assume the
73
+ current section list is universal.
74
+
75
+ ## Reference
76
+
77
+ The full generated reference (sections, islands, context variables, tokens, dialect) lives at
78
+ https://developers.port60.com — also available in one file for agents at
79
+ https://developers.port60.com/llms-full.txt
80
+ `;
81
+ }
@@ -0,0 +1,28 @@
1
+ // The contract-shaped file set of an artifact directory — the same selection the platform's
2
+ // upload intake allow-lists: manifest, layout, section/page renderers, css assets. Anything else
3
+ // in the directory is not part of an artifact and is neither validated nor packaged.
4
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export function loadArtifactDir(root) {
8
+ const files = {};
9
+ const add = (rel) => {
10
+ const abs = join(root, rel);
11
+ if (existsSync(abs)) files[rel] = readFileSync(abs, 'utf8');
12
+ };
13
+ add('manifest.json');
14
+ add('layout.liquid');
15
+ for (const dir of ['sections', 'pages']) {
16
+ const abs = join(root, dir);
17
+ if (!existsSync(abs)) continue;
18
+ for (const name of readdirSync(abs)) {
19
+ if (name.endsWith('.liquid')) files[`${dir}/${name}`] = readFileSync(join(abs, name), 'utf8');
20
+ }
21
+ }
22
+ if (existsSync(join(root, 'assets'))) {
23
+ for (const name of readdirSync(join(root, 'assets'))) {
24
+ if (name.endsWith('.css')) files[`assets/${name}`] = readFileSync(join(root, 'assets', name), 'utf8');
25
+ }
26
+ }
27
+ return files;
28
+ }
@@ -0,0 +1,77 @@
1
+ // A minimal STORE-only ZIP writer — no dependency, no compression. Artifacts are ~80KB of text
2
+ // against a 2MB upload cap, so stored entries are fine, and the platform's TemplateArchive reads
3
+ // any conformant zip. Local file headers + central directory + CRC-32, nothing else.
4
+
5
+ const CRC_TABLE = (() => {
6
+ const table = new Uint32Array(256);
7
+ for (let n = 0; n < 256; n++) {
8
+ let c = n;
9
+ for (let k = 0; k < 8; k++) {
10
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
11
+ }
12
+ table[n] = c >>> 0;
13
+ }
14
+ return table;
15
+ })();
16
+
17
+ function crc32(bytes) {
18
+ let crc = 0xffffffff;
19
+ for (const byte of bytes) {
20
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
21
+ }
22
+ return (crc ^ 0xffffffff) >>> 0;
23
+ }
24
+
25
+ /** entries: Array<{path: string, content: Buffer|string}> → a complete zip Buffer. */
26
+ export function buildZip(entries) {
27
+ const localParts = [];
28
+ const centralParts = [];
29
+ let offset = 0;
30
+
31
+ for (const entry of entries) {
32
+ const nameBytes = Buffer.from(entry.path, 'utf8');
33
+ const data = Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content, 'utf8');
34
+ const crc = crc32(data);
35
+
36
+ const local = Buffer.alloc(30);
37
+ local.writeUInt32LE(0x04034b50, 0); // local file header signature
38
+ local.writeUInt16LE(20, 4); // version needed
39
+ local.writeUInt16LE(0x0800, 6); // flags: UTF-8 names
40
+ local.writeUInt16LE(0, 8); // method: stored
41
+ local.writeUInt16LE(0, 10); // mod time
42
+ local.writeUInt16LE(0x21, 12); // mod date (a fixed valid date)
43
+ local.writeUInt32LE(crc, 14);
44
+ local.writeUInt32LE(data.length, 18); // compressed size (== stored)
45
+ local.writeUInt32LE(data.length, 22); // uncompressed size
46
+ local.writeUInt16LE(nameBytes.length, 26);
47
+ local.writeUInt16LE(0, 28); // extra length
48
+ localParts.push(local, nameBytes, data);
49
+
50
+ const central = Buffer.alloc(46);
51
+ central.writeUInt32LE(0x02014b50, 0); // central directory signature
52
+ central.writeUInt16LE(20, 4); // version made by
53
+ central.writeUInt16LE(20, 6); // version needed
54
+ central.writeUInt16LE(0x0800, 8); // flags: UTF-8 names
55
+ central.writeUInt16LE(0, 10); // method: stored
56
+ central.writeUInt16LE(0, 12); // mod time
57
+ central.writeUInt16LE(0x21, 14); // mod date
58
+ central.writeUInt32LE(crc, 16);
59
+ central.writeUInt32LE(data.length, 20);
60
+ central.writeUInt32LE(data.length, 24);
61
+ central.writeUInt16LE(nameBytes.length, 28);
62
+ central.writeUInt32LE(offset, 42); // local header offset
63
+ centralParts.push(central, nameBytes);
64
+
65
+ offset += 30 + nameBytes.length + data.length;
66
+ }
67
+
68
+ const centralSize = centralParts.reduce((n, b) => n + b.length, 0);
69
+ const end = Buffer.alloc(22);
70
+ end.writeUInt32LE(0x06054b50, 0); // end of central directory
71
+ end.writeUInt16LE(entries.length, 8);
72
+ end.writeUInt16LE(entries.length, 10);
73
+ end.writeUInt32LE(centralSize, 12);
74
+ end.writeUInt32LE(offset, 16);
75
+
76
+ return Buffer.concat([...localParts, ...centralParts, end]);
77
+ }
@@ -0,0 +1,117 @@
1
+ {
2
+ "description": "The maximum lengths of every admin-entered display field a template renders. The admin screens stop input at these caps and the API rejects anything longer, so a template can design cards, headings and captions against known worst cases. Additive-only within a contract major: a bound may loosen, never tighten. Long-form bodies (article bodies, course About pages, service pages) are deliberately outside this table — they are sanitised HTML rendered in dedicated full-width regions, never inside cards.",
3
+ "groups": [
4
+ {
5
+ "name": "Brand (layout chrome)",
6
+ "fields": [
7
+ {
8
+ "field": "brand.name",
9
+ "max": 60,
10
+ "note": "The masthead/wordmark text."
11
+ },
12
+ {
13
+ "field": "brand.tagline",
14
+ "max": 140,
15
+ "note": "One line under the masthead; may be empty."
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ "name": "Articles",
21
+ "fields": [
22
+ {
23
+ "field": "Article title",
24
+ "max": 200
25
+ },
26
+ {
27
+ "field": "Excerpt",
28
+ "max": 500,
29
+ "note": "The card/front-page summary."
30
+ },
31
+ {
32
+ "field": "Author name / role",
33
+ "max": 120,
34
+ "note": "Each."
35
+ },
36
+ {
37
+ "field": "Articles nav label",
38
+ "max": 30,
39
+ "note": "The tenant's name for the section (\"Latest\", \"News\", …)."
40
+ },
41
+ {
42
+ "field": "Categories",
43
+ "max": null,
44
+ "note": "≤5 per article; names come from a tenant catalogue capped at 20 entries."
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "name": "Events",
50
+ "fields": [
51
+ {
52
+ "field": "Event name",
53
+ "max": 200
54
+ },
55
+ {
56
+ "field": "Summary (description)",
57
+ "max": 200,
58
+ "note": "A sentence or two for cards and listings. The long-form About body is separate sanitised HTML on the detail page."
59
+ },
60
+ {
61
+ "field": "Venue name",
62
+ "max": 200
63
+ },
64
+ {
65
+ "field": "Venue address",
66
+ "max": 500
67
+ },
68
+ {
69
+ "field": "Tag",
70
+ "max": 40,
71
+ "note": "Each — rendered as chips."
72
+ }
73
+ ]
74
+ },
75
+ {
76
+ "name": "Courses",
77
+ "fields": [
78
+ {
79
+ "field": "Course title",
80
+ "max": 200
81
+ },
82
+ {
83
+ "field": "Description",
84
+ "max": 500,
85
+ "note": "The card/detail summary; the long-form About page is separate sanitised HTML."
86
+ },
87
+ {
88
+ "field": "Venue label",
89
+ "max": 200
90
+ }
91
+ ]
92
+ },
93
+ {
94
+ "name": "Donations",
95
+ "fields": [
96
+ {
97
+ "field": "Cause name",
98
+ "max": 200
99
+ },
100
+ {
101
+ "field": "Cause description",
102
+ "max": 500
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ "name": "Service pages",
108
+ "fields": [
109
+ {
110
+ "field": "Page name",
111
+ "max": 200,
112
+ "note": "The body is sanitised HTML rendered full-width — not card content."
113
+ }
114
+ ]
115
+ }
116
+ ]
117
+ }