@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.
@@ -0,0 +1,111 @@
1
+ // The STUDIO PREVIEW renderer (developer program T1.3): a validated artifact rendered over the
2
+ // contract's KIND FIXTURES — no tenant, no tenant data, exactly what `template-kit dev` will show
3
+ // locally in T3. Deliberately NOT the production TemplateHost path: a studio version must never
4
+ // touch a live site, so this renders from an in-memory file map and the page it produces is
5
+ // self-contained and network-dead — a CSP meta of default-src 'none' means the template's CSS
6
+ // cannot fetch, beacon or import anything, and the consumer embeds it in a sandboxed iframe.
7
+ // Islands render as labelled placeholder boxes (previews carry no runtime).
8
+ import { Liquid } from 'liquidjs';
9
+ import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
10
+ import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
11
+ import dialect from '../contract/v1/dialect.json' with { type: 'json' };
12
+ import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
13
+ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
14
+ import contextContract from '../contract/v1/context.json' with { type: 'json' };
15
+
16
+ const escapeHtml = (s) =>
17
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
+
19
+ function islandPlaceholder(name) {
20
+ return `<div class="p60-preview-island" role="note">island: ${escapeHtml(name)}</div>`;
21
+ }
22
+
23
+ function partsToHtml(html, contentHtml) {
24
+ let out = '';
25
+ for (const part of splitIslandParts(html)) {
26
+ if (part.island === CONTENT_SLOT) out += contentHtml ?? '';
27
+ else if (part.island) out += islandPlaceholder(part.island);
28
+ else out += part.html;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** Knob defaults → the same body attributes / CSS vars TemplateHost stamps, minus network fonts
34
+ * (the CSP kills font fetches by design; system fallbacks are fine for a structural preview). */
35
+ function knobDefaults(manifest) {
36
+ const attrs = [];
37
+ const vars = [];
38
+ for (const knob of manifest?.settings?.schema ?? []) {
39
+ const value = knob.default;
40
+ if (value == null || value === '') continue;
41
+ if (knob.kind === 'color') {
42
+ if (/^#[0-9a-fA-F]{3,8}$/.test(String(value))) vars.push(`--p60s-${knob.key}: ${value};`);
43
+ } else if (knob.kind === 'font') {
44
+ vars.push(`--p60s-${knob.key}: '${String(value).replace(/'/g, '')}', system-ui, sans-serif;`);
45
+ } else {
46
+ attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(value))}"`);
47
+ }
48
+ }
49
+ return { attrs: attrs.join(' '), vars: vars.join(' ') };
50
+ }
51
+
52
+ /**
53
+ * Renders the artifact's declared sections (sample fixtures) inside its layout (when declared)
54
+ * and returns a complete, self-contained HTML document. Throws on parse/render failure — callers
55
+ * preview only versions the validator has already passed, so a throw here is a bug report, not
56
+ * a user flow.
57
+ */
58
+ export async function renderStudioPreview(files) {
59
+ const manifest = JSON.parse(files['manifest.json']);
60
+ const allIslands = new Set(islandRegistry.islands.map((i) => i.name));
61
+ const liquid = new Liquid({ outputEscape: 'escape', strictFilters: true, ...LIQUID_BUDGETS });
62
+ configureDialect(liquid, dialect, allIslands);
63
+
64
+ const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
65
+ const brand = contextContract.fixtures.brand;
66
+
67
+ const sectionsHtml = [];
68
+ for (const type of manifest?.supports?.sections ?? []) {
69
+ const entry = catalogueByType.get(type);
70
+ const source = files[`sections/${type}.liquid`];
71
+ if (!entry || source == null) continue;
72
+ const rendered = await liquid.parseAndRender(source, { section: entry.sample ?? {}, brand });
73
+ sectionsHtml.push(partsToHtml(rendered, ''));
74
+ }
75
+ const contentHtml = sectionsHtml.join('\n');
76
+
77
+ let bodyHtml;
78
+ if (manifest?.supports?.layout && files['layout.liquid'] != null) {
79
+ const rendered = await liquid.parseAndRender(files['layout.liquid'], {
80
+ brand,
81
+ nav: contextContract.fixtures.layout.nav,
82
+ socials: contextContract.fixtures.layout.socials ?? [],
83
+ worship: manifest?.supports?.worship ? (contextContract.fixtures.layout.worship ?? null) : null
84
+ });
85
+ bodyHtml = partsToHtml(rendered, contentHtml);
86
+ } else {
87
+ bodyHtml = contentHtml;
88
+ }
89
+
90
+ const themeCss = files['assets/theme.css'] ?? '';
91
+ const { attrs, vars } = knobDefaults(manifest);
92
+
93
+ return `<!doctype html>
94
+ <html lang="en">
95
+ <head>
96
+ <meta charset="utf-8">
97
+ <!-- Network-dead by design: the template's CSS can style, never fetch. -->
98
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">
99
+ <meta name="viewport" content="width=device-width, initial-scale=1">
100
+ <title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')} — studio preview</title>
101
+ <style>${vars ? `:root { ${vars} }` : ''}
102
+ .p60-preview-island { border: 2px dashed #8aa; border-radius: 8px; padding: 18px; margin: 8px 0;
103
+ font: 13px/1.4 system-ui, sans-serif; color: #567; background: rgba(120,160,160,0.08); text-align: center; }
104
+ </style>
105
+ <style>${themeCss}</style>
106
+ </head>
107
+ <body ${attrs}>
108
+ ${bodyHtml}
109
+ </body>
110
+ </html>`;
111
+ }
@@ -0,0 +1,265 @@
1
+ // The Port60 template CONFORMANCE VALIDATOR as a PURE MODULE (developer program T1.3): the same
2
+ // checks the publish-time CLI has always run, callable with an in-memory file map — no fs, no
3
+ // argv, no process.exit — so the studio upload lane (an HTTP endpoint) and the CLI share ONE
4
+ // implementation, and "validated ⇒ renders in production" keeps holding: the Liquid instance is
5
+ // configured identically to the engine's, dialect enforcement is the same shared module, and the
6
+ // render budgets here match production's.
7
+ //
8
+ // validateArtifact(files) → { errors: string[], warnings: string[], manifest: object|null }
9
+ //
10
+ // `files` is a plain object of artifact-relative path → string content (manifest.json,
11
+ // layout.liquid, sections/*.liquid, pages/*.liquid, assets/theme.css).
12
+ import { Liquid } from 'liquidjs';
13
+ import Ajv2020 from 'ajv/dist/2020.js';
14
+ import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
15
+ import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
16
+
17
+ import dialect from '../contract/v1/dialect.json' with { type: 'json' };
18
+ import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
19
+ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
20
+ import manifestSchema from '../contract/v1/manifest.schema.json' with { type: 'json' };
21
+ import contextContract from '../contract/v1/context.json' with { type: 'json' };
22
+ import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
23
+
24
+ const Ajv = Ajv2020.default ?? Ajv2020;
25
+
26
+ export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract };
27
+
28
+ export async function validateArtifact(files) {
29
+ const errors = [];
30
+ const warnings = [];
31
+ const has = (path) => Object.hasOwn(files, path);
32
+ const read = (path) => files[path];
33
+
34
+ // 1. Manifest against the schema.
35
+ let manifest = null;
36
+ if (!has('manifest.json')) {
37
+ return { errors: ['manifest.json is missing — every artifact starts with its manifest'], warnings, manifest };
38
+ }
39
+ try {
40
+ manifest = JSON.parse(read('manifest.json'));
41
+ } catch (e) {
42
+ return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
43
+ }
44
+ const ajv = new Ajv({ allErrors: true });
45
+ const validateManifest = ajv.compile(manifestSchema);
46
+ if (!validateManifest(manifest)) {
47
+ for (const err of validateManifest.errors) {
48
+ errors.push(`manifest${err.instancePath || ''}: ${err.message}`);
49
+ }
50
+ }
51
+
52
+ // Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
53
+ // it), and the slot must declare the weights the template's typographic system needs.
54
+ {
55
+ const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
56
+ for (const knob of manifest?.settings?.schema ?? []) {
57
+ if (knob.kind !== 'font') continue;
58
+ if (typeof knob.default !== 'string' || !familyNames.has(knob.default)) {
59
+ errors.push(`settings knob '${knob.key}': font default '${knob.default}' is not in the font catalogue`);
60
+ } else if (!Array.isArray(knob.weights) || knob.weights.length === 0) {
61
+ errors.push(`settings knob '${knob.key}': font knobs must declare the weights the template uses`);
62
+ }
63
+ }
64
+ }
65
+
66
+ // Looks: every value must target a declared knob and be valid for it — a look that half-applies
67
+ // would leave the tenant in a state no author designed.
68
+ {
69
+ const knobByKey = new Map((manifest?.settings?.schema ?? []).map((k) => [k.key, k]));
70
+ const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
71
+ const seenNames = new Set();
72
+ for (const look of manifest?.looks ?? []) {
73
+ if (seenNames.has(look.name)) errors.push(`look '${look.name}': duplicate name`);
74
+ seenNames.add(look.name);
75
+ for (const [key, value] of Object.entries(look.values ?? {})) {
76
+ const knob = knobByKey.get(key);
77
+ if (!knob) {
78
+ errors.push(`look '${look.name}': '${key}' is not a declared settings knob`);
79
+ } else if (knob.kind === 'select' && !(knob.options ?? []).includes(value)) {
80
+ errors.push(`look '${look.name}': '${value}' is not an option of select knob '${key}'`);
81
+ } else if (knob.kind === 'font' && !familyNames.has(value)) {
82
+ errors.push(`look '${look.name}': font '${value}' is not in the font catalogue`);
83
+ } else if (knob.kind === 'color' && value !== '' && !/^#[0-9a-fA-F]{3,8}$/.test(value)) {
84
+ errors.push(`look '${look.name}': '${value}' is not a colour value for knob '${key}'`);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ // Engine identical to production: escape-by-default, strict filters, restricted dialect,
91
+ // and the SAME DoS budgets the live renderer runs with.
92
+ const availableIslands = new Set(
93
+ islandRegistry.islands.filter((i) => i.status === 'available').map((i) => i.name)
94
+ );
95
+ const allIslands = new Set(islandRegistry.islands.map((i) => i.name));
96
+ const liquid = new Liquid({ outputEscape: 'escape', strictFilters: true, ...LIQUID_BUDGETS });
97
+ configureDialect(liquid, dialect, allIslands);
98
+
99
+ const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
100
+ const declaredIslands = new Set(manifest?.supports?.islands ?? []);
101
+ const placedIslands = new Set();
102
+
103
+ // 2–4. Sections: catalogue membership, parse, fixture renders.
104
+ for (const type of manifest?.supports?.sections ?? []) {
105
+ const entry = catalogueByType.get(type);
106
+ if (!entry) {
107
+ errors.push(`section '${type}': not in the platform section catalogue`);
108
+ continue;
109
+ }
110
+ const file = `sections/${type}.liquid`;
111
+ if (!has(file)) {
112
+ errors.push(`section '${type}': declared in the manifest but sections/${type}.liquid is missing`);
113
+ continue;
114
+ }
115
+ let parsed;
116
+ try {
117
+ parsed = liquid.parse(read(file));
118
+ } catch (e) {
119
+ errors.push(`section '${type}': does not parse under the dialect — ${e.message}`);
120
+ continue;
121
+ }
122
+ for (const fixtureName of ['minimal', 'sample']) {
123
+ const fixture = entry[fixtureName] ?? {};
124
+ try {
125
+ const html = await liquid.render(parsed, {
126
+ section: fixture,
127
+ brand: contextContract.fixtures.brand
128
+ });
129
+ for (const part of splitIslandParts(html)) {
130
+ if (part.island === CONTENT_SLOT) {
131
+ errors.push(`section '${type}': uses {% content %} — that tag is layout-only`);
132
+ } else if (part.island) {
133
+ placedIslands.add(part.island);
134
+ }
135
+ }
136
+ } catch (e) {
137
+ errors.push(`section '${type}': failed rendering the ${fixtureName} fixture — ${e.message}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ // 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
143
+ if (manifest?.supports?.worship && !manifest?.supports?.layout) {
144
+ errors.push('manifest: supports.worship requires supports.layout — the worship rail is layout chrome');
145
+ }
146
+ if (manifest?.supports?.layout) {
147
+ if (!has('layout.liquid')) {
148
+ errors.push('layout: manifest declares supports.layout but layout.liquid is missing');
149
+ } else {
150
+ let parsedLayout;
151
+ try {
152
+ parsedLayout = liquid.parse(read('layout.liquid'));
153
+ } catch (e) {
154
+ errors.push(`layout: does not parse under the dialect — ${e.message}`);
155
+ }
156
+ if (parsedLayout) {
157
+ try {
158
+ const html = await liquid.render(parsedLayout, {
159
+ brand: contextContract.fixtures.brand,
160
+ nav: contextContract.fixtures.layout.nav,
161
+ socials: contextContract.fixtures.layout.socials ?? [],
162
+ worship: contextContract.fixtures.layout.worship ?? null
163
+ });
164
+ let contentSlots = 0;
165
+ const layoutIslands = [];
166
+ for (const part of splitIslandParts(html)) {
167
+ if (part.island === CONTENT_SLOT) contentSlots++;
168
+ else if (part.island) { placedIslands.add(part.island); layoutIslands.push(part.island); }
169
+ }
170
+ if (contentSlots !== 1) {
171
+ errors.push(`layout: must contain exactly one {% content %} slot (found ${contentSlots})`);
172
+ }
173
+ if (!layoutIslands.includes('member_menu')) {
174
+ warnings.push("layout: no {% island 'member_menu' %} — member sign-in will be unreachable on tenants that allow sign-ups; place it in your header");
175
+ }
176
+
177
+ // Two-sided worship honesty, checked BEHAVIOURALLY: does the rendered layout actually
178
+ // display the worship fixture's times? Declaration and behaviour must agree — the
179
+ // choosers steer worship-enabled tenants by supports.worship, so a false declaration
180
+ // either hides their times (undeclared but rendered is fine to fix by declaring) or
181
+ // promises a rail that never appears.
182
+ const worshipProbe = contextContract.fixtures.layout.worship?.times?.[0]?.name;
183
+ if (worshipProbe) {
184
+ const rendersWorship = html.includes(worshipProbe);
185
+ if (manifest?.supports?.worship && !rendersWorship) {
186
+ errors.push('layout: manifest declares supports.worship but the rendered layout does not display the worship fixture times — the rail never appears');
187
+ }
188
+ if (!manifest?.supports?.worship && rendersWorship) {
189
+ errors.push('layout: renders the worship rail but the manifest does not declare supports.worship — declare it so the choosers can badge it');
190
+ }
191
+ if (manifest?.supports?.worship) {
192
+ const nullHtml = await liquid.render(parsedLayout, {
193
+ brand: contextContract.fixtures.brand,
194
+ nav: contextContract.fixtures.layout.nav,
195
+ socials: contextContract.fixtures.layout.socials ?? [],
196
+ worship: null
197
+ });
198
+ if (nullHtml.includes(worshipProbe)) {
199
+ errors.push('layout: worship rail content appears even when `worship` is null — always branch on it (tenants without a schedule must not see a rail)');
200
+ }
201
+ }
202
+ }
203
+ } catch (e) {
204
+ errors.push(`layout: failed rendering the layout fixture — ${e.message}`);
205
+ }
206
+ }
207
+ }
208
+ } else if (has('layout.liquid')) {
209
+ warnings.push('layout.liquid present but manifest.supports.layout is not true — it will be ignored');
210
+ }
211
+
212
+ // 8. Page templates (when declared): file exists, parses, renders the page's data fixture.
213
+ for (const pageName of manifest?.supports?.pageTemplates ?? []) {
214
+ const fixture = contextContract.fixtures.pages?.[pageName];
215
+ if (!fixture) {
216
+ errors.push(`page template '${pageName}': no such page in the contract (no fixtures.pages.${pageName})`);
217
+ continue;
218
+ }
219
+ const pageFile = `pages/${pageName}.liquid`;
220
+ if (!has(pageFile)) {
221
+ errors.push(`page template '${pageName}': declared in supports.pageTemplates but pages/${pageName}.liquid is missing`);
222
+ continue;
223
+ }
224
+ let parsedPage;
225
+ try {
226
+ parsedPage = liquid.parse(read(pageFile));
227
+ } catch (e) {
228
+ errors.push(`page template '${pageName}': does not parse under the dialect — ${e.message}`);
229
+ continue;
230
+ }
231
+ try {
232
+ const html = await liquid.render(parsedPage, { ...fixture, brand: contextContract.fixtures.brand });
233
+ for (const part of splitIslandParts(html)) {
234
+ if (part.island === CONTENT_SLOT) {
235
+ errors.push(`page template '${pageName}': uses {% content %} — that tag is layout-only`);
236
+ } else if (part.island) {
237
+ placedIslands.add(part.island);
238
+ }
239
+ }
240
+ } catch (e) {
241
+ errors.push(`page template '${pageName}': failed rendering the page fixture — ${e.message}`);
242
+ }
243
+ }
244
+
245
+ // 5. Island discipline: placed ⊆ declared ⊆ registry (and available).
246
+ for (const name of placedIslands) {
247
+ if (!declaredIslands.has(name)) {
248
+ errors.push(`island '${name}': placed in a section but not declared in manifest.supports.islands`);
249
+ }
250
+ }
251
+ for (const name of declaredIslands) {
252
+ if (!allIslands.has(name)) {
253
+ errors.push(`island '${name}': not in the platform island registry`);
254
+ } else if (!availableIslands.has(name)) {
255
+ warnings.push(`island '${name}': registry status is 'planned' — it will render nothing until available`);
256
+ }
257
+ }
258
+
259
+ // 6. Theme.
260
+ if (!has('assets/theme.css') || read('assets/theme.css').trim() === '') {
261
+ errors.push('assets/theme.css missing or empty — a template must ship its look');
262
+ }
263
+
264
+ return { errors, warnings, manifest };
265
+ }
@@ -0,0 +1,43 @@
1
+ /* starter theme — a deliberately distinct look (midnight/emerald) proving the artifact owns the skin */
2
+ :root:root {
3
+ --bg: #0e1614;
4
+ --bg-tint: #14201d;
5
+ --surface: #16231f;
6
+ --ink: #eef6f2;
7
+ --muted: #93a8a0;
8
+ --primary: #2dd4a7;
9
+ --primary-strong: #19b28a;
10
+ --primary-soft: rgba(45, 212, 167, 0.14);
11
+ --accent: #f2b23e;
12
+ --accent-strong: #d99a24;
13
+ --line: rgba(238, 246, 242, 0.14);
14
+ --line-strong: rgba(45, 212, 167, 0.4);
15
+ --shadow-sm: 0 8px 24px rgba(0, 0, 0, 0.35);
16
+ --shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
17
+ --radius: 10px;
18
+ --radius-sm: 7px;
19
+ }
20
+ body { background: var(--bg); }
21
+ h1, h2, h3, h4 { color: var(--ink); font-family: 'IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), sans-serif; letter-spacing: -0.02em; margin: 0 0 1rem; line-height: 1.1; }
22
+ h1 { font-size: clamp(2.4rem, 5vw, 3.6rem); }
23
+ h2 { font-size: clamp(1.7rem, 3vw, 2.4rem); }
24
+ h3 { font-size: 1.15rem; }
25
+ p { color: var(--muted); line-height: 1.7; margin: 0 0 1rem; }
26
+ strong { color: var(--ink); }
27
+ .site-header { background: rgba(14, 22, 20, 0.86); }
28
+ .lq-hero { padding: 4.5rem 0 2.5rem; border-bottom: 1px solid var(--line); }
29
+ .lq-kicker { display: inline-block; border: 1px solid var(--line-strong); color: var(--primary); border-radius: 4px; padding: 0.3rem 0.8rem; font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 700; }
30
+ .lq-hero h1 { margin-top: 1rem; max-width: 18ch; }
31
+ .lq-hero-body { max-width: 62ch; }
32
+ .lq-values { padding: 3.5rem 0; }
33
+ .lq-value-rail { list-style: none; margin: 0; padding: 0 0 0 1.4rem; border-left: 2px solid var(--line-strong); display: grid; gap: 1.6rem; max-width: 700px; }
34
+ .lq-value-rail li { position: relative; }
35
+ .lq-value-dot { position: absolute; left: -1.75rem; top: 0.35rem; width: 0.65rem; height: 0.65rem; border-radius: 50%; background: var(--primary); box-shadow: 0 0 0 4px var(--primary-soft); }
36
+ .lq-value-rail h3 { margin: 0 0 0.25rem; }
37
+ .lq-value-rail p { margin: 0; }
38
+ .lq-cta { padding: 3.5rem 0 4.5rem; }
39
+ .lq-cta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; align-items: start; }
40
+ @media (max-width: 900px) { .lq-cta-grid { grid-template-columns: 1fr; } }
41
+
42
+ /* The one font slot — headings above already follow it; body text too. */
43
+ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-sans-serif, system-ui, sans-serif; }
@@ -0,0 +1,75 @@
1
+ {% comment %}
2
+ The starter's chrome — header/nav/footer around the {% content %} slot. It reuses the platform
3
+ chrome classes (.site-header/.site-nav/…) so the base mechanics (sticky header, burger
4
+ breakpoint, dropdown hover) apply, and emits the documented behaviour attributes
5
+ (data-nav-burger, #site-nav) so the platform tail script wires the mobile burger. Identity is
6
+ PLACED, never rebuilt: {% island 'member_menu' %}. The footer derives everything from brand/nav —
7
+ no fabricated copy (derive or omit).
8
+ {% endcomment %}
9
+ <header class="site-header">
10
+ <div class="container header-inner">
11
+ <a class="brand-lockup" href="/">
12
+ {% if brand.logoUrl and brand.logoType != 'icon' %}
13
+ <img class="brand-logo" src="{{ brand.logoUrl }}" alt="{{ brand.name }}">
14
+ {% elsif brand.logoUrl %}
15
+ <img class="brand-logo-icon" src="{{ brand.logoUrl }}" alt="">
16
+ <strong>{{ brand.name }}</strong>
17
+ {% else %}
18
+ <span class="brand-mark" aria-hidden="true">♥</span>
19
+ <strong>{{ brand.name }}</strong>
20
+ {% endif %}
21
+ </a>
22
+
23
+ <button type="button" class="nav-burger" data-nav-burger aria-label="Menu" aria-expanded="false" aria-controls="site-nav">
24
+ <span aria-hidden="true"></span>
25
+ <span aria-hidden="true"></span>
26
+ <span aria-hidden="true"></span>
27
+ </button>
28
+
29
+ <nav class="site-nav" id="site-nav" aria-label="Primary">
30
+ {% for item in nav.items %}
31
+ {% if item.children %}
32
+ <span class="nav-dropdown">
33
+ <a href="{{ item.href }}" class="nav-dropdown-toggle" aria-haspopup="true">{{ item.label }} ▾</a>
34
+ <span class="nav-dropdown-menu">
35
+ {% for child in item.children %}
36
+ <a href="{{ child.href }}">{{ child.label }}</a>
37
+ {% endfor %}
38
+ </span>
39
+ </span>
40
+ {% elsif item.cta %}
41
+ <a class="nav-cta" href="{{ item.href }}">{{ item.label }}</a>
42
+ {% else %}
43
+ <a href="{{ item.href }}">{{ item.label }}</a>
44
+ {% endif %}
45
+ {% endfor %}
46
+ {% island 'member_menu' %}
47
+ </nav>
48
+ </div>
49
+ </header>
50
+
51
+ <main>
52
+ {% content %}
53
+ </main>
54
+
55
+ <footer class="site-footer">
56
+ <div class="container footer-grid">
57
+ <div>
58
+ <p class="lq-kicker">{{ brand.name }}</p>
59
+ {% if brand.tagline %}<h2>{{ brand.tagline }}</h2>{% endif %}
60
+ </div>
61
+ <div class="footer-links">
62
+ {% for item in nav.items %}
63
+ <a href="{{ item.href }}">{{ item.label }}</a>
64
+ {% endfor %}
65
+ </div>
66
+ </div>
67
+ <div class="container">
68
+ <p class="footer-note">
69
+ <span class="footer-legal-links">
70
+ <a href="/cookies">Cookies</a>
71
+ · <button type="button" class="footer-linklike" data-consent-open>Cookie settings</button>
72
+ </span>
73
+ </p>
74
+ </div>
75
+ </footer>
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "starter",
3
+ "version": "1.2.0",
4
+ "format": "port60-liquid@1",
5
+ "label": "Starter",
6
+ "description": "The reference template for the Port60 dialect \u2014 the developer docs' worked example and the base to copy when building your own. 1.1.0 adds the layout slot: the template owns the header, navigation and footer chrome.",
7
+ "supports": {
8
+ "pages": [
9
+ "about"
10
+ ],
11
+ "sections": [
12
+ "hero",
13
+ "values",
14
+ "cta"
15
+ ],
16
+ "islands": [
17
+ "donation_widget",
18
+ "member_menu"
19
+ ],
20
+ "layout": true
21
+ },
22
+ "settings": {
23
+ "schema": [
24
+ {
25
+ "key": "siteFont",
26
+ "kind": "font",
27
+ "label": "Site font",
28
+ "default": "Inter",
29
+ "weights": [
30
+ 400,
31
+ 500,
32
+ 600,
33
+ 700,
34
+ 800
35
+ ]
36
+ }
37
+ ]
38
+ },
39
+ "changelog": "The reference font slot \u2014 one 'Site font' knob showing the var(--p60s-*) pattern."
40
+ }
@@ -0,0 +1,12 @@
1
+ <section class="lq-cta">
2
+ <div class="container lq-cta-grid">
3
+ <div>
4
+ <h2>{{ section.heading }}</h2>
5
+ {% if section.text %}<p>{{ section.text }}</p>{% endif %}
6
+ <a class="button button-primary" href="{{ section.buttonHref }}">{{ section.buttonLabel }}</a>
7
+ </div>
8
+ <div class="lq-cta-widget">
9
+ {% island 'donation_widget' %}
10
+ </div>
11
+ </div>
12
+ </section>
@@ -0,0 +1,7 @@
1
+ <section class="lq-hero">
2
+ <div class="container">
3
+ {% if section.eyebrow %}<p class="lq-kicker">{{ section.eyebrow }}</p>{% endif %}
4
+ <h1>{{ section.title }}</h1>
5
+ <div class="lq-hero-body">{{ section.bodyHtml | raw }}</div>
6
+ </div>
7
+ </section>
@@ -0,0 +1,14 @@
1
+ <section class="lq-values">
2
+ <div class="container">
3
+ <h2>{{ section.heading | default: "What guides us" }}</h2>
4
+ <ol class="lq-value-rail">
5
+ {% for item in section.items %}
6
+ <li>
7
+ <span class="lq-value-dot"></span>
8
+ <h3>{{ item.title }}</h3>
9
+ <p>{{ item.description }}</p>
10
+ </li>
11
+ {% endfor %}
12
+ </ol>
13
+ </div>
14
+ </section>