@port60/template-kit 0.6.0 → 0.8.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,446 @@
1
+ {
2
+ "_comment": "FROZEN compatibility baseline for port60-liquid@1 (8.6). tests/contract-freeze.test.mjs enforces that everything here still exists — removals/narrowings fail. Regenerate ONLY deliberately via scripts/contract-lock.mjs; the diff is the review.",
3
+ "format": "port60-liquid@1",
4
+ "dialect": {
5
+ "tags": [
6
+ "assign",
7
+ "break",
8
+ "capture",
9
+ "case",
10
+ "comment",
11
+ "continue",
12
+ "echo",
13
+ "else",
14
+ "elsif",
15
+ "endcapture",
16
+ "endcase",
17
+ "endcomment",
18
+ "endfor",
19
+ "endif",
20
+ "endraw",
21
+ "endunless",
22
+ "for",
23
+ "if",
24
+ "liquid",
25
+ "raw",
26
+ "unless",
27
+ "when"
28
+ ],
29
+ "filters": [
30
+ "abs",
31
+ "append",
32
+ "at_least",
33
+ "at_most",
34
+ "capitalize",
35
+ "ceil",
36
+ "compact",
37
+ "concat",
38
+ "date",
39
+ "default",
40
+ "divided_by",
41
+ "downcase",
42
+ "escape",
43
+ "escape_once",
44
+ "first",
45
+ "floor",
46
+ "join",
47
+ "last",
48
+ "lstrip",
49
+ "map",
50
+ "minus",
51
+ "modulo",
52
+ "newline_to_br",
53
+ "plus",
54
+ "prepend",
55
+ "raw",
56
+ "remove",
57
+ "remove_first",
58
+ "replace",
59
+ "replace_first",
60
+ "reverse",
61
+ "round",
62
+ "rstrip",
63
+ "size",
64
+ "slice",
65
+ "sort",
66
+ "sort_natural",
67
+ "split",
68
+ "strip",
69
+ "strip_html",
70
+ "strip_newlines",
71
+ "times",
72
+ "truncate",
73
+ "truncatewords",
74
+ "uniq",
75
+ "upcase",
76
+ "url_decode",
77
+ "url_encode",
78
+ "where"
79
+ ]
80
+ },
81
+ "sectionTypes": [
82
+ "appealGrid",
83
+ "articles",
84
+ "cta",
85
+ "emergency",
86
+ "events",
87
+ "hero",
88
+ "homeHero",
89
+ "impact",
90
+ "people",
91
+ "stats",
92
+ "values",
93
+ "whatsOn"
94
+ ],
95
+ "islands": {
96
+ "course_enrol": [
97
+ ".course-enrol",
98
+ ".course-pay-panel",
99
+ ".course-soldout-note",
100
+ ".course-subject-list",
101
+ ".course-summary",
102
+ ".course-variant-list"
103
+ ],
104
+ "donation_widget": [
105
+ ".amount-grid",
106
+ ".amount-list",
107
+ ".amount-option",
108
+ ".amount-row",
109
+ ".donate-card",
110
+ ".donate-head",
111
+ ".donate-note",
112
+ ".donate-sub",
113
+ ".donate-submit",
114
+ ".donate-success",
115
+ ".freq-tab",
116
+ ".freq-tabs",
117
+ ".giftaid-box"
118
+ ],
119
+ "events_carousel": [
120
+ ".carousel-caption",
121
+ ".carousel-dot",
122
+ ".carousel-dots",
123
+ ".carousel-slide",
124
+ ".events-carousel"
125
+ ],
126
+ "hero_carousel": [
127
+ ".hero-carousel",
128
+ ".hero-dot",
129
+ ".hero-dot--active",
130
+ ".hero-dots",
131
+ ".hero-slide",
132
+ ".hero-slide-img",
133
+ ".hero-slide-scrim",
134
+ ".hero-slides"
135
+ ],
136
+ "latest_articles": [
137
+ ".article-card",
138
+ ".article-card-body",
139
+ ".article-grid",
140
+ ".article-strip-cta"
141
+ ],
142
+ "member_menu": [
143
+ ".nav-account",
144
+ ".nav-account-menu",
145
+ ".nav-account-toggle",
146
+ ".nav-p60-signin",
147
+ ".p60-mark"
148
+ ],
149
+ "whats_on_strip": [
150
+ ".info-event-chip",
151
+ ".info-events-bar",
152
+ ".info-events-inner"
153
+ ]
154
+ },
155
+ "contextVariables": {
156
+ "brand.footerLogoUrl": [
157
+ "layout"
158
+ ],
159
+ "brand.logoType": [
160
+ "layout"
161
+ ],
162
+ "brand.logoUrl": [
163
+ "layout"
164
+ ],
165
+ "brand.name": [
166
+ "layout",
167
+ "section"
168
+ ],
169
+ "brand.tagline": [
170
+ "layout",
171
+ "section"
172
+ ],
173
+ "course": [
174
+ "page:course"
175
+ ],
176
+ "course.aboutHtml": [
177
+ "page:course"
178
+ ],
179
+ "course.audience": [
180
+ "page:course"
181
+ ],
182
+ "course.backHref": [
183
+ "page:course"
184
+ ],
185
+ "course.billingMode": [
186
+ "page:course"
187
+ ],
188
+ "course.currency": [
189
+ "page:course"
190
+ ],
191
+ "course.description": [
192
+ "page:course"
193
+ ],
194
+ "course.id": [
195
+ "page:course"
196
+ ],
197
+ "course.imageUrl": [
198
+ "page:course"
199
+ ],
200
+ "course.priceFrom": [
201
+ "page:course"
202
+ ],
203
+ "course.soldOut": [
204
+ "page:course"
205
+ ],
206
+ "course.termEnd": [
207
+ "page:course"
208
+ ],
209
+ "course.termStart": [
210
+ "page:course"
211
+ ],
212
+ "course.title": [
213
+ "page:course"
214
+ ],
215
+ "course.venue": [
216
+ "page:course"
217
+ ],
218
+ "events": [
219
+ "page:events",
220
+ "section:events"
221
+ ],
222
+ "events[].currency": [
223
+ "page:events"
224
+ ],
225
+ "events[].description": [
226
+ "page:events"
227
+ ],
228
+ "events[].detailHref": [
229
+ "page:events"
230
+ ],
231
+ "events[].endsAt": [
232
+ "page:events"
233
+ ],
234
+ "events[].id": [
235
+ "page:events"
236
+ ],
237
+ "events[].imageUrl": [
238
+ "page:events"
239
+ ],
240
+ "events[].name": [
241
+ "page:events"
242
+ ],
243
+ "events[].online": [
244
+ "page:events"
245
+ ],
246
+ "events[].onSale": [
247
+ "page:events"
248
+ ],
249
+ "events[].priceFrom": [
250
+ "page:events"
251
+ ],
252
+ "events[].registrationMode": [
253
+ "page:events"
254
+ ],
255
+ "events[].soldOut": [
256
+ "page:events"
257
+ ],
258
+ "events[].startsAt": [
259
+ "page:events"
260
+ ],
261
+ "events[].venueName": [
262
+ "page:events"
263
+ ],
264
+ "infoEvents": [
265
+ "section:whatsOn"
266
+ ],
267
+ "latestArticles": [
268
+ "section:articles"
269
+ ],
270
+ "latestArticles[].categories": [
271
+ "section:articles"
272
+ ],
273
+ "latestArticles[].excerpt": [
274
+ "section:articles"
275
+ ],
276
+ "latestArticles[].href": [
277
+ "section:articles"
278
+ ],
279
+ "latestArticles[].imageUrl": [
280
+ "section:articles"
281
+ ],
282
+ "latestArticles[].publishedAt": [
283
+ "section:articles"
284
+ ],
285
+ "latestArticles[].readingMinutes": [
286
+ "section:articles"
287
+ ],
288
+ "latestArticles[].title": [
289
+ "section:articles"
290
+ ],
291
+ "nav.items": [
292
+ "layout"
293
+ ],
294
+ "nav.items[].children": [
295
+ "layout"
296
+ ],
297
+ "nav.items[].cta": [
298
+ "layout"
299
+ ],
300
+ "nav.items[].href": [
301
+ "layout"
302
+ ],
303
+ "nav.items[].label": [
304
+ "layout"
305
+ ],
306
+ "section": [
307
+ "section"
308
+ ],
309
+ "socials": [
310
+ "layout"
311
+ ],
312
+ "socials[].iconSvg": [
313
+ "layout"
314
+ ],
315
+ "socials[].platform": [
316
+ "layout"
317
+ ],
318
+ "socials[].url": [
319
+ "layout"
320
+ ],
321
+ "worship": [
322
+ "layout"
323
+ ],
324
+ "worship.beginsLabel": [
325
+ "layout"
326
+ ],
327
+ "worship.congregationLabel": [
328
+ "layout"
329
+ ],
330
+ "worship.href": [
331
+ "layout"
332
+ ],
333
+ "worship.note": [
334
+ "layout"
335
+ ],
336
+ "worship.times": [
337
+ "layout"
338
+ ],
339
+ "worship.today": [
340
+ "layout"
341
+ ],
342
+ "worship.todayAlt": [
343
+ "layout"
344
+ ]
345
+ },
346
+ "tokens": [
347
+ "--accent",
348
+ "--accent-strong",
349
+ "--bg",
350
+ "--bg-tint",
351
+ "--container",
352
+ "--cta-hover",
353
+ "--cta-hover-strong",
354
+ "--danger",
355
+ "--gold",
356
+ "--ink",
357
+ "--line",
358
+ "--line-strong",
359
+ "--muted",
360
+ "--primary",
361
+ "--primary-soft",
362
+ "--primary-strong",
363
+ "--radius",
364
+ "--radius-sm",
365
+ "--shadow",
366
+ "--shadow-sm",
367
+ "--surface"
368
+ ],
369
+ "fonts": [
370
+ "Abril Fatface",
371
+ "Albert Sans",
372
+ "Alfa Slab One",
373
+ "Almarai",
374
+ "Amiri",
375
+ "Archivo",
376
+ "Arvo",
377
+ "Baloo 2",
378
+ "Barlow",
379
+ "Bebas Neue",
380
+ "Bitter",
381
+ "Cairo",
382
+ "Comfortaa",
383
+ "Cormorant Garamond",
384
+ "Crimson Pro",
385
+ "DM Sans",
386
+ "EB Garamond",
387
+ "Figtree",
388
+ "Fraunces",
389
+ "Fredoka",
390
+ "IBM Plex Sans",
391
+ "IBM Plex Sans Arabic",
392
+ "IBM Plex Serif",
393
+ "Inter",
394
+ "Josefin Sans",
395
+ "Jost",
396
+ "Karla",
397
+ "Lato",
398
+ "Libre Baskerville",
399
+ "Lora",
400
+ "Manrope",
401
+ "Merriweather",
402
+ "Montserrat",
403
+ "Newsreader",
404
+ "Noto Kufi Arabic",
405
+ "Noto Naskh Arabic",
406
+ "Nunito",
407
+ "Open Sans",
408
+ "Oswald",
409
+ "Outfit",
410
+ "Playfair Display",
411
+ "Plus Jakarta Sans",
412
+ "Poppins",
413
+ "Quicksand",
414
+ "Raleway",
415
+ "Roboto Slab",
416
+ "Rubik",
417
+ "Scheherazade New",
418
+ "Sora",
419
+ "Source Serif 4",
420
+ "Space Grotesk",
421
+ "Tajawal",
422
+ "Work Sans",
423
+ "Zilla Slab"
424
+ ],
425
+ "manifest": {
426
+ "required": [
427
+ "format",
428
+ "name",
429
+ "supports",
430
+ "version"
431
+ ],
432
+ "properties": [
433
+ "changelog",
434
+ "description",
435
+ "fonts",
436
+ "format",
437
+ "imagery",
438
+ "label",
439
+ "looks",
440
+ "name",
441
+ "settings",
442
+ "supports",
443
+ "version"
444
+ ]
445
+ }
446
+ }
@@ -103,6 +103,17 @@ export async function validateArtifact(files) {
103
103
  let homeHeroMultiShows = null; // sample (several photos): probe in html OR hero_carousel placed
104
104
  let homeHeroSingleShows = null; // single-photo variant: probe rendered directly
105
105
 
106
+ // Author-renderable widget sections (the flip): each carries a curated data context; a template
107
+ // either places the section's DEFAULT ISLAND (which owns rendering + empty states) or renders
108
+ // the data itself — in which case both directions are proven behaviourally (the worship
109
+ // pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
110
+ // away (derive or omit — nothing invented, nothing dangling).
111
+ const WIDGET_SECTIONS = {
112
+ events: { island: 'events_carousel', dataKey: 'events', sentinel: 'Fixture Lantern Gala' },
113
+ whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: 'Fixture Open Morning' },
114
+ articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: 'Fixture Winter Diary' }
115
+ };
116
+
106
117
  // 2–4. Sections: catalogue membership, parse, fixture renders.
