@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 +21 -0
- package/README.md +50 -0
- package/bin/cli.mjs +58 -0
- package/package.json +33 -0
- package/src/commands/create.mjs +73 -0
- package/src/commands/dev.mjs +56 -0
- package/src/commands/packageCmd.mjs +25 -0
- package/src/commands/validate.mjs +38 -0
- package/src/lib/agentsMd.mjs +81 -0
- package/src/lib/artifactFiles.mjs +28 -0
- package/src/lib/zip.mjs +77 -0
- package/src/vendor/contract/v1/content-bounds.json +117 -0
- package/src/vendor/contract/v1/context.json +534 -0
- package/src/vendor/contract/v1/dialect.json +39 -0
- package/src/vendor/contract/v1/fonts.json +67 -0
- package/src/vendor/contract/v1/islands.json +95 -0
- package/src/vendor/contract/v1/manifest.schema.json +144 -0
- package/src/vendor/contract/v1/sections.json +558 -0
- package/src/vendor/contract/v1/tokens.json +26 -0
- package/src/vendor/engine/budgets.mjs +14 -0
- package/src/vendor/engine/dialect.mjs +77 -0
- package/src/vendor/validator/preview.mjs +111 -0
- package/src/vendor/validator/validate.mjs +265 -0
- package/starter/assets/theme.css +43 -0
- package/starter/layout.liquid +75 -0
- package/starter/manifest.json +40 -0
- package/starter/sections/cta.liquid +12 -0
- package/starter/sections/hero.liquid +7 -0
- package/starter/sections/values.liquid +14 -0
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, '<')}</code></p>
|
|
31
|
+
${errors.length ? `<h2>Validation says</h2><ul>${errors.map((x) => `<li>${String(x).replace(/</g, '<')}</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
|
+
}
|
package/src/lib/zip.mjs
ADDED
|
@@ -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
|
+
}
|