@port60/template-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/bin/cli.mjs +58 -0
- package/package.json +33 -0
- package/src/commands/create.mjs +73 -0
- package/src/commands/dev.mjs +56 -0
- package/src/commands/packageCmd.mjs +25 -0
- package/src/commands/validate.mjs +38 -0
- package/src/lib/agentsMd.mjs +81 -0
- package/src/lib/artifactFiles.mjs +28 -0
- package/src/lib/zip.mjs +77 -0
- package/src/vendor/contract/v1/content-bounds.json +117 -0
- package/src/vendor/contract/v1/context.json +534 -0
- package/src/vendor/contract/v1/dialect.json +39 -0
- package/src/vendor/contract/v1/fonts.json +67 -0
- package/src/vendor/contract/v1/islands.json +95 -0
- package/src/vendor/contract/v1/manifest.schema.json +144 -0
- package/src/vendor/contract/v1/sections.json +558 -0
- package/src/vendor/contract/v1/tokens.json +26 -0
- package/src/vendor/engine/budgets.mjs +14 -0
- package/src/vendor/engine/dialect.mjs +77 -0
- package/src/vendor/validator/preview.mjs +111 -0
- package/src/vendor/validator/validate.mjs +265 -0
- package/starter/assets/theme.css +43 -0
- package/starter/layout.liquid +75 -0
- package/starter/manifest.json +40 -0
- package/starter/sections/cta.liquid +12 -0
- package/starter/sections/hero.liquid +7 -0
- package/starter/sections/values.liquid +14 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "The content-section catalogue — the closed set of section types a page may hold and a template may render. Section type = content KIND (coarse); layout/variant is the template's job. Additive-only: types and fields are added, never removed or re-shaped, within a contract major. Every section is independently omittable — a template must degrade gracefully when any subset is absent.",
|
|
3
|
+
"sections": [
|
|
4
|
+
{
|
|
5
|
+
"type": "hero",
|
|
6
|
+
"label": "Hero banner",
|
|
7
|
+
"pages": [
|
|
8
|
+
"about"
|
|
9
|
+
],
|
|
10
|
+
"description": "The page opener: a headline with an optional kicker and sanitised rich-text intro.",
|
|
11
|
+
"fields": [
|
|
12
|
+
{
|
|
13
|
+
"name": "eyebrow",
|
|
14
|
+
"kind": "text",
|
|
15
|
+
"required": false,
|
|
16
|
+
"description": "Short kicker above the headline."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "title",
|
|
20
|
+
"kind": "text",
|
|
21
|
+
"required": true,
|
|
22
|
+
"description": "The headline."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "bodyHtml",
|
|
26
|
+
"kind": "richtext",
|
|
27
|
+
"required": false,
|
|
28
|
+
"description": "Sanitised HTML intro — render with the raw filter."
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"minimal": {
|
|
32
|
+
"title": "Our story"
|
|
33
|
+
},
|
|
34
|
+
"sample": {
|
|
35
|
+
"eyebrow": "About us",
|
|
36
|
+
"title": "Generosity, put to work.",
|
|
37
|
+
"bodyHtml": "<p>We began in 2019 around a simple belief: small, regular acts of kindness, pooled together, change lives.</p>"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "values",
|
|
42
|
+
"label": "Values / list",
|
|
43
|
+
"pages": [
|
|
44
|
+
"about"
|
|
45
|
+
],
|
|
46
|
+
"description": "A set of principles shown as cards or a numbered list, per the template.",
|
|
47
|
+
"fields": [
|
|
48
|
+
{
|
|
49
|
+
"name": "eyebrow",
|
|
50
|
+
"kind": "text",
|
|
51
|
+
"required": false,
|
|
52
|
+
"description": "Short kicker above the heading."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "heading",
|
|
56
|
+
"kind": "text",
|
|
57
|
+
"required": false,
|
|
58
|
+
"description": "The section heading."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "items",
|
|
62
|
+
"kind": "items",
|
|
63
|
+
"required": true,
|
|
64
|
+
"description": "The value entries.",
|
|
65
|
+
"itemFields": [
|
|
66
|
+
{
|
|
67
|
+
"name": "title",
|
|
68
|
+
"kind": "text",
|
|
69
|
+
"required": true,
|
|
70
|
+
"description": "The value's name."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "description",
|
|
74
|
+
"kind": "text",
|
|
75
|
+
"required": true,
|
|
76
|
+
"description": "One sentence describing it."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "icon",
|
|
80
|
+
"kind": "icon",
|
|
81
|
+
"required": false,
|
|
82
|
+
"description": "Optional emoji/icon."
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"minimal": {
|
|
88
|
+
"items": [
|
|
89
|
+
{
|
|
90
|
+
"title": "Transparency",
|
|
91
|
+
"description": "Every pound is tracked and reported."
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"sample": {
|
|
96
|
+
"eyebrow": "What we stand for",
|
|
97
|
+
"heading": "Our values",
|
|
98
|
+
"items": [
|
|
99
|
+
{
|
|
100
|
+
"title": "Transparency",
|
|
101
|
+
"description": "Every pound is tracked and reported."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"title": "Dignity",
|
|
105
|
+
"description": "We work alongside communities, not for them."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"title": "Impact",
|
|
109
|
+
"description": "We fund what works and measure the difference."
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"type": "cta",
|
|
116
|
+
"label": "Call to action",
|
|
117
|
+
"pages": [
|
|
118
|
+
"home",
|
|
119
|
+
"about"
|
|
120
|
+
],
|
|
121
|
+
"description": "A closing band: heading, optional line, one button.",
|
|
122
|
+
"fields": [
|
|
123
|
+
{
|
|
124
|
+
"name": "heading",
|
|
125
|
+
"kind": "text",
|
|
126
|
+
"required": true,
|
|
127
|
+
"description": "The band heading."
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "text",
|
|
131
|
+
"kind": "text",
|
|
132
|
+
"required": false,
|
|
133
|
+
"description": "A supporting line."
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "buttonLabel",
|
|
137
|
+
"kind": "text",
|
|
138
|
+
"required": true,
|
|
139
|
+
"description": "The button's label."
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "buttonHref",
|
|
143
|
+
"kind": "url",
|
|
144
|
+
"required": true,
|
|
145
|
+
"description": "The button's destination (scheme-guarded on save)."
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
"minimal": {
|
|
149
|
+
"heading": "Ready to help?",
|
|
150
|
+
"buttonLabel": "Donate",
|
|
151
|
+
"buttonHref": "/donate"
|
|
152
|
+
},
|
|
153
|
+
"sample": {
|
|
154
|
+
"heading": "Ready to help?",
|
|
155
|
+
"text": "It takes less than a minute.",
|
|
156
|
+
"buttonLabel": "Donate now",
|
|
157
|
+
"buttonHref": "/#donate"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"type": "homeHero",
|
|
162
|
+
"label": "Home hero",
|
|
163
|
+
"pages": [
|
|
164
|
+
"home"
|
|
165
|
+
],
|
|
166
|
+
"description": "The home opener. EVERY field optional: eyebrow/title derive from branding when absent, the lead OMITS when absent (never a fabricated placeholder), buttons fall back to platform defaults.",
|
|
167
|
+
"fields": [
|
|
168
|
+
{
|
|
169
|
+
"name": "eyebrow",
|
|
170
|
+
"kind": "text",
|
|
171
|
+
"required": false,
|
|
172
|
+
"description": "Defaults to the charity's name."
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "title",
|
|
176
|
+
"kind": "text",
|
|
177
|
+
"required": false,
|
|
178
|
+
"description": "Defaults to the charity's tagline."
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "lead",
|
|
182
|
+
"kind": "text",
|
|
183
|
+
"required": false,
|
|
184
|
+
"description": "A lead paragraph; omitted entirely when blank."
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"name": "primaryLabel",
|
|
188
|
+
"kind": "text",
|
|
189
|
+
"required": false,
|
|
190
|
+
"description": "Main button label (default: Donate now)."
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"name": "primaryHref",
|
|
194
|
+
"kind": "url",
|
|
195
|
+
"required": false,
|
|
196
|
+
"description": "Main button link (default: #donate)."
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "secondaryLabel",
|
|
200
|
+
"kind": "text",
|
|
201
|
+
"required": false,
|
|
202
|
+
"description": "Second button label (default: Our story)."
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"name": "secondaryHref",
|
|
206
|
+
"kind": "url",
|
|
207
|
+
"required": false,
|
|
208
|
+
"description": "Second button link (default: /about)."
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"name": "givingStyle",
|
|
212
|
+
"kind": "select",
|
|
213
|
+
"required": false,
|
|
214
|
+
"options": [
|
|
215
|
+
"form",
|
|
216
|
+
"button"
|
|
217
|
+
],
|
|
218
|
+
"description": "How this hero offers giving: 'form' (default) places the donation widget beside the copy; 'button' renders copy only — the primary button becomes the giving path (defaulting to /donate). Templates MUST render both."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "imageUrl",
|
|
222
|
+
"kind": "image",
|
|
223
|
+
"required": false,
|
|
224
|
+
"description": "Background photograph for the hero (uploaded through the platform pipeline). The template lays a dark overlay under the headline; omit for the flat colour hero."
|
|
225
|
+
}
|
|
226
|
+
],
|
|
227
|
+
"minimal": {},
|
|
228
|
+
"sample": {
|
|
229
|
+
"lead": "We turn everyday generosity into practical help for the families who need it most.",
|
|
230
|
+
"givingStyle": "button"
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"type": "impact",
|
|
235
|
+
"label": "Impact cards",
|
|
236
|
+
"pages": [
|
|
237
|
+
"home"
|
|
238
|
+
],
|
|
239
|
+
"description": "What gifts make possible — optional intro plus icon/title/description cards. Claims must be truthful for the tenant.",
|
|
240
|
+
"fields": [
|
|
241
|
+
{
|
|
242
|
+
"name": "eyebrow",
|
|
243
|
+
"kind": "text",
|
|
244
|
+
"required": false,
|
|
245
|
+
"description": "Short kicker."
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"name": "heading",
|
|
249
|
+
"kind": "text",
|
|
250
|
+
"required": false,
|
|
251
|
+
"description": "The section heading."
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"name": "text",
|
|
255
|
+
"kind": "text",
|
|
256
|
+
"required": false,
|
|
257
|
+
"description": "An intro line."
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"name": "items",
|
|
261
|
+
"kind": "items",
|
|
262
|
+
"required": true,
|
|
263
|
+
"description": "The impact cards.",
|
|
264
|
+
"itemFields": [
|
|
265
|
+
{
|
|
266
|
+
"name": "imageUrl",
|
|
267
|
+
"kind": "image",
|
|
268
|
+
"required": false,
|
|
269
|
+
"description": "Card image (uploaded through the platform pipeline). Preferred over icon."
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"name": "icon",
|
|
273
|
+
"kind": "icon",
|
|
274
|
+
"required": false,
|
|
275
|
+
"description": "Legacy emoji/icon — superseded by imageUrl; render it only when imageUrl is absent."
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"name": "title",
|
|
279
|
+
"kind": "text",
|
|
280
|
+
"required": true,
|
|
281
|
+
"description": "The card title."
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"name": "description",
|
|
285
|
+
"kind": "text",
|
|
286
|
+
"required": true,
|
|
287
|
+
"description": "One sentence describing it."
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
],
|
|
292
|
+
"minimal": {
|
|
293
|
+
"items": [
|
|
294
|
+
{
|
|
295
|
+
"title": "Warm meals",
|
|
296
|
+
"description": "Hot meals served at a community kitchen."
|
|
297
|
+
}
|
|
298
|
+
]
|
|
299
|
+
},
|
|
300
|
+
"sample": {
|
|
301
|
+
"eyebrow": "Where your gift goes",
|
|
302
|
+
"heading": "Real help, made of small acts",
|
|
303
|
+
"items": [
|
|
304
|
+
{
|
|
305
|
+
"imageUrl": "https://assets.example/impact-water.jpg",
|
|
306
|
+
"title": "£10 · Clean water",
|
|
307
|
+
"description": "Safe drinking water for a family for a whole month."
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"icon": "🍲",
|
|
311
|
+
"title": "£25 · Warm meals",
|
|
312
|
+
"description": "Fifteen hot meals served at a local community kitchen."
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"title": "£50 · School kit",
|
|
316
|
+
"description": "Books and supplies for two children for a full term."
|
|
317
|
+
}
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"type": "whatsOn",
|
|
323
|
+
"label": "What's-on strip",
|
|
324
|
+
"pages": [
|
|
325
|
+
"home"
|
|
326
|
+
],
|
|
327
|
+
"kind": "widget",
|
|
328
|
+
"island": "whats_on_strip",
|
|
329
|
+
"description": "Widget section — positions the what's-on strip of info events. No authorable content.",
|
|
330
|
+
"fields": [],
|
|
331
|
+
"minimal": {},
|
|
332
|
+
"sample": {}
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
"type": "events",
|
|
336
|
+
"label": "Events carousel",
|
|
337
|
+
"pages": [
|
|
338
|
+
"home"
|
|
339
|
+
],
|
|
340
|
+
"kind": "widget",
|
|
341
|
+
"island": "events_carousel",
|
|
342
|
+
"description": "Widget section — positions the ticketed-events carousel. No authorable content.",
|
|
343
|
+
"fields": [],
|
|
344
|
+
"minimal": {},
|
|
345
|
+
"sample": {}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"type": "articles",
|
|
349
|
+
"label": "Latest articles",
|
|
350
|
+
"pages": [
|
|
351
|
+
"home"
|
|
352
|
+
],
|
|
353
|
+
"kind": "widget",
|
|
354
|
+
"island": "latest_articles",
|
|
355
|
+
"description": "Widget section — positions the latest-articles strip. No authorable content.",
|
|
356
|
+
"fields": [],
|
|
357
|
+
"minimal": {},
|
|
358
|
+
"sample": {}
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"type": "emergency",
|
|
362
|
+
"label": "Emergency appeal strip",
|
|
363
|
+
"pages": [
|
|
364
|
+
"home"
|
|
365
|
+
],
|
|
366
|
+
"description": "A high-urgency full-width strip — one headline, an optional line, one action. For live emergencies and time-critical appeals.",
|
|
367
|
+
"fields": [
|
|
368
|
+
{
|
|
369
|
+
"name": "heading",
|
|
370
|
+
"kind": "text",
|
|
371
|
+
"required": true,
|
|
372
|
+
"description": "The urgent headline."
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
"name": "text",
|
|
376
|
+
"kind": "text",
|
|
377
|
+
"required": false,
|
|
378
|
+
"description": "One supporting line."
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"name": "buttonLabel",
|
|
382
|
+
"kind": "text",
|
|
383
|
+
"required": false,
|
|
384
|
+
"description": "Action label (default: Donate now)."
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"name": "buttonHref",
|
|
388
|
+
"kind": "url",
|
|
389
|
+
"required": false,
|
|
390
|
+
"description": "Action link (default: /donate). Scheme-guarded on save."
|
|
391
|
+
}
|
|
392
|
+
],
|
|
393
|
+
"minimal": {
|
|
394
|
+
"heading": "Emergency appeal"
|
|
395
|
+
},
|
|
396
|
+
"sample": {
|
|
397
|
+
"heading": "Gaza emergency appeal",
|
|
398
|
+
"text": "Families need food, water and shelter right now.",
|
|
399
|
+
"buttonLabel": "Give emergency aid",
|
|
400
|
+
"buttonHref": "/donate"
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"type": "stats",
|
|
405
|
+
"label": "Stats band",
|
|
406
|
+
"pages": [
|
|
407
|
+
"home"
|
|
408
|
+
],
|
|
409
|
+
"description": "Big headline numbers — the at-a-glance impact band. Values are author-entered text (write them exactly as they should read).",
|
|
410
|
+
"fields": [
|
|
411
|
+
{
|
|
412
|
+
"name": "eyebrow",
|
|
413
|
+
"kind": "text",
|
|
414
|
+
"required": false,
|
|
415
|
+
"description": "Short kicker."
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
"name": "heading",
|
|
419
|
+
"kind": "text",
|
|
420
|
+
"required": false,
|
|
421
|
+
"description": "The band heading."
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
"name": "items",
|
|
425
|
+
"kind": "items",
|
|
426
|
+
"required": true,
|
|
427
|
+
"description": "The stat entries.",
|
|
428
|
+
"itemFields": [
|
|
429
|
+
{
|
|
430
|
+
"name": "value",
|
|
431
|
+
"kind": "text",
|
|
432
|
+
"required": true,
|
|
433
|
+
"description": "The number, as it should read (e.g. 12,000 or 98%)."
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
"name": "label",
|
|
437
|
+
"kind": "text",
|
|
438
|
+
"required": true,
|
|
439
|
+
"description": "What it counts (e.g. meals served)."
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
}
|
|
443
|
+
],
|
|
444
|
+
"minimal": {
|
|
445
|
+
"items": [
|
|
446
|
+
{
|
|
447
|
+
"value": "1,200",
|
|
448
|
+
"label": "meals served"
|
|
449
|
+
}
|
|
450
|
+
]
|
|
451
|
+
},
|
|
452
|
+
"sample": {
|
|
453
|
+
"eyebrow": "Last year",
|
|
454
|
+
"heading": "What your giving did",
|
|
455
|
+
"items": [
|
|
456
|
+
{
|
|
457
|
+
"value": "12,400",
|
|
458
|
+
"label": "hot meals served"
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
"value": "3,100",
|
|
462
|
+
"label": "families supported"
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
"value": "98p",
|
|
466
|
+
"label": "of every pound to the cause"
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
"type": "appealGrid",
|
|
473
|
+
"label": "Appeal cards",
|
|
474
|
+
"pages": [
|
|
475
|
+
"home"
|
|
476
|
+
],
|
|
477
|
+
"description": "A grid of appeal/campaign cards — image, title, a line, and a per-card action.",
|
|
478
|
+
"fields": [
|
|
479
|
+
{
|
|
480
|
+
"name": "eyebrow",
|
|
481
|
+
"kind": "text",
|
|
482
|
+
"required": false,
|
|
483
|
+
"description": "Short kicker."
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
"name": "heading",
|
|
487
|
+
"kind": "text",
|
|
488
|
+
"required": false,
|
|
489
|
+
"description": "The section heading."
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"name": "items",
|
|
493
|
+
"kind": "items",
|
|
494
|
+
"required": true,
|
|
495
|
+
"description": "The appeal cards.",
|
|
496
|
+
"itemFields": [
|
|
497
|
+
{
|
|
498
|
+
"name": "imageUrl",
|
|
499
|
+
"kind": "image",
|
|
500
|
+
"required": false,
|
|
501
|
+
"description": "Card photograph (uploaded through the platform pipeline)."
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
"name": "title",
|
|
505
|
+
"kind": "text",
|
|
506
|
+
"required": true,
|
|
507
|
+
"description": "The appeal's name."
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
"name": "text",
|
|
511
|
+
"kind": "text",
|
|
512
|
+
"required": true,
|
|
513
|
+
"description": "One sentence on what it funds."
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
"name": "buttonLabel",
|
|
517
|
+
"kind": "text",
|
|
518
|
+
"required": false,
|
|
519
|
+
"description": "Card action label (default: Donate)."
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
"name": "buttonHref",
|
|
523
|
+
"kind": "url",
|
|
524
|
+
"required": false,
|
|
525
|
+
"description": "Card action link (default: /donate). Scheme-guarded on save."
|
|
526
|
+
}
|
|
527
|
+
]
|
|
528
|
+
}
|
|
529
|
+
],
|
|
530
|
+
"minimal": {
|
|
531
|
+
"items": [
|
|
532
|
+
{
|
|
533
|
+
"title": "Winter appeal",
|
|
534
|
+
"text": "Warm meals through the coldest months."
|
|
535
|
+
}
|
|
536
|
+
]
|
|
537
|
+
},
|
|
538
|
+
"sample": {
|
|
539
|
+
"eyebrow": "Our appeals",
|
|
540
|
+
"heading": "Where help is needed most",
|
|
541
|
+
"items": [
|
|
542
|
+
{
|
|
543
|
+
"title": "Winter appeal",
|
|
544
|
+
"text": "Warm meals and heating grants through the coldest months.",
|
|
545
|
+
"buttonLabel": "Support winter",
|
|
546
|
+
"buttonHref": "/donate"
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
"title": "Clean water",
|
|
550
|
+
"text": "Wells and filtration for three villages.",
|
|
551
|
+
"buttonLabel": "Give water",
|
|
552
|
+
"buttonHref": "/donate"
|
|
553
|
+
}
|
|
554
|
+
]
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
]
|
|
558
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "The CSS custom-property contract — the theming tokens the platform base declares and every island/page consumes. A template overrides them via :root:root in its theme.css. Additive-only: tokens are never removed or repurposed within a contract major.",
|
|
3
|
+
"tokens": [
|
|
4
|
+
{ "name": "--bg", "description": "Page background." },
|
|
5
|
+
{ "name": "--bg-tint", "description": "Subtle tinted background (footer, wells)." },
|
|
6
|
+
{ "name": "--surface", "description": "Card/panel surface." },
|
|
7
|
+
{ "name": "--ink", "description": "Primary text colour." },
|
|
8
|
+
{ "name": "--muted", "description": "Secondary text colour." },
|
|
9
|
+
{ "name": "--primary", "description": "Brand primary (progress, links, selected states)." },
|
|
10
|
+
{ "name": "--primary-strong", "description": "Darker primary (hover/emphasis)." },
|
|
11
|
+
{ "name": "--primary-soft", "description": "Translucent primary wash (selected fills, chips)." },
|
|
12
|
+
{ "name": "--accent", "description": "Action accent (the donate CTA family)." },
|
|
13
|
+
{ "name": "--accent-strong", "description": "Darker accent." },
|
|
14
|
+
{ "name": "--gold", "description": "Highlight tint (marks, ratings)." },
|
|
15
|
+
{ "name": "--line", "description": "Hairline borders." },
|
|
16
|
+
{ "name": "--line-strong", "description": "Emphasised borders (selected, focus)." },
|
|
17
|
+
{ "name": "--danger", "description": "Errors and destructive affordances." },
|
|
18
|
+
{ "name": "--cta-hover", "description": "The donate-button hover 'go' colour." },
|
|
19
|
+
{ "name": "--cta-hover-strong", "description": "Darker CTA hover." },
|
|
20
|
+
{ "name": "--shadow-sm", "description": "Small elevation shadow." },
|
|
21
|
+
{ "name": "--shadow", "description": "Large elevation shadow." },
|
|
22
|
+
{ "name": "--radius", "description": "Large corner radius (cards, bands)." },
|
|
23
|
+
{ "name": "--radius-sm", "description": "Small corner radius (inputs, chips)." },
|
|
24
|
+
{ "name": "--container", "description": "Max content width." }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// LiquidJS DoS budgets — ONE definition for the production engine, the validator and the studio
|
|
2
|
+
// preview, so "validated ⇒ renders in production" includes the resource ceiling. Third-party
|
|
3
|
+
// templates run in the shared SSR process (developer program T1.3): without these, an untrusted
|
|
4
|
+
// `{% for %}`/`{% capture %}` burns CPU/memory for every tenant on the box. The numbers are far
|
|
5
|
+
// above anything a legitimate template needs (the largest internal artifact parses ~80KB and
|
|
6
|
+
// renders in single-digit ms) and far below what hurts the process.
|
|
7
|
+
export const LIQUID_BUDGETS = {
|
|
8
|
+
/** Max characters parsed per parse() call (dialect docs: a typical PC handles 1e8). */
|
|
9
|
+
parseLimit: 1e6,
|
|
10
|
+
/** Max wall-clock ms per render() call. */
|
|
11
|
+
renderLimit: 1000,
|
|
12
|
+
/** Max object creations per render (arrays, concats, strftime …). */
|
|
13
|
+
memoryLimit: 5e7
|
|
14
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Applies the Port60 dialect (contract/v1/dialect.json) to a LiquidJS instance. Plain JS on purpose:
|
|
2
|
+
// this exact module is imported by BOTH the rendering engine (Vite-bundled into charity-site) and the
|
|
3
|
+
// Node conformance validator (scripts/validate-template.mjs) — one enforcement implementation, so the
|
|
4
|
+
// validator can never disagree with production rendering. The dialect object is passed IN (parsed
|
|
5
|
+
// JSON); this module does no file loading.
|
|
6
|
+
|
|
7
|
+
/** Marker protocol for island placement: the island tag emits these; the renderer splits on them. */
|
|
8
|
+
export const ISLAND_MARK_START = '\u0000P60_ISLAND:';
|
|
9
|
+
export const ISLAND_MARK_END = '\u0000';
|
|
10
|
+
|
|
11
|
+
/** The layout's {% content %} slot rides the island marker protocol under a reserved name — the
|
|
12
|
+
* slash makes collision with registry island names (snake_case words) impossible. */
|
|
13
|
+
export const CONTENT_SLOT = 'P60/content';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Restrict a Liquid instance to the dialect: excluded tags become parse-time errors, excluded
|
|
17
|
+
* filters become render-time errors (unknown filters already throw via strictFilters), and the
|
|
18
|
+
* {% island %} tag is registered. `islandNames` is the platform island registry (a Set of names);
|
|
19
|
+
* an unknown island renders to nothing (the validator rejects it at publish time).
|
|
20
|
+
*/
|
|
21
|
+
export function configureDialect(liquid, dialect, islandNames) {
|
|
22
|
+
for (const tag of dialect.excluded?.tags ?? []) {
|
|
23
|
+
liquid.registerTag(tag, {
|
|
24
|
+
parse() {
|
|
25
|
+
throw new Error(`The '${tag}' tag is not part of the Port60 dialect (${dialect.format}).`);
|
|
26
|
+
},
|
|
27
|
+
render() {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
// Filters LiquidJS ships beyond our whitelist (e.g. its non-standard json/inspect debug filters).
|
|
33
|
+
const allowed = new Set(dialect.filters);
|
|
34
|
+
for (const name of ['json', 'inspect', 'to_integer', 'normalize_whitespace', 'find', 'find_exp', 'group_by', 'group_by_exp', 'where_exp', 'sum']) {
|
|
35
|
+
if (!allowed.has(name)) {
|
|
36
|
+
liquid.registerFilter(name, () => {
|
|
37
|
+
throw new Error(`The '${name}' filter is not part of the Port60 dialect (${dialect.format}).`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
liquid.registerTag('island', {
|
|
42
|
+
parse(token) {
|
|
43
|
+
this.args = token.args;
|
|
44
|
+
},
|
|
45
|
+
async render(ctx) {
|
|
46
|
+
const name = String(await this.liquid.evalValue(this.args, ctx));
|
|
47
|
+
if (!islandNames.has(name)) {
|
|
48
|
+
return ''; // unknown island — omitted at render; rejected at publish by the validator
|
|
49
|
+
}
|
|
50
|
+
return `${ISLAND_MARK_START}${name}${ISLAND_MARK_END}`;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
// {% content %} — the layout's page slot (contract v1, roadmap 8.2). Registered everywhere; a
|
|
54
|
+
// section that emits it is rejected by the validator and stripped by the section renderer.
|
|
55
|
+
liquid.registerTag('content', {
|
|
56
|
+
parse() {},
|
|
57
|
+
render() {
|
|
58
|
+
return `${ISLAND_MARK_START}${CONTENT_SLOT}${ISLAND_MARK_END}`;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Split rendered output into HTML fragments and island mount points. */
|
|
64
|
+
export function splitIslandParts(html) {
|
|
65
|
+
const parts = [];
|
|
66
|
+
const pieces = html.split(ISLAND_MARK_START);
|
|
67
|
+
parts.push({ html: pieces[0] ?? '' });
|
|
68
|
+
for (const piece of pieces.slice(1)) {
|
|
69
|
+
const end = piece.indexOf(ISLAND_MARK_END);
|
|
70
|
+
if (end === -1) {
|
|
71
|
+
parts.push({ html: piece });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
parts.push({ island: piece.slice(0, end) }, { html: piece.slice(end + ISLAND_MARK_END.length) });
|
|
75
|
+
}
|
|
76
|
+
return parts;
|
|
77
|
+
}
|