@port60/template-kit 0.7.0 → 0.9.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.
@@ -20,10 +20,24 @@ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
20
20
  import manifestSchema from '../contract/v1/manifest.schema.json' with { type: 'json' };
21
21
  import contextContract from '../contract/v1/context.json' with { type: 'json' };
22
22
  import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
23
+ import layoutContract from '../contract/v1/layout.json' with { type: 'json' };
24
+ import behaviourCatalogue from '../contract/v1/behaviours.json' with { type: 'json' };
23
25
 
24
26
  const Ajv = Ajv2020.default ?? Ajv2020;
25
27
 
26
- export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract };
28
+ export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue };
29
+
30
+ // Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
31
+ // errors over the RAW liquid source — even inside comments, because there is no legitimate reason
32
+ // for the tokens to appear at all. The handler pattern names real DOM event families rather than
33
+ // matching any on* word, so attributes like `once` or `online` never false-positive.
34
+ const FORBIDDEN_MARKUP = [
35
+ [/<script\b/i, 'a <script> tag'],
36
+ [/<(iframe|object|embed)\b/i, 'an embedded frame or plugin element'],
37
+ [/\son(?:click|dbl|aux|load|error|abort|unload|mouse|pointer|touch|drag|drop|wheel|scroll|key|focus|blur|input|change|submit|reset|invalid|select|toggle|copy|paste|cut|context|play|pause|ended|seek|stall|suspend|time|volume|waiting|canplay|animation|transition|message|resize|hashchange|popstate|storage|got|lost)[a-z]*\s*=/i,
38
+ 'an inline event handler'],
39
+ [/javascript\s*:/i, 'a javascript: URL'],
40
+ ];
27
41
 
28
42
  export async function validateArtifact(files) {
29
43
  const errors = [];
@@ -49,6 +63,96 @@ export async function validateArtifact(files) {
49
63
  }
50
64
  }
51
65
 
