@port60/template-kit 0.9.0 → 0.10.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/README.md +10 -3
- package/bin/cli.mjs +26 -4
- package/package.json +1 -1
- package/src/commands/content.mjs +42 -0
- package/src/commands/create.mjs +6 -6
- package/src/commands/dev.mjs +97 -9
- package/src/commands/model.mjs +34 -0
- package/src/commands/packageCmd.mjs +3 -3
- package/src/commands/refresh.mjs +48 -0
- package/src/commands/upgrade.mjs +44 -0
- package/src/commands/validate.mjs +2 -3
- package/src/lib/agentsMd.mjs +47 -19
- package/src/vendor/contract/v1/behaviours.json +4 -4
- package/src/vendor/contract/v1/content-bounds.json +3 -3
- package/src/vendor/contract/v1/content-model.json +193 -0
- package/src/vendor/contract/v1/context.json +1667 -275
- package/src/vendor/contract/v1/dialect.json +3 -3
- package/src/vendor/contract/v1/fonts.json +1 -1
- package/src/vendor/contract/v1/imagery.json +4 -4
- package/src/vendor/contract/v1/islands.json +256 -235
- package/src/vendor/contract/v1/layout.json +4 -4
- package/src/vendor/contract/v1/manifest.schema.json +9 -9
- package/src/vendor/contract/v1/sections.json +248 -92
- package/src/vendor/contract/v1/tokens.json +1 -1
- package/src/vendor/contract/v1.lock.json +1 -1
- package/src/vendor/engine/content-footprint.mjs +79 -0
- package/src/vendor/validator/fixture-art.mjs +109 -0
- package/src/vendor/validator/model-reference.mjs +92 -0
- package/src/vendor/validator/platform-base.css +21 -0
- package/src/vendor/validator/preview.mjs +149 -56
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +71 -14
- package/starter/assets/theme.css +10 -10
- package/starter/layout.liquid +15 -16
- package/starter/manifest.json +2 -2
- package/starter/sections/campaigns.liquid +5 -5
- package/starter/sections/homeHero.liquid +11 -11
- package/starter/sections/people.liquid +1 -1
- package/starter/sections/values.liquid +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@port60/template-kit)
|
|
4
4
|
[](https://github.com/Port60Labs/template-kit/actions/workflows/ci.yml)
|
|
5
5
|
|
|
6
|
-
The official toolkit for building [Port60](https://port60.com) site templates
|
|
6
|
+
The official toolkit for building [Port60](https://port60.com) site templates, scaffold from
|
|
7
7
|
the starter, preview locally against the platform contract, validate with the exact checks the
|
|
8
8
|
platform runs at upload, and package for review.
|
|
9
9
|
|
|
@@ -14,13 +14,17 @@ owns how a site looks.
|
|
|
14
14
|
## Quickstart
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
npx @port60/template-kit create my-template
|
|
17
|
+
npx @port60/template-kit create my-template # the folder name IS the template name
|
|
18
18
|
cd my-template && npm install
|
|
19
19
|
npm run dev # live preview at http://localhost:4400
|
|
20
20
|
npm run validate # the platform's conformance checks
|
|
21
21
|
npm run package # the uploadable <name>-<version>.zip
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Already building? `npx @port60/template-kit@latest upgrade` moves an existing template onto
|
|
25
|
+
the latest kit and contract, regenerates the agent briefing, and reports what (if anything) the
|
|
26
|
+
newer contract asks of you.
|
|
27
|
+
|
|
24
28
|
## Commands
|
|
25
29
|
|
|
26
30
|
| Command | What it does |
|
|
@@ -29,6 +33,9 @@ npm run package # the uploadable <name>-<version>.zip
|
|
|
29
33
|
| `dev [dir]` | Live preview over the contract's sample fixtures; validation re-runs on save. |
|
|
30
34
|
| `validate [dir] [--json]` | The exact checks the platform runs at upload. `--json` emits `{ok, errors, warnings, provenSupports}`. |
|
|
31
35
|
| `package [dir]` | Validate, then build the contract-shaped zip the studio accepts as-is. |
|
|
36
|
+
| `model [--json]` | The content model, in hand; `--json` for agents. |
|
|
37
|
+
| `content [dir]` | Eject the model's data as your editable copy; render it with `dev --content`. |
|
|
38
|
+
| `upgrade [dir]` | Latest kit + contract for an existing template; re-briefs and re-validates. |
|
|
32
39
|
|
|
33
40
|
## Building with an AI agent
|
|
34
41
|
|
|
@@ -41,7 +48,7 @@ See the [AI quickstart](https://developers.port60.com/guides/ai-quickstart/).
|
|
|
41
48
|
|
|
42
49
|
## The contract
|
|
43
50
|
|
|
44
|
-
The vendored contract in `src/vendor/contract` is the **Charity Platform contract v1
|
|
51
|
+
The vendored contract in `src/vendor/contract` is the **Charity Platform contract v1**, the
|
|
45
52
|
platform's first product surface. The dialect, validation rules and this toolchain are
|
|
46
53
|
platform-wide; future Port60 products ship their own contract packs for the same kit.
|
|
47
54
|
|
package/bin/cli.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @port60/template-kit
|
|
2
|
+
// @port60/template-kit, build Port60 site templates locally. AI-agent ready: `create` scaffolds
|
|
3
3
|
// an AGENTS.md-briefed project; `validate --json` is the machine feedback loop; `dev` is the
|
|
4
4
|
// human's live preview; `package` produces the uploadable artifact.
|
|
5
5
|
import { create } from '../src/commands/create.mjs';
|
|
6
6
|
import { validate } from '../src/commands/validate.mjs';
|
|
7
7
|
import { dev } from '../src/commands/dev.mjs';
|
|
8
8
|
import { packageCmd } from '../src/commands/packageCmd.mjs';
|
|
9
|
+
import { model } from '../src/commands/model.mjs';
|
|
10
|
+
import { content } from '../src/commands/content.mjs';
|
|
11
|
+
import { upgrade } from '../src/commands/upgrade.mjs';
|
|
12
|
+
import { refresh } from '../src/commands/refresh.mjs';
|
|
9
13
|
|
|
10
14
|
function parseArgs(argv) {
|
|
11
15
|
const args = { _: [] };
|
|
@@ -37,6 +41,18 @@ switch (command) {
|
|
|
37
41
|
case 'validate':
|
|
38
42
|
await validate(args);
|
|
39
43
|
break;
|
|
44
|
+
case 'model':
|
|
45
|
+
model(args);
|
|
46
|
+
break;
|
|
47
|
+
case 'content':
|
|
48
|
+
content(args);
|
|
49
|
+
break;
|
|
50
|
+
case 'upgrade':
|
|
51
|
+
upgrade(args);
|
|
52
|
+
break;
|
|
53
|
+
case 'refresh':
|
|
54
|
+
await refresh(args);
|
|
55
|
+
break;
|
|
40
56
|
case 'dev':
|
|
41
57
|
await dev(args);
|
|
42
58
|
break;
|
|
@@ -44,13 +60,19 @@ switch (command) {
|
|
|
44
60
|
await packageCmd(args);
|
|
45
61
|
break;
|
|
46
62
|
default:
|
|
47
|
-
console.log(`@port60/template-kit
|
|
63
|
+
console.log(`@port60/template-kit, build Port60 site templates locally
|
|
48
64
|
|
|
49
65
|
usage:
|
|
50
|
-
p60-template-kit create <dir>
|
|
51
|
-
|
|
66
|
+
p60-template-kit create <dir> scaffold a template (name = the folder;
|
|
67
|
+
--name/--label override the identity)
|
|
68
|
+
p60-template-kit dev [dir] [--port 4400] [--content my.json]
|
|
69
|
+
live preview; --content renders YOUR data
|
|
70
|
+
p60-template-kit model [--json] the content model, in hand (--json for agents)
|
|
71
|
+
p60-template-kit content [dir] [--out file] eject the model's data as your editable copy
|
|
52
72
|
p60-template-kit validate [dir] [--json] the platform's exact conformance checks
|
|
53
73
|
p60-template-kit package [dir] validate + build the uploadable zip
|
|
74
|
+
p60-template-kit upgrade [dir] move an existing template to the latest kit
|
|
75
|
+
and contract, re-brief, re-validate
|
|
54
76
|
|
|
55
77
|
AI agents: the scaffold's AGENTS.md is your briefing; iterate with \`validate --json\`.
|
|
56
78
|
Docs: https://developers.port60.com (agents: /llms-full.txt)`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@port60/template-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { buildSiteFixture, validatePreviewContent } from '../vendor/validator/site-context.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `content [dir] [--out file] [--force]`, EJECT the content model's data as your hard copy.
|
|
7
|
+
* Writes every overridable `site.content.*` collection, fully populated with the canonical
|
|
8
|
+
* organisation's data, as one editable JSON file. Replace the copy, swap the imagery for your
|
|
9
|
+
* own URLs, then hand it back:
|
|
10
|
+
*
|
|
11
|
+
* p60-template-kit content . → writes ./preview-content.json
|
|
12
|
+
* p60-template-kit dev . --content my.json → the preview renders YOUR data
|
|
13
|
+
*
|
|
14
|
+
* The shape stays the platform's (schema-checked on every read, a field that does not exist in
|
|
15
|
+
* production cannot exist in a preview); the data becomes yours. `about` is not ejected: it is
|
|
16
|
+
* your template's own section composition, derived from the manifest.
|
|
17
|
+
*/
|
|
18
|
+
export function content(args) {
|
|
19
|
+
const dir = resolve(args._[0] ?? '.');
|
|
20
|
+
const out = resolve(dir, typeof args.out === 'string' ? args.out : 'preview-content.json');
|
|
21
|
+
if (existsSync(out) && !args.force) {
|
|
22
|
+
console.error(`✗ ${out} already exists, pass --force to overwrite it.`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const site = buildSiteFixture(null);
|
|
26
|
+
const data = {
|
|
27
|
+
brand: site.brand,
|
|
28
|
+
...Object.fromEntries(Object.entries(site.content).filter(([name]) => name !== 'about'))
|
|
29
|
+
};
|
|
30
|
+
const problems = validatePreviewContent(data);
|
|
31
|
+
if (problems.length > 0) {
|
|
32
|
+
// The eject is generated FROM the model, so this firing means the kit itself is broken.
|
|
33
|
+
console.error('✗ internal error, the ejected content does not validate:');
|
|
34
|
+
for (const problem of problems) console.error(` - ${problem}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(out, JSON.stringify(data, null, 2) + '\n');
|
|
38
|
+
console.log(`✓ content model data written to ${out}`);
|
|
39
|
+
console.log(' Edit the copy, swap imageUrl values for your own hosted images, then:');
|
|
40
|
+
console.log(` p60-template-kit dev ${args._[0] ?? '.'} --content ${typeof args.out === 'string' ? args.out : 'preview-content.json'}`);
|
|
41
|
+
console.log(' Shape is fixed (schema-checked every read); the data is yours. Never packaged.');
|
|
42
|
+
}
|
package/src/commands/create.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const KIT_PACKAGE = JSON.parse(
|
|
|
7
7
|
readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8')
|
|
8
8
|
);
|
|
9
9
|
|
|
10
|
-
/** `create <dir> [--name x] [--label "X"]
|
|
10
|
+
/** `create <dir> [--name x] [--label "X"]`, a working, validating template from the starter,
|
|
11
11
|
* briefed for AI agents (AGENTS.md + CLAUDE.md) and wired with the kit's npm scripts. */
|
|
12
12
|
export function create(args) {
|
|
13
13
|
const dir = args._[0];
|
|
@@ -35,7 +35,7 @@ export function create(args) {
|
|
|
35
35
|
manifest.name = name;
|
|
36
36
|
manifest.version = '0.1.0';
|
|
37
37
|
manifest.label = label;
|
|
38
|
-
manifest.description = `${label}
|
|
38
|
+
manifest.description = `${label}, a Port60 site template.`;
|
|
39
39
|
manifest.changelog = 'First cut from the starter.';
|
|
40
40
|
writeFileSync(join(target, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
41
41
|
|
|
@@ -60,11 +60,11 @@ export function create(args) {
|
|
|
60
60
|
|
|
61
61
|
A Port60 site template. Start with \`npm install\`, then:
|
|
62
62
|
|
|
63
|
-
- \`npm run dev
|
|
64
|
-
- \`npm run validate
|
|
65
|
-
- \`npm run package
|
|
63
|
+
- \`npm run dev\`, live preview at http://localhost:4400
|
|
64
|
+
- \`npm run validate\`, conformance against the platform contract
|
|
65
|
+
- \`npm run package\`, the uploadable zip
|
|
66
66
|
|
|
67
|
-
**Working with an AI agent?** Point it at this directory
|
|
67
|
+
**Working with an AI agent?** Point it at this directory, \`AGENTS.md\` (and \`CLAUDE.md\`)
|
|
68
68
|
brief it on the contract, the rules and the validate loop.
|
|
69
69
|
|
|
70
70
|
Docs: https://developers.port60.com
|
package/src/commands/dev.mjs
CHANGED
|
@@ -3,30 +3,101 @@ import { watch, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { renderStudioPreview } from '../vendor/validator/preview.mjs';
|
|
5
5
|
import { validateArtifact } from '../vendor/validator/validate.mjs';
|
|
6
|
+
import { applyPreviewContent, buildSiteFixture, validatePreviewContent } from '../vendor/validator/site-context.mjs';
|
|
7
|
+
import { renderModelReferenceHtml } from '../vendor/validator/model-reference.mjs';
|
|
6
8
|
import { loadArtifactDir } from '../lib/artifactFiles.mjs';
|
|
9
|
+
import { join } from 'node:path';
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
*
|
|
12
|
+
* The author's optional preview data (content model v1): preview-content.json beside the
|
|
13
|
+
* manifest replaces site.content collections wholesale for THIS preview, data is free, shape is
|
|
14
|
+
* fixed (schema-validated every read; problems land in the terminal and the canonical fixtures
|
|
15
|
+
* render instead). Hot like everything else: re-read on every request, never packaged, refused
|
|
16
|
+
* by the upload intake.
|
|
17
|
+
*/
|
|
18
|
+
function loadPreviewContent(dir, explicitPath) {
|
|
19
|
+
const path = explicitPath ?? join(dir, 'preview-content.json');
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = readFileSync(path, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
if (explicitPath) console.error(`✗ --content file not found: ${explicitPath}, using the canonical fixtures`);
|
|
25
|
+
return null; // no file, the canonical fixtures render
|
|
26
|
+
}
|
|
27
|
+
let json;
|
|
28
|
+
try {
|
|
29
|
+
json = JSON.parse(raw);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error(`✗ preview-content.json is not valid JSON (${e.message}), using the canonical fixtures`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const problems = validatePreviewContent(json);
|
|
35
|
+
if (problems.length > 0) {
|
|
36
|
+
console.error('✗ preview-content.json ignored, the shape is fixed even though the data is yours:');
|
|
37
|
+
for (const problem of problems) console.error(` - ${problem}`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return json;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The origins of every absolute URL in the override, the hosts the dev CSP must admit. */
|
|
44
|
+
function imageOriginsOf(json) {
|
|
45
|
+
const origins = new Set();
|
|
46
|
+
const walk = (value) => {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
value.forEach(walk);
|
|
49
|
+
} else if (value && typeof value === 'object') {
|
|
50
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
51
|
+
// Imagery fields only (imageUrl, logoUrl, …), a directions link is a URL too, and the
|
|
52
|
+
// CSP should admit exactly the hosts that will be ASKED for pixels.
|
|
53
|
+
if (/Url$/.test(key) && typeof inner === 'string' && /^https?:\/\//.test(inner)) {
|
|
54
|
+
try {
|
|
55
|
+
origins.add(new URL(inner).origin);
|
|
56
|
+
} catch {
|
|
57
|
+
// not a URL after all, nothing to admit
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
walk(inner);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(json);
|
|
66
|
+
return [...origins];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `dev <dir> [--port 4400]`, the local preview: your template over the contract's kind fixtures,
|
|
10
71
|
* the SAME render the studio and reviewers see (islands as fixture-hydrated skeletons,
|
|
11
72
|
* network-dead CSP). ONE addition over the studio render: the platform's own behaviour runtime is
|
|
12
|
-
* inlined, so data-p60-* carousels, reveals and tabs run for real locally
|
|
73
|
+
* inlined, so data-p60-* carousels, reveals and tabs run for real locally, the only script the
|
|
13
74
|
* document can execute. Files are re-read on every request, so a browser refresh is the hot
|
|
14
|
-
* reload; file changes also re-run validation into the terminal
|
|
75
|
+
* reload; file changes also re-run validation into the terminal, the human watches the page,
|
|
15
76
|
* the agent watches the JSON.
|
|
16
77
|
*/
|
|
17
78
|
export async function dev(args) {
|
|
18
79
|
const dir = resolve(args._[0] ?? '.');
|
|
19
80
|
const port = Number(args.port ?? 4400);
|
|
81
|
+
// The dev-richer half of the imagery split: point P60_FIXTURE_IMAGES at the platform's fixture
|
|
82
|
+
// imagery base and the preview loads photographic fixtures from that ONE origin instead of the
|
|
83
|
+
// sealed inline-SVG art. Unset (the default), the preview stays fully network-dead.
|
|
84
|
+
const fixtureImageBase = process.env.P60_FIXTURE_IMAGES || null;
|
|
85
|
+
// `--content my-org.json` points the preview at YOUR data (an ejected, edited copy of the
|
|
86
|
+
// content model, see the `content` command). Without it, preview-content.json beside the
|
|
87
|
+
// manifest is picked up by convention. Hot either way: re-read on every request.
|
|
88
|
+
// Relative --content paths resolve against the TEMPLATE dir (matching the eject hint);
|
|
89
|
+
// absolute paths pass through untouched.
|
|
90
|
+
const contentPath = typeof args.content === 'string' ? resolve(dir, args.content) : null;
|
|
20
91
|
let behaviorsRuntime = null;
|
|
21
92
|
try {
|
|
22
93
|
behaviorsRuntime = readFileSync(
|
|
23
94
|
resolve(import.meta.dirname, '../vendor/validator/behaviors-runtime.js'), 'utf8');
|
|
24
95
|
} catch {
|
|
25
|
-
// An older vendored copy without the runtime
|
|
96
|
+
// An older vendored copy without the runtime, the preview degrades to the CSS approximation.
|
|
26
97
|
}
|
|
27
98
|
|
|
28
99
|
// The preview is ROUTED: nav links land on real surfaces, so an author sees every platform
|
|
29
|
-
// page wearing their chrome
|
|
100
|
+
// page wearing their chrome, their own page template where they ship one, the platform's
|
|
30
101
|
// fixture skeleton where the page is platform-owned (ticket purchase, donate, campaigns).
|
|
31
102
|
const surfaceFor = (rawUrl) => {
|
|
32
103
|
const url = new URL(rawUrl, 'http://preview.local');
|
|
@@ -45,7 +116,24 @@ export async function dev(args) {
|
|
|
45
116
|
const server = createServer(async (req, res) => {
|
|
46
117
|
try {
|
|
47
118
|
const files = loadArtifactDir(dir);
|
|
48
|
-
const
|
|
119
|
+
const url = new URL(req.url ?? '/', 'http://preview.local');
|
|
120
|
+
if (url.pathname === '/model') {
|
|
121
|
+
// The model, where the developer lives: the registry beside the LIVE data this preview
|
|
122
|
+
// renders (preview-content.json overlay included).
|
|
123
|
+
const manifest = JSON.parse(files['manifest.json'] ?? '{}');
|
|
124
|
+
const site = applyPreviewContent(buildSiteFixture(manifest), loadPreviewContent(dir, contentPath));
|
|
125
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
126
|
+
res.end(renderModelReferenceHtml(site));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const previewContent = loadPreviewContent(dir, contentPath);
|
|
130
|
+
const html = await renderStudioPreview(files, {
|
|
131
|
+
behaviorsRuntime,
|
|
132
|
+
fixtureImageBase,
|
|
133
|
+
previewContent,
|
|
134
|
+
contentImageOrigins: imageOriginsOf(previewContent),
|
|
135
|
+
surface: surfaceFor(req.url ?? '/')
|
|
136
|
+
});
|
|
49
137
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
50
138
|
res.end(html);
|
|
51
139
|
} catch (e) {
|
|
@@ -56,7 +144,7 @@ export async function dev(args) {
|
|
|
56
144
|
<h1>The template didn't render</h1>
|
|
57
145
|
<p><code>${String(e instanceof Error ? e.message : e).replace(/</g, '<')}</code></p>
|
|
58
146
|
${errors.length ? `<h2>Validation says</h2><ul>${errors.map((x) => `<li>${String(x).replace(/</g, '<')}</li>`).join('')}</ul>` : ''}
|
|
59
|
-
<p>Fix and refresh
|
|
147
|
+
<p>Fix and refresh, files are re-read on every request.</p>`);
|
|
60
148
|
}
|
|
61
149
|
});
|
|
62
150
|
|
|
@@ -64,7 +152,7 @@ ${errors.length ? `<h2>Validation says</h2><ul>${errors.map((x) => `<li>${String
|
|
|
64
152
|
// template), and a raw EADDRINUSE stack reads like the kit is broken. Name the fix instead.
|
|
65
153
|
server.on('error', (e) => {
|
|
66
154
|
if (e.code === 'EADDRINUSE') {
|
|
67
|
-
console.error(`✗ port ${port} is already in use
|
|
155
|
+
console.error(`✗ port ${port} is already in use, another preview is probably still running.`);
|
|
68
156
|
console.error(` Stop it, or start this one elsewhere: p60-template-kit dev ${args._[0] ?? '.'} --port ${port + 1}`);
|
|
69
157
|
process.exit(1);
|
|
70
158
|
}
|
|
@@ -72,7 +160,7 @@ ${errors.length ? `<h2>Validation says</h2><ul>${errors.map((x) => `<li>${String
|
|
|
72
160
|
});
|
|
73
161
|
|
|
74
162
|
server.listen(port, () => {
|
|
75
|
-
console.log(`✓ preview at http://localhost:${port}
|
|
163
|
+
console.log(`✓ preview at http://localhost:${port}, refresh after edits`);
|
|
76
164
|
console.log(' watching for changes; validation runs on save:');
|
|
77
165
|
});
|
|
78
166
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { contentModel } from '../vendor/engine/content-footprint.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `model [--json]`, the content model, in hand. Prints every `site.content.*` collection with
|
|
5
|
+
* its bound, its onward link and its fields; `--json` emits the registry verbatim for agents
|
|
6
|
+
* (the same file the validator and the production renderer enforce, so it cannot drift).
|
|
7
|
+
* The dev server serves the same reference with live example data at /model.
|
|
8
|
+
*/
|
|
9
|
+
export function model(args) {
|
|
10
|
+
if (args.json) {
|
|
11
|
+
console.log(JSON.stringify(contentModel, null, 2));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(`site.content, content model ${contentModel.version}`);
|
|
15
|
+
console.log('site also carries: ' + contentModel.siblings.keys.map((k) => `site.${k}`).join(', '));
|
|
16
|
+
console.log('');
|
|
17
|
+
for (const [name, collection] of Object.entries(contentModel.collections)) {
|
|
18
|
+
const more = collection.moreHref ? ` · more at ${collection.moreHref}` : '';
|
|
19
|
+
console.log(`site.content.${name} (bounded at ${collection.cap}${more}, since ${collection.since})`);
|
|
20
|
+
for (const [field, spec] of Object.entries(collection.item)) {
|
|
21
|
+
const notes = [
|
|
22
|
+
spec.nullable ? 'nullable' : null,
|
|
23
|
+
spec.enumOpen ? `open enum${spec.enumOpen.length ? `: ${spec.enumOpen.join('|')}` : ''}` : null,
|
|
24
|
+
spec.max ? `max ${spec.max}` : null
|
|
25
|
+
].filter(Boolean).join(', ');
|
|
26
|
+
console.log(` ${field}: ${spec.type}${notes ? ` (${notes})` : ''}`);
|
|
27
|
+
}
|
|
28
|
+
console.log('');
|
|
29
|
+
}
|
|
30
|
+
console.log('Rules: collections are bounded (link onward via moreHref); enums are open (branch and');
|
|
31
|
+
console.log('fall back); nullable fields need a branch; the model only grows. Your footprint and');
|
|
32
|
+
console.log('minimum model version are computed at publish, you never declare them. Live examples:');
|
|
33
|
+
console.log('the /model page on your dev preview.');
|
|
34
|
+
}
|
|
@@ -5,16 +5,16 @@ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
|
|
|
5
5
|
import { buildZip } from '../lib/zip.mjs';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* `package <dir
|
|
8
|
+
* `package <dir>`, validate first (the platform will run the identical checks, so failing here
|
|
9
9
|
* saves the round trip), then zip EXACTLY the contract-shaped file set into
|
|
10
|
-
* `<name>-<version>.zip
|
|
10
|
+
* `<name>-<version>.zip`, the artifact the studio's upload lane accepts as-is.
|
|
11
11
|
*/
|
|
12
12
|
export async function packageCmd(args) {
|
|
13
13
|
const dir = resolve(args._[0] ?? '.');
|
|
14
14
|
const files = loadArtifactDir(dir);
|
|
15
15
|
const { errors, manifest } = await validateArtifact(files);
|
|
16
16
|
if (errors.length > 0) {
|
|
17
|
-
console.error(`✗ not packaging
|
|
17
|
+
console.error(`✗ not packaging, ${errors.length} validation error${errors.length === 1 ? '' : 's'}:`);
|
|
18
18
|
for (const e of errors) console.error(` - ${e}`);
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { agentsMd } from '../lib/agentsMd.mjs';
|
|
4
|
+
import { loadArtifactDir } from '../lib/artifactFiles.mjs';
|
|
5
|
+
import { validateArtifact } from '../vendor/validator/validate.mjs';
|
|
6
|
+
import { contentModel } from '../vendor/engine/content-footprint.mjs';
|
|
7
|
+
import dialect from '../vendor/contract/v1/dialect.json' with { type: 'json' };
|
|
8
|
+
|
|
9
|
+
const KIT_PACKAGE = JSON.parse(
|
|
10
|
+
readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8')
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `refresh [dir]`, regenerate the GENERATED files against the installed kit and report where the
|
|
15
|
+
* template stands. Rewrites AGENTS.md and CLAUDE.md (they are the kit's briefing, generated at
|
|
16
|
+
* create time and stale the moment the contract moves, your own notes belong in README.md),
|
|
17
|
+
* prints the versions in force, and runs the full conformance check so an upgrade immediately
|
|
18
|
+
* shows what, if anything, the newer contract asks of you. Template sources are never touched.
|
|
19
|
+
*/
|
|
20
|
+
export async function refresh(args) {
|
|
21
|
+
const dir = resolve(args._[0] ?? '.');
|
|
22
|
+
const files = loadArtifactDir(dir);
|
|
23
|
+
if (!files['manifest.json']) {
|
|
24
|
+
console.error(`✗ ${dir} does not contain a template (no manifest.json).`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const manifest = JSON.parse(files['manifest.json']);
|
|
28
|
+
|
|
29
|
+
const briefing = agentsMd(manifest.name);
|
|
30
|
+
writeFileSync(join(dir, 'AGENTS.md'), briefing);
|
|
31
|
+
writeFileSync(join(dir, 'CLAUDE.md'), briefing);
|
|
32
|
+
|
|
33
|
+
console.log(`✓ kit ${KIT_PACKAGE.version} · contract ${dialect.format} · content model ${contentModel.version}`);
|
|
34
|
+
console.log('✓ AGENTS.md and CLAUDE.md regenerated for this contract');
|
|
35
|
+
|
|
36
|
+
const { errors, warnings, contentFootprint, minContentVersion } = await validateArtifact(files);
|
|
37
|
+
if (contentFootprint?.length) {
|
|
38
|
+
console.log(` content footprint: ${contentFootprint.join(', ')}`
|
|
39
|
+
+ (minContentVersion ? ` (minimum model ${minContentVersion})` : ''));
|
|
40
|
+
}
|
|
41
|
+
if (errors.length === 0) {
|
|
42
|
+
console.log(`✓ ${manifest.name}@${manifest.version} conforms${warnings.length ? ` (${warnings.length} warning${warnings.length === 1 ? '' : 's'})` : ''}, nothing to change.`);
|
|
43
|
+
} else {
|
|
44
|
+
console.log(`✗ the current contract asks ${errors.length} thing${errors.length === 1 ? '' : 's'} of this template:`);
|
|
45
|
+
for (const e of errors) console.log(` - ${e}`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `upgrade [dir]`, bring an EXISTING template onto the latest kit and contract. The scaffold
|
|
7
|
+
* pins the kit as a devDependency and every script runs the local copy, so the whole enforcement
|
|
8
|
+
* surface (contract, content model, validator, preview, behaviour runtime) upgrades with one
|
|
9
|
+
* install; this command does that install and then hands over to the FRESHLY INSTALLED kit's
|
|
10
|
+
* `refresh` to regenerate the agent briefing and report what the new contract thinks of your
|
|
11
|
+
* template. Your template sources are never touched, only the generated briefing files.
|
|
12
|
+
*
|
|
13
|
+
* Run it as `npx @port60/template-kit@latest upgrade` (or `p60-template-kit upgrade` from an
|
|
14
|
+
* installed copy, the delegation makes either safe).
|
|
15
|
+
*/
|
|
16
|
+
export function upgrade(args) {
|
|
17
|
+
const dir = resolve(args._[0] ?? '.');
|
|
18
|
+
if (!existsSync(join(dir, 'manifest.json'))) {
|
|
19
|
+
console.error(`✗ ${dir} does not contain a template (no manifest.json).`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
if (!existsSync(join(dir, 'package.json'))) {
|
|
23
|
+
console.error(`✗ ${dir} has no package.json, scaffolds created by this kit pin it as a devDependency.`);
|
|
24
|
+
console.error(' Add it yourself: npm init -y && npm install --save-dev @port60/template-kit@latest');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log('▸ installing the latest kit…');
|
|
29
|
+
const install = spawnSync('npm', ['install', '--save-dev', '@port60/template-kit@latest'],
|
|
30
|
+
{ cwd: dir, stdio: 'inherit' });
|
|
31
|
+
if (install.status !== 0) {
|
|
32
|
+
console.error('✗ npm install failed, nothing else was changed.');
|
|
33
|
+
process.exit(install.status ?? 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The refresh must run on the NEW version (this process may be an older one).
|
|
37
|
+
const localCli = join(dir, 'node_modules', '@port60', 'template-kit', 'bin', 'cli.mjs');
|
|
38
|
+
const refresh = spawnSync(process.execPath, [localCli, 'refresh', dir], { stdio: 'inherit' });
|
|
39
|
+
if (refresh.status !== 0) {
|
|
40
|
+
// An older kit without `refresh` (or a refresh failure): the install still happened.
|
|
41
|
+
console.log('✓ kit upgraded. This version has no refresh step, run `npm run validate` to see');
|
|
42
|
+
console.log(' what the new contract thinks of your template.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -3,9 +3,8 @@ import { validateArtifact } from '../vendor/validator/validate.mjs';
|
|
|
3
3
|
import { loadArtifactDir } from '../lib/artifactFiles.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* `validate <dir> [--json]
|
|
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)
|
|
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, * the validator's two-way honesty checks (declared ⇒ renders the fixture, renders ⇒ declared)
|
|
9
8
|
* are what turn a declaration into proof. `--json` is the AI agent's feedback loop: iterate
|
|
10
9
|
* until {ok: true}.
|
|
11
10
|
*/
|
package/src/lib/agentsMd.mjs
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
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
|
|
2
|
+
// engine on template work and be productive immediately). Written for ANY coding agent, // AGENTS.md is the cross-tool convention and CLAUDE.md carries the identical content for tools
|
|
4
3
|
// that read that name. The briefing is the contract's rules + the iteration loop, so an agent's
|
|
5
4
|
// first move is always the same: read this, edit, validate --json, repeat until clean.
|
|
6
5
|
|
|
7
6
|
export function agentsMd(name) {
|
|
8
7
|
return `# Working on the "${name}" Port60 template
|
|
9
8
|
|
|
10
|
-
You are working on a **Port60 site template
|
|
9
|
+
You are working on a **Port60 site template**, a small, versioned artifact of Liquid renderers
|
|
11
10
|
and CSS that a charity's site is rendered through. It contains **no application code**: no
|
|
12
11
|
JavaScript, no API calls, no payment logic. Templates decide how a site *looks*; the platform
|
|
13
12
|
owns what it *does*.
|
|
@@ -22,25 +21,25 @@ npm run package # validate + produce the uploadable <name>-<version>.z
|
|
|
22
21
|
\`\`\`
|
|
23
22
|
|
|
24
23
|
**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
|
|
24
|
+
on.** The validator is the exact code the platform runs at upload, if it passes here, the
|
|
26
25
|
platform accepts it; if it fails here, the upload will fail identically.
|
|
27
26
|
|
|
28
27
|
## The file layout (nothing else is accepted)
|
|
29
28
|
|
|
30
|
-
- \`manifest.json
|
|
29
|
+
- \`manifest.json\`, identity + what you support. \`name\` and \`version\` are immutable
|
|
31
30
|
identity; bump \`version\` (semver) for every published change.
|
|
32
|
-
- \`layout.liquid
|
|
31
|
+
- \`layout.liquid\`, the page chrome (header/nav/footer). Must contain **exactly one**
|
|
33
32
|
\`{% content %}\` slot. Only needed when \`supports.layout\` is true.
|
|
34
|
-
- \`sections/<type>.liquid
|
|
33
|
+
- \`sections/<type>.liquid\`, one renderer per section type you declare in
|
|
35
34
|
\`supports.sections\`. Charities compose pages from a **closed catalogue** of section types
|
|
36
|
-
(see \`npm run validate\` output or the docs)
|
|
37
|
-
- \`pages/<page>.liquid
|
|
38
|
-
- \`assets/theme.css
|
|
39
|
-
allowed. **No images, no JS
|
|
35
|
+
(see \`npm run validate\` output or the docs), you cannot invent new types.
|
|
36
|
+
- \`pages/<page>.liquid\`, optional full-page templates for \`supports.pageTemplates\`.
|
|
37
|
+
- \`assets/theme.css\`, required, your entire look. More \`.css\` files under \`assets/\` are
|
|
38
|
+
allowed. **No images, no JS**, they are refused at upload.
|
|
40
39
|
|
|
41
40
|
## The rules the validator enforces (do not fight them)
|
|
42
41
|
|
|
43
|
-
1. **Escape-by-default.** Every output is HTML-escaped unless you use \`| raw
|
|
42
|
+
1. **Escape-by-default.** Every output is HTML-escaped unless you use \`| raw\`, and the only
|
|
44
43
|
values you may pass through raw are the contract's sanitised richtext fields.
|
|
45
44
|
2. **The dialect is a whitelist.** \`{% include %}\`, \`{% render %}\`, \`{% layout %}\` and
|
|
46
45
|
several other tags are excluded and fail at parse. Unknown filters throw.
|
|
@@ -55,7 +54,7 @@ platform accepts it; if it fails here, the upload will fail identically.
|
|
|
55
54
|
5. **Render budgets are real.** Runaway loops are killed (~1s per render). Keep renderers simple.
|
|
56
55
|
6. **Context is a whitelist.** Sections see \`{section, brand}\` plus only the collection named by
|
|
57
56
|
that section in the contract; layouts see \`{brand, nav, socials, worship, locale}\`; page
|
|
58
|
-
templates see their documented fixture + \`brand\`. Nothing else exists
|
|
57
|
+
templates see their documented fixture + \`brand\`. Nothing else exists, do not invent
|
|
59
58
|
variables.
|
|
60
59
|
7. **Capabilities are matching metadata, never entitlements.** Every value in
|
|
61
60
|
\`requiresCapabilities\` must have a declared section, page template or island that presents it.
|
|
@@ -77,15 +76,44 @@ platform accepts it; if it fails here, the upload will fail identically.
|
|
|
77
76
|
- Other platform routes keep their platform body and render inside your layout. Capability flags
|
|
78
77
|
expose documented optional context; they do not transfer transaction or route ownership.
|
|
79
78
|
|
|
79
|
+
## The content model
|
|
80
|
+
|
|
81
|
+
Everything you read comes from ONE tree: \`site\`, \`site.brand\`, \`site.nav\`,
|
|
82
|
+
\`site.socials\`, \`site.locale\` and the typed collections under \`site.content.*\`
|
|
83
|
+
(services, events, articles, campaigns, causes, courses, volunteering, media, resources,
|
|
84
|
+
locations, schedules, about). Four rules it never breaks, so neither should you:
|
|
85
|
+
|
|
86
|
+
- Every collection is BOUNDED (a documented cap plus a \`moreHref\`), link onward, never
|
|
87
|
+
assume you have everything.
|
|
88
|
+
- Enum fields are OPEN, branch on the values you style and fall back for the rest; the
|
|
89
|
+
validator proves your template survives values it has never seen.
|
|
90
|
+
- Optional fields are explicitly nullable, always branch.
|
|
91
|
+
- The model only grows. The validator computes your content footprint from the paths you read
|
|
92
|
+
and stamps the minimum model version at publish; you never declare versions, and dynamic
|
|
93
|
+
indexing into \`site.content\` is refused so that stays decidable.
|
|
94
|
+
|
|
95
|
+
SEE the model: \`npm run dev\` serves the full reference with live example data at \`/model\`;
|
|
96
|
+
\`npx p60-template-kit model --json\` prints the machine-readable registry; the same reference
|
|
97
|
+
lives at https://developers.port60.com/reference/content-model/.
|
|
98
|
+
|
|
99
|
+
Bring your own content and imagery: \`npx p60-template-kit content .\` ejects every collection,
|
|
100
|
+
fully populated, as an editable JSON file; replace the copy and the imageUrl values, then
|
|
101
|
+
\`npx p60-template-kit dev . --content my-org.json\` renders YOUR data (the preview admits
|
|
102
|
+
exactly the image hosts your file names, nothing else).
|
|
103
|
+
|
|
104
|
+
You may replace the preview DATA with your own via \`preview-content.json\` beside the
|
|
105
|
+
manifest ({ collection: [items] }, schema-checked, hot-reloaded). The shape is fixed, packaging
|
|
106
|
+
excludes it, and conformance proofs always run on the canonical fixtures.
|
|
107
|
+
|
|
80
108
|
## What to build with
|
|
81
109
|
|
|
82
110
|
- Theme via CSS custom properties and the settings knobs you declare in
|
|
83
|
-
\`manifest.settings.schema
|
|
111
|
+
\`manifest.settings.schema\`, they surface in the charity's Appearance editor as
|
|
84
112
|
\`--p60s-<key>\` variables and \`data-p60s-<key>\` body attributes.
|
|
85
113
|
- Hero photographs (\`homeHero.images\`, when you declare \`supports.heroImagery\`): render one
|
|
86
114
|
photo directly as a TREATED backdrop (a scrim/tint built from your own palette variables via
|
|
87
|
-
color-mix
|
|
88
|
-
\`.hero-slide-scrim\`), and design the no-photo state as a gradient/colour
|
|
115
|
+
color-mix, never raw), place the \`hero_carousel\` island for two or more (style its
|
|
116
|
+
\`.hero-slide-scrim\`), and design the no-photo state as a gradient/colour, never a
|
|
89
117
|
placeholder. The starter's \`.lq-homehero\` is the reference; the validator checks all of this
|
|
90
118
|
behaviourally.
|
|
91
119
|
- "Looks" = named one-click bundles of knob values in \`manifest.looks\`.
|
|
@@ -100,12 +128,12 @@ platform accepts it; if it fails here, the upload will fail identically.
|
|
|
100
128
|
\`search\`, \`language_switch\` and \`next_prayer\` are platform islands. Place and style them;
|
|
101
129
|
never reproduce their API calls or consent behaviour.
|
|
102
130
|
- \`manifest.imagery.hero\` (optional but recommended): declare the photo shape YOUR hero
|
|
103
|
-
composes best with (\`idealAspect\`, \`minWidth\`, a one-line \`note\`)
|
|
131
|
+
composes best with (\`idealAspect\`, \`minWidth\`, a one-line \`note\`), the charity's editor
|
|
104
132
|
measures their actual upload against it and advises. Advice, never enforcement.
|
|
105
133
|
|
|
106
134
|
## Which contract this is
|
|
107
135
|
|
|
108
|
-
The section catalogue, islands and fixtures here are the **Charity Platform contract v1
|
|
136
|
+
The section catalogue, islands and fixtures here are the **Charity Platform contract v1**, the
|
|
109
137
|
platform's first product surface. The dialect, the rules above and this toolchain are
|
|
110
138
|
platform-wide; other Port60 products will ship their own contract packs. Do not assume the
|
|
111
139
|
current section list is universal.
|
|
@@ -113,7 +141,7 @@ current section list is universal.
|
|
|
113
141
|
## Reference
|
|
114
142
|
|
|
115
143
|
The full generated reference (sections, islands, context variables, tokens, dialect) lives at
|
|
116
|
-
https://developers.port60.com
|
|
144
|
+
https://developers.port60.com, also available in one file for agents at
|
|
117
145
|
https://developers.port60.com/llms-full.txt
|
|
118
146
|
`;
|
|
119
147
|
}
|