107
118
  for (const type of manifest?.supports?.sections ?? []) {
108
119
  const entry = catalogueByType.get(type);
@@ -122,12 +133,49 @@ export async function validateArtifact(files) {
122
133
  errors.push(`section '${type}': does not parse under the dialect — ${e.message}`);
123
134
  continue;
124
135
  }
136
+ // Widget-section proof (independent of the minimal/sample loop): island placed → the island
137
+ // owns everything; hand-rendered → sentinel appears with data, vanishes without.
138
+ const widget = WIDGET_SECTIONS[type];
139
+ if (widget) {
140
+ const dataFixture = contextContract.fixtures.sections?.[type] ?? {};
141
+ try {
142
+ const populated = await liquid.render(parsed, {
143
+ section: {},
144
+ brand: contextContract.fixtures.brand,
145
+ ...dataFixture
146
+ });
147
+ const placesIsland = splitIslandParts(populated).some((p) => p.island === widget.island);
148
+ if (!placesIsland) {
149
+ if (!populated.includes(widget.sentinel)) {
150
+ 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`
152
+ );
153
+ }
154
+ const empty = await liquid.render(parsed, {
155
+ section: {},
156
+ brand: contextContract.fixtures.brand,
157
+ [widget.dataKey]: []
158
+ });
159
+ if (empty.includes(widget.sentinel)) {
160
+ errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey} — content must come from the context`);
161
+ }
162
+ if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
163
+ errors.push(`section '${type}': renders 'undefined'/'null' literals when ${widget.dataKey} is empty — guard the empty case (derive or omit)`);
164
+ }
165
+ }
166
+ } catch (e) {
167
+ errors.push(`section '${type}': failed rendering the ${widget.dataKey} context fixtures — ${e.message}`);
168
+ }
169
+ }
125
170
  for (const fixtureName of ['minimal', 'sample']) {
126
171
  const fixture = entry[fixtureName] ?? {};
127
172
  try {
128
173
  const html = await liquid.render(parsed, {
129
174
  section: fixture,
130
- brand: contextContract.fixtures.brand
175
+ brand: contextContract.fixtures.brand,
176
+ // Widget data rides the ordinary fixture renders too, so a hand-rendering section
177
+ // doesn't fail the generic pass for want of its context.
178
+ ...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
131
179
  });
132
180
  if (type === 'homeHero' && fixtureName === 'sample') {
133
181
  const probe = 'Hero photo one';
@@ -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,52 +1,52 @@
1
1
  {
2
- "name": "starter",
3
- "version": "1.5.4",
4
- "format": "port60-liquid@1",
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.",
7
- "supports": {
8
- "pages": [
9
- "home",
10
- "about"
11
- ],
12
- "sections": [
13
- "homeHero",
14
- "hero",
15
- "values",
16
- "cta",
17
- "people"
18
- ],
19
- "islands": [
20
- "donation_widget",
21
- "member_menu",
22
- "hero_carousel"
23
- ],
24
- "layout": true,
25
- "heroImagery": true
26
- },
27
- "settings": {
28
- "schema": [
29
- {
30
- "key": "siteFont",
31
- "kind": "font",
32
- "label": "Site font",
33
- "default": "Inter",
34
- "weights": [
35
- 400,
36
- 500,
37
- 600,
38
- 700,
39
- 800
2
+ "name": "starter",
3
+ "version": "1.6.0",
4
+ "format": "port60-liquid@1",
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.",
7
+ "supports": {
8
+ "pages": [
9
+ "home",
10
+ "about"
11
+ ],
12
+ "sections": [
13
+ "homeHero",
14
+ "hero",
15
+ "values",
16
+ "cta",
17
+ "people"
18
+ ],
19
+ "islands": [
20
+ "donation_widget",
21
+ "member_menu",
22
+ "hero_carousel"
23
+ ],
24
+ "layout": true,
25
+ "heroImagery": true
26
+ },
27
+ "settings": {
28
+ "schema": [
29
+ {
30
+ "key": "siteFont",
31
+ "kind": "font",
32
+ "label": "Site font",
33
+ "default": "Inter",
34
+ "weights": [
35
+ 400,
36
+ 500,
37
+ 600,
38
+ 700,
39
+ 800
40
+ ]
41
+ }
40
42
  ]
41
- }
42
- ]
43
- },
44
- "changelog": "People / team: the new About-page section — the key people behind the charity as cards, stacking on phones.",
45
- "imagery": {
46
- "hero": {
47
- "idealAspect": "16:9",
48
- "minWidth": 1600,
49
- "note": "Landscape around 16:9 fills the hero cleanly; panoramas suit the whole-photo framing."
43
+ },
44
+ "changelog": "Versionless source workflow — no content changes.",
45
+ "imagery": {
46
+ "hero": {
47
+ "idealAspect": "16:9",
48
+ "minWidth": 1600,
49
+ "note": "Landscape around 16:9 fills the hero cleanly; panoramas suit the whole-photo framing."
50
+ }
50
51
  }
51
- }
52
52
  }