66
+ // Capability declarations are catalogue MATCHING metadata, not an entitlement shortcut. Keep
67
+ // them honest by requiring one corresponding template-facing surface. The mapping is deliberately
68
+ // structural: it proves the design can present a capability without inspecting tenant data or
69
+ // crossing the platform-owned transaction, identity and consent boundaries.
70
+ {
71
+ const sections = new Set(manifest?.supports?.sections ?? []);
72
+ const islands = new Set(manifest?.supports?.islands ?? []);
73
+ const pages = new Set(manifest?.supports?.pageTemplates ?? []);
74
+ const hasAny = (values, expected) => expected.some((value) => values.has(value));
75
+ const capabilitySurface = {
76
+ giving: () => islands.has('donation_widget'),
77
+ appeals: () => hasAny(sections, ['appealGrid', 'emergency']) || islands.has('donation_widget'),
78
+ worship: () => manifest?.supports?.worship === true || islands.has('next_prayer'),
79
+ courses: () => pages.has('course') || islands.has('course_enrol'),
80
+ membership: () => islands.has('member_menu'),
81
+ events: () => sections.has('events') || pages.has('events') || islands.has('events_carousel'),
82
+ articles: () => sections.has('articles') || hasAny(pages, ['articles', 'article']) || islands.has('latest_articles'),
83
+ services: () => sections.has('programmes'),
84
+ volunteering: () => sections.has('volunteering') || islands.has('volunteer_signup'),
85
+ forms: () => islands.has('form'),
86
+ resources: () => sections.has('resources'),
87
+ locations: () => sections.has('locations'),
88
+ newsletter: () => islands.has('newsletter_signup'),
89
+ i18n: () => islands.has('language_switch'),
90
+ search: () => islands.has('search'),
91
+ media: () => sections.has('media')
92
+ };
93
+ for (const capability of manifest?.requiresCapabilities ?? []) {
94
+ if (!capabilitySurface[capability]?.()) {
95
+ errors.push(`manifest: requiresCapabilities '${capability}' has no corresponding declared section, page template or island`);
96
+ }
97
+ }
98
+ }
99
+
100
+ // Markup and attributes, NEVER code — the machine-enforced JavaScript ban over every liquid
101
+ // source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
102
+ // declares supports.behaviors and uses the data-p60-* grammar.
103
+ for (const [path, source] of Object.entries(files)) {
104
+ if (!path.endsWith('.liquid')) continue;
105
+ for (const [pattern, what] of FORBIDDEN_MARKUP) {
106
+ if (pattern.test(source)) {
107
+ errors.push(`${path}: contains ${what} — templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
108
+ }
109
+ }
110
+ }
111
+
112
+ // Behaviour declaration and usage must agree in BOTH directions. Source-level, deliberately:
113
+ // usage often sits inside content-dependent branches the fixtures never take, so the grammar's
114
+ // presence in the source is the honest minimal proof.
115
+ {
116
+ const declaredBehaviours = new Set(manifest?.supports?.behaviors ?? []);
117
+ const liquidSource = Object.entries(files)
118
+ .filter(([path]) => path.endsWith('.liquid'))
119
+ .map(([, source]) => source)
120
+ .join('\n');
121
+ const PRIMARY_ATTR = {
122
+ reveal: [/data-p60-reveal\b/, 'data-p60-reveal'],
123
+ counter: [/data-p60-count\b/, 'data-p60-count'],
124
+ progress: [/data-p60-progress\b/, 'data-p60-progress'],
125
+ countdown: [/data-p60-countdown\b/, 'data-p60-countdown'],
126
+ accordion: [/data-p60-accordion\b/, 'data-p60-accordion'],
127
+ carousel: [/data-p60-carousel\b/, 'data-p60-carousel'],
128
+ stickyHeader: [/data-p60-sticky-header\b/, 'data-p60-sticky-header'],
129
+ stickyCta: [/data-p60-sticky-cta\b/, 'data-p60-sticky-cta'],
130
+ lightbox: [/data-p60-lightbox\b/, 'data-p60-lightbox'],
131
+ tabs: [/data-p60-tabs\b/, 'data-p60-tabs'],
132
+ };
133
+ for (const name of declaredBehaviours) {
134
+ const primary = PRIMARY_ATTR[name];
135
+ if (primary && !primary[0].test(liquidSource)) {
136
+ errors.push(`manifest: supports.behaviors declares '${name}' but no ${primary[1]} attribute appears in any liquid source`);
137
+ }
138
+ }
139
+ for (const [name, [pattern, attr]] of Object.entries(PRIMARY_ATTR)) {
140
+ if (pattern.test(liquidSource) && !declaredBehaviours.has(name)) {
141
+ errors.push(`behaviour '${name}': ${attr} appears in the markup but supports.behaviors does not declare it`);
142
+ }
143
+ }
144
+ if (/data-p60-carousel(?!-)\b/.test(liquidSource) && !/data-p60-slide\b/.test(liquidSource)) {
145
+ errors.push("behaviour 'carousel': a data-p60-carousel container needs data-p60-slide children");
146
+ }
147
+ if (/data-p60-lightbox(?!-)\b/.test(liquidSource) && !/data-p60-lightbox-item\b/.test(liquidSource)) {
148
+ errors.push("behaviour 'lightbox': a data-p60-lightbox group needs data-p60-lightbox-item anchors");
149
+ }
150
+ if (/data-p60-tabs\b/.test(liquidSource)
151
+ && (!/data-p60-tab(?!s)\b/.test(liquidSource) || !/data-p60-panel\b/.test(liquidSource))) {
152
+ errors.push("behaviour 'tabs': a data-p60-tabs container needs data-p60-tab controls and data-p60-panel panels");
153
+ }
154
+ }
155
+
52
156
  // Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
53
157
  // it), and the slot must declare the weights the template's typographic system needs.
54
158
  {
@@ -111,7 +215,12 @@ export async function validateArtifact(files) {
111
215
  const WIDGET_SECTIONS = {
112
216
  events: { island: 'events_carousel', dataKey: 'events', sentinel: 'Fixture Lantern Gala' },
113
217
  whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: 'Fixture Open Morning' },
114
- articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: 'Fixture Winter Diary' }
218
+ articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: 'Fixture Winter Diary' },
219
+ campaigns: { island: null, dataKey: 'campaigns', sentinel: 'Fixture Winter Campaign' },
220
+ resources: { island: null, dataKey: 'resources', sentinel: 'Fixture annual report.pdf' },
221
+ locations: { island: null, dataKey: 'locations', sentinel: 'Fixture Community Hall' },
222
+ volunteering: { island: 'volunteer_signup', dataKey: 'opportunities', sentinel: 'Fixture Garden Volunteer' },
223
+ media: { island: null, dataKey: 'media', sentinel: 'Fixture Community Talk' }
115
224
  };
116
225
 
117
226
  // 2–4. Sections: catalogue membership, parse, fixture renders.
@@ -144,11 +253,14 @@ export async function validateArtifact(files) {
144
253
  brand: contextContract.fixtures.brand,
145
254
  ...dataFixture
146
255
  });
147
- const placesIsland = splitIslandParts(populated).some((p) => p.island === widget.island);
256
+ const placesIsland = widget.island !== null
257
+ && splitIslandParts(populated).some((p) => p.island === widget.island);
148
258
  if (!placesIsland) {
149
259
  if (!populated.includes(widget.sentinel)) {
150
260
  errors.push(
151
- `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context — render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
261
+ widget.island
262
+ ? `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context — render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
263
+ : `section '${type}': does not render the ${widget.dataKey} context — the fixture's "${widget.sentinel}" must appear`
152
264
  );
153
265
  }
154
266
  const empty = await liquid.render(parsed, {
@@ -250,7 +362,8 @@ export async function validateArtifact(files) {
250
362
  brand: contextContract.fixtures.brand,
251
363
  nav: contextContract.fixtures.layout.nav,
252
364
  socials: contextContract.fixtures.layout.socials ?? [],
253
- worship: contextContract.fixtures.layout.worship ?? null
365
+ worship: contextContract.fixtures.layout.worship ?? null,
366
+ locale: contextContract.fixtures.layout.locale
254
367
  });
255
368
  let contentSlots = 0;
256
369
  const layoutIslands = [];
@@ -284,7 +397,8 @@ export async function validateArtifact(files) {
284
397
  brand: contextContract.fixtures.brand,
285
398
  nav: contextContract.fixtures.layout.nav,
286
399
  socials: contextContract.fixtures.layout.socials ?? [],
287
- worship: null
400
+ worship: null,
401
+ locale: contextContract.fixtures.layout.locale
288
402
  });
289
403
  if (nullHtml.includes(worshipProbe)) {
290
404
  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)');
@@ -352,5 +466,34 @@ export async function validateArtifact(files) {
352
466
  errors.push('assets/theme.css missing or empty — a template must ship its look');
353
467
  }
354
468
 
469
+ // 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
470
+ // (.container / .full); a template STYLES those to place content, never a parallel content container.
471
+ // The rule is DATA — the seam token, the allowed selectors and the message all come from the contract
472
+ // file; this only implements the check KIND (a non-seam selector sizing its width off the token).
473
+ {
474
+ const css = has('assets/theme.css') ? read('assets/theme.css') : '';
475
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // drop comments so an example can't trip it
476
+ const RULE = /([^{}]+)\{([^{}]*)\}/g;
477
+ for (const rule of layoutContract.rules ?? []) {
478
+ if (rule.kind !== 'css-width-off-token') continue;
479
+ const token = rule.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
480
+ const sizesOffToken = new RegExp(`\\b(?:min-|max-)?(?:width|inline-size)\\s*:[^;]*var\\(\\s*${token}\\b`);
481
+ const isSeam = new RegExp(`\\.(?:${rule.allow.join('|')})(?![\\w-])`);
482
+ const offenders = new Set();
483
+ let m;
484
+ RULE.lastIndex = 0;
485
+ while ((m = RULE.exec(stripped)) !== null) {
486
+ if (!sizesOffToken.test(m[2])) continue; // only rules that size a box off the token
487
+ for (const sel of m[1].split(',')) {
488
+ const s = sel.trim();
489
+ if (s && !isSeam.test(s)) offenders.add(s);
490
+ }
491
+ }
492
+ for (const sel of [...offenders].sort()) {
493
+ errors.push(`theme: selector '${sel}' ${rule.message}`);
494
+ }
495
+ }
496
+ }
497
+
355
498
  return { errors, warnings, manifest };
356
499
  }
@@ -187,3 +187,49 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
187
187
  @media (max-width: 640px) {
188
188
  .lq-people { grid-template-columns: 1fr; }
189
189
  }
190
+
191
+ /* Campaigns — the same quiet rail as values: title, summary, one action. */
192
+ .lq-campaigns { padding: 3.5rem 0; }
193
+ .lq-campaign-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.6rem; max-width: 700px; }
194
+ .lq-campaign h3 { margin: 0 0 0.25rem; }
195
+ .lq-campaign p { margin: 0 0 0.6rem; }
196
+
197
+ /* Behaviours (engine-wired via data-p60-* — docs: Motion and behaviour guide). Everything that
198
+ hides pre-behaviour content is .p60-js-scoped: the no-JS render stays complete. */
199
+
200
+ /* Reveal: the values rail rises in with a stagger. */
201
+ .p60-js .lq-value-rail li {
202
+ opacity: 0;
203
+ transform: translateY(0.6rem);
204
+ transition: opacity 480ms ease, transform 480ms ease;
205
+ transition-delay: calc(var(--p60-reveal-index, 0) * 90ms);
206
+ }
207
+ .p60-js .lq-value-rail.is-revealed li { opacity: 1; transform: none; }
208
+ @media (prefers-reduced-motion: reduce) {
209
+ .p60-js .lq-value-rail li { opacity: 1; transform: none; transition: none; }
210
+ }
211
+
212
+ /* Behaviour carousel: the standard hero's photo stack (the vista framings use the island). */
213
+ .lq-hero-slides { position: absolute; inset: 0; overflow: hidden; }
214
+ .lq-hero-slide { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
215
+ .p60-js .lq-hero-slide { opacity: 0; transition: opacity 700ms ease; }
216
+ .p60-js .lq-hero-slide.is-active { opacity: 1; }
217
+ .lq-hero-slides .p60-carousel-dots {
218
+ position: absolute;
219
+ inset-inline: 0;
220
+ bottom: 0.9rem;
221
+ display: flex;
222
+ justify-content: center;
223
+ gap: 0.45rem;
224
+ z-index: 2;
225
+ }
226
+ .lq-hero-slides .p60-carousel-dot {
227
+ width: 0.55rem;
228
+ height: 0.55rem;
229
+ border-radius: 50%;
230
+ border: 1px solid rgba(255, 255, 255, 0.9);
231
+ background: transparent;
232
+ padding: 0;
233
+ cursor: pointer;
234
+ }
235
+ .lq-hero-slides .p60-carousel-dot--active { background: rgba(255, 255, 255, 0.95); }
@@ -33,14 +33,14 @@
33
33
  <a href="{{ item.href }}" class="nav-dropdown-toggle" aria-haspopup="true">{{ item.label }} ▾</a>
34
34
  <span class="nav-dropdown-menu">
35
35
  {% for child in item.children %}
36
- <a href="{{ child.href }}">{{ child.label }}</a>
36
+ <a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener"{% endif %}>{{ child.label }}</a>
37
37
  {% endfor %}
38
38
  </span>
39
39
  </span>
40
40
  {% elsif item.cta %}
41
- <a class="nav-cta" href="{{ item.href }}">{{ item.label }}</a>
41
+ <a class="nav-cta" href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener"{% endif %}>{{ item.label }}</a>
42
42
  {% else %}
43
- <a href="{{ item.href }}">{{ item.label }}</a>
43
+ <a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener"{% endif %}>{{ item.label }}</a>
44
44
  {% endif %}
45
45
  {% endfor %}
46
46
  {% island 'member_menu' %}
@@ -59,9 +59,19 @@
59
59
  {% if brand.tagline %}<h2>{{ brand.tagline }}</h2>{% endif %}
60
60
  </div>
61
61
  <div class="footer-links">
62
- {% for item in nav.items %}
63
- <a href="{{ item.href }}">{{ item.label }}</a>
64
- {% endfor %}
62
+ {% comment %} A tenant-curated footer menu when they've built one, else the main nav. {% endcomment %}
63
+ {% if nav.footer.size > 0 %}
64
+ {% for item in nav.footer %}
65
+ <a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener"{% endif %}>{{ item.label }}</a>
66
+ {% for child in item.children %}
67
+ <a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener"{% endif %}>{{ child.label }}</a>
68
+ {% endfor %}
69
+ {% endfor %}
70
+ {% else %}
71
+ {% for item in nav.items %}
72
+ <a href="{{ item.href }}">{{ item.label }}</a>
73
+ {% endfor %}
74
+ {% endif %}
65
75
  </div>
66
76
  </div>
67
77
  <div class="container">
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "starter",
3
- "version": "1.5.5",
3
+ "version": "1.8.0",
4
4
  "format": "port60-liquid@1",
5
5
  "label": "Starter",
6
- "description": "The reference template for the Port60 dialect — 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.",
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
7
  "supports": {
8
8
  "pages": [
9
9
  "home",
@@ -14,7 +14,8 @@
14
14
  "hero",
15
15
  "values",
16
16
  "cta",
17
- "people"
17
+ "people",
18
+ "campaigns"
18
19
  ],
19
20
  "islands": [
20
21
  "donation_widget",
@@ -22,7 +23,11 @@
22
23
  "hero_carousel"
23
24
  ],
24
25
  "layout": true,
25
- "heroImagery": true
26
+ "heroImagery": true,
27
+ "behaviors": [
28
+ "reveal",
29
+ "carousel"
30
+ ]
26
31
  },
27
32
  "settings": {
28
33
  "schema": [
@@ -41,7 +46,7 @@
41
46
  }
42
47
  ]
43
48
  },
44
- "changelog": "Versionless source workflow — no content changes.",
49
+ "changelog": "Demonstrates the behaviour catalogue: the values rail reveals with a stagger and a multi-photo standard hero becomes an engine-wired carousel.",
45
50
  "imagery": {
46
51
  "hero": {
47
52
  "idealAspect": "16:9",
@@ -0,0 +1,23 @@
1
+ {% comment %}
2
+ Live campaigns — the starter's plain rendering of the `campaigns` context, kept simple on
3
+ purpose: this template is the worked example template authors copy from. The context is the
4
+ platform's published campaigns; hrefs are platform-derived — always link via campaign.href.
5
+ Derive or omit: nothing renders when no campaign is live.
6
+ {% endcomment %}
7
+ {% if campaigns.size > 0 %}
8
+ <section class="lq-campaigns">
9
+ <div class="container">
10
+ <h2>{{ section.heading | default: campaignsLabel }}</h2>
11
+ {% if section.text %}<p>{{ section.text }}</p>{% endif %}
12
+ <ul class="lq-campaign-list">
13
+ {% for campaign in campaigns %}
14
+ <li class="lq-campaign">
15
+ <h3>{{ campaign.title }}</h3>
16
+ {% if campaign.summary %}<p>{{ campaign.summary }}</p>{% endif %}
17
+ <a class="button button-primary" href="{{ campaign.href }}">View campaign</a>
18
+ </li>
19
+ {% endfor %}
20
+ </ul>
21
+ </div>
22
+ </section>
23
+ {% endif %}
@@ -75,7 +75,22 @@
75
75
  {% endif %}
76
76
  {% else %}
77
77
  <section class="lq-homehero{% if heroPhoto %} lq-homehero--photo{% endif %}{% if heroCount > 1 %} lq-homehero--carousel{% endif %}"{% if heroPhoto %} style="background-image: url('{{ heroPhoto }}'); background-position: {{ heroFocus }}"{% endif %}>
78
- {% if heroCount > 1 %}{% island 'hero_carousel' %}{% endif %}
78
+ {% comment %}
79
+ Several photos → the BEHAVIOUR carousel (data-p60-carousel, engine-wired — the template ships
80
+ no JavaScript): complete static markup, one data-p60-slide per photo, auto-advance opt-in.
81
+ Hiding non-active slides is .p60-js-scoped in theme.css so a no-JS visitor sees the photos
82
+ stacked, never a blank hero. The vista framings above show the other route: the ready-made
83
+ hero_carousel island. A template chooses either.
84
+ {% endcomment %}
85
+ {% if heroCount > 1 %}
86
+ <div class="lq-hero-slides" data-p60-carousel="auto" aria-label="Hero photographs">
87
+ {% for image in section.images %}
88
+ <img class="lq-hero-slide{% if forloop.first %} is-active{% endif %}" data-p60-slide
89
+ src="{{ image.imageUrl }}" alt="{{ image.alt }}" style="object-position: {{ heroFocus }}"
90
+ {% if forloop.first %}fetchpriority="high"{% else %}loading="lazy"{% endif %} decoding="async">
91
+ {% endfor %}
92
+ </div>
93
+ {% endif %}
79
94
  <div class="container lq-homehero-inner">
80
95
  <div>
81
96
  <p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
@@ -1,7 +1,12 @@
1
1
  <section class="lq-values">
2
2
  <div class="container">
3
3
  <h2>{{ section.heading | default: "What guides us" }}</h2>
4
- <ol class="lq-value-rail">
4
+ {% comment %}
5
+ data-p60-reveal-group: the engine stamps --p60-reveal-index on each child and adds
6
+ is-revealed when the rail scrolls into view — the stagger and motion are OURS, in theme.css,
7
+ and .p60-js-scoped so a no-JS visitor sees the rail complete and still.
8
+ {% endcomment %}
9
+ <ol class="lq-value-rail" data-p60-reveal-group>
5
10
  {% for item in section.items %}
6
11
  <li>
7
12
  <span class="lq-value-dot"></span>