@port60/template-kit 0.20.4 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@port60/template-kit",
3
- "version": "0.20.4",
3
+ "version": "0.21.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": {
@@ -134,6 +134,13 @@ excludes it, and conformance proofs always run on the canonical fixtures.
134
134
  \`imageUrl\`; no entity lookup belongs in a template. Preserve a text-only card when its image
135
135
  is absent, omit an absent card and keep normal navigation links. Validation proves the explicit
136
136
  declaration; missing declarations never enable the editor feature automatically.
137
+ - Field markers are optional and additive: mark the node that shows a field with
138
+ \`data-p60-field="title"\`, or \`data-p60-field="items.{{ forloop.index0 }}.label"\` for an
139
+ entry in a list, and declare \`supports.fieldMarkers: true\`. The charity then types into the
140
+ real heading on the page instead of into a side panel. The address is the field's name inside
141
+ that section's own content, and the marked node holds that field and nothing else, so wrap the
142
+ value in a span when punctuation or other copy sits beside it. Mark what you like; the editor
143
+ puts a caret only in plain text, and a marker naming nothing is a warning, never a failure.
137
144
  - Dynamic sections include appeals (\`causes\`), programmes (\`services\`), resources and
138
145
  locations. Derive or omit when a collection is empty and use the supplied URLs rather than
139
146
  constructing routes.
@@ -591,7 +591,7 @@
591
591
  "availableIn": [
592
592
  "section:campaigns"
593
593
  ],
594
- "description": "What the tenant calls their campaigns section (\"Campaigns\" by default), the heading fallback.",
594
+ "description": "The platform word for the campaigns collection (\"Campaigns\"), the section's heading fallback. It does not vary: a tenant's own word for a link lives in their menu.",
595
595
  "deprecated": true
596
596
  },
597
597
  {
@@ -154,6 +154,12 @@
154
154
  "default": false,
155
155
  "description": "True when the layout displays the optional single site.nav.items[].megaMenu.promo card inside each expanded top-level menu. Requires supports.layout. The validator proves title, text, image and linked label render together; image-free and absent highlights must remain safe. The platform resolves linked courses, events and campaigns into the same promo fields as custom highlights. This flag does not enable a general mega menu or control column counts. Missing or false disables highlight editing for this exact template version."
156
156
  },
157
+ "fieldMarkers": {
158
+ "type": "boolean",
159
+ "default": false,
160
+ "description": "True when the sections mark which node shows which field with data-p60-field, so the editor can put the caret on the page instead of in a side panel. The address is inside that section's own content: data-p60-field=\"title\", or data-p60-field=\"items.{{ forloop.index0 }}.label\" for an entry in a list. Purely additive: the attribute is markup, the contract is untouched, and a template that marks nothing keeps the section-level editor. The validator reads the marks from the source and warns on any that does not name a field of that section. Missing or false leaves this exact template version editing in the panel.",
161
+ "$comment": "Follows the navigationHighlights pattern: declared honesty, proven by the validator."
162
+ },
157
163
  "heroImagery": {
158
164
  "type": "boolean",
159
165
  "default": false,
@@ -623,6 +623,32 @@ a.eyebrow:hover {
623
623
  max-width: 640px;
624
624
  }
625
625
 
626
+ .campaign-form {
627
+ margin-block: 2rem;
628
+ }
629
+
630
+ /* An embedded form belongs to the campaign, not a second page inside it. */
631
+ .p60-form--embedded {
632
+ padding: clamp(1rem, 3vw, 2rem);
633
+ border: 1px solid var(--line);
634
+ border-radius: var(--radius-sm, 12px);
635
+ background: var(--surface);
636
+ color: var(--ink);
637
+ }
638
+
639
+ .p60-form--embedded .p60-form-control {
640
+ background: var(--bg);
641
+ border-color: color-mix(in srgb, var(--ink) 35%, var(--surface));
642
+ }
643
+
644
+ .p60-form--embedded .p60-form-control:focus {
645
+ border-color: var(--primary);
646
+ }
647
+
648
+ .p60-form--embedded > :last-child {
649
+ margin-block-end: 0;
650
+ }
651
+
626
652
  .p60-form-intro {
627
653
  color: var(--muted);
628
654
  margin: 0 0 1.5rem;
@@ -636,6 +662,18 @@ a.eyebrow:hover {
636
662
  margin-bottom: 1.15rem;
637
663
  }
638
664
 
665
+ .p60-form-respondent {
666
+ min-inline-size: 0;
667
+ border: 0;
668
+ padding: 0 0 0.5rem;
669
+ margin: 0 0 1.5rem;
670
+ border-block-end: 1px solid var(--line);
671
+ }
672
+
673
+ .p60-form-respondent > legend {
674
+ margin-block-end: 1rem;
675
+ }
676
+
639
677
  .p60-form-label {
640
678
  display: block;
641
679
  font-weight: 600;
@@ -717,11 +755,18 @@ textarea.p60-form-control {
717
755
  width: 1.05rem;
718
756
  height: 1.05rem;
719
757
  flex: 0 0 auto;
720
- margin: 0.15rem 0 0;
721
- accent-color: var(--primary);
758
+ margin: 0.25rem 0 0;
759
+ padding: 0;
760
+ accent-color: var(--accent, var(--primary));
722
761
  cursor: pointer;
723
762
  }
724
763
 
764
+ .p60-form-check input:is([type='checkbox'], [type='radio']):focus-visible {
765
+ outline: 2px solid var(--ink);
766
+ outline-offset: 3px;
767
+ box-shadow: none;
768
+ }
769
+
725
770
  .p60-form-check.has-error span {
726
771
  color: var(--danger);
727
772
  }
@@ -4162,6 +4207,8 @@ textarea.p60-form-control {
4162
4207
  }
4163
4208
  .member-course-attendance li:last-child { border-bottom: none; }
4164
4209
  .member-course-attendance li.is-absent { opacity: 0.7; }
4210
+ /* Which class this mark was, for a learner who attends more than one. */
4211
+ .member-course-attendance-class { display: block; font-size: 0.8em; font-style: normal; opacity: 0.75; }
4165
4212
 
4166
4213
  /* C1.9 breaks (informational) + C1.8 course materials (presigned downloads). */
4167
4214
  .member-course-breaks {
@@ -395,6 +395,48 @@ export async function validateArtifact(files) {
395
395
  }
396
396
  }
397
397
 
398
+ // 5. FIELD MARKERS: which node shows which field, so the editor can put the caret on the page
399
+ // instead of in a side panel. Source-level, like behaviours: a marker often sits inside a
400
+ // content-dependent branch the fixtures never take, and its presence in the source is the honest
401
+ // proof. A marker is an address inside that section's own content: `title`, or `items.0.label`
402
+ // for a list, where the index is usually a Liquid expression and stands for any position.
403
+ {
404
+ const MARKER = /data-p60-field="([^"]*)"/g;
405
+ const fieldsOf = (type) => new Map((catalogueByType.get(type)?.fields ?? []).map((f) => [f.name, f]));
406
+ const marked = [];
407
+ for (const type of manifest?.supports?.sections ?? []) {
408
+ const source = has(`sections/${type}.liquid`) ? read(`sections/${type}.liquid`) : '';
409
+ const fields = fieldsOf(type);
410
+ for (const [, raw] of source.matchAll(MARKER)) {
411
+ marked.push(type);
412
+ // An index written as Liquid stands for whichever item this is.
413
+ const steps = raw.replace(/\{\{[^}]*\}\}/g, '#').split('.');
414
+ const field = fields.get(steps[0]);
415
+ const named = steps.length === 1
416
+ ? Boolean(field) && field.kind !== 'items'
417
+ : steps.length === 3
418
+ && Boolean(field) && field.kind === 'items'
419
+ && (steps[1] === '#' || /^\d+$/.test(steps[1]))
420
+ && (field.itemFields ?? []).some((f) => f.name === steps[2]);
421
+ if (!named) {
422
+ warnings.push(`section '${type}': data-p60-field="${raw}" does not name a field of this section, the editor will ignore it (a field of ${type}, or items.<index>.<field> for a list)`);
423
+ }
424
+ }
425
+ }
426
+ for (const [path, source] of Object.entries(files)) {
427
+ if (path.endsWith('.liquid') && !path.startsWith('sections/') && MARKER.test(source)) {
428
+ warnings.push(`${path}: data-p60-field marks a section's own content, and there is none here; the editor ignores markers outside sections/`);
429
+ }
430
+ MARKER.lastIndex = 0;
431
+ }
432
+ if (manifest?.supports?.fieldMarkers && marked.length === 0) {
433
+ errors.push('manifest: supports.fieldMarkers is declared but no section marks a field, the editor would offer typing on the page and find nothing to type into');
434
+ }
435
+ if (!manifest?.supports?.fieldMarkers && marked.length > 0) {
436
+ warnings.push('manifest: sections carry data-p60-field markers but supports.fieldMarkers is not declared, declare it so the editor offers typing on the page');
437
+ }
438
+ }
439
+
398
440
  // Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
399
441
  // fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
400
442
  // escaping, so "does the rendered hero display the tenant's photos?" is a substring check,
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "starter",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "format": "port60-liquid@1",
5
5
  "label": "Starter",
6
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
7
  "supports": {
8
8
  "navigationHighlights": false,
9
+ "fieldMarkers": true,
9
10
  "pages": [
10
11
  "home",
11
12
  "about"
@@ -93,7 +94,7 @@
93
94
  }
94
95
  ]
95
96
  },
96
- "changelog": "Places the site search box in the header: a typeahead across events, articles, services, campaigns, appeals, volunteering and documents, with the results page at /search.",
97
+ "changelog": "Marks which node shows which field (data-p60-field), so the editor puts the caret on the page itself instead of in a side panel.",
97
98
  "imagery": {
98
99
  "hero": {
99
100
  "idealAspect": "16:9",
@@ -7,8 +7,8 @@
7
7
  {% if site.content.campaigns.size > 0 %}
8
8
  <section class="lq-site.content.campaigns">
9
9
  <div class="container">
10
- <h2>{{ section.heading | default: campaignsLabel }}</h2>
11
- {% if section.text %}<p>{{ section.text }}</p>{% endif %}
10
+ <h2 data-p60-field="heading">{{ section.heading | default: campaignsLabel }}</h2>
11
+ {% if section.text %}<p data-p60-field="text">{{ section.text }}</p>{% endif %}
12
12
  <ul class="lq-campaign-list">
13
13
  {% for campaign in site.content.campaigns %}
14
14
  <li class="lq-campaign">
@@ -1,8 +1,8 @@
1
1
  <section class="lq-cta">
2
2
  <div class="container lq-cta-grid">
3
3
  <div>
4
- <h2>{{ section.heading }}</h2>
5
- {% if section.text %}<p>{{ section.text }}</p>{% endif %}
4
+ <h2 data-p60-field="heading">{{ section.heading }}</h2>
5
+ {% if section.text %}<p data-p60-field="text">{{ section.text }}</p>{% endif %}
6
6
  <a class="button button-primary" href="{{ section.buttonHref }}">{{ section.buttonLabel }}</a>
7
7
  </div>
8
8
  <div class="lq-cta-widget">
@@ -1,7 +1,14 @@
1
+ {% comment %}
2
+ FIELD MARKERS (supports.fieldMarkers): data-p60-field says which node shows which field, and the
3
+ editor puts the caret there instead of in a side panel. Two rules. The address is the field's
4
+ name inside this section's own content. The marked node holds that field and nothing else, so
5
+ wrap the value in a span when there is punctuation or other copy beside it (people.liquid shows
6
+ one). Marking is optional and additive: an unmarked template is edited in the panel as before.
7
+ {% endcomment %}
1
8
  <section class="lq-hero">
2
9
  <div class="container">
3
- {% if section.eyebrow %}<p class="lq-kicker">{{ section.eyebrow }}</p>{% endif %}
4
- <h1>{{ section.title }}</h1>
10
+ {% if section.eyebrow %}<p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow }}</p>{% endif %}
11
+ <h1 data-p60-field="title">{{ section.title }}</h1>
5
12
  <div class="lq-hero-body">{{ section.bodyHtml | raw }}</div>
6
13
  </div>
7
14
  </section>
@@ -38,9 +38,9 @@
38
38
  {% if heroCount > 1 %}{% island 'hero_carousel' %}{% else %}<div class="lq-vista-scrim" aria-hidden="true"></div>{% endif %}
39
39
  <div class="lq-vista-copy">
40
40
  <div class="container">
41
- <p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
42
- <h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
43
- {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
41
+ <p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow | default: site.brand.name }}</p>
42
+ <h1 data-p60-field="title">{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
43
+ {% if section.lead %}<p class="lq-homehero-lead" data-p60-field="lead">{{ section.lead }}</p>{% endif %}
44
44
  <div class="lq-homehero-actions">
45
45
  <a class="button button-primary" href="{{ section.primaryHref | default: '/donate' }}">{{ section.primaryLabel | default: 'Donate now' }}</a>
46
46
  <a class="button" href="{{ section.secondaryHref | default: '/about' }}">{{ section.secondaryLabel | default: 'Our story' }}</a>
@@ -59,9 +59,9 @@
59
59
  </div>
60
60
  <div class="container lq-homehero-inner lq-vista-overlap">
61
61
  <div>
62
- <p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
63
- <h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
64
- {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
62
+ <p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow | default: site.brand.name }}</p>
63
+ <h1 data-p60-field="title">{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
64
+ {% if section.lead %}<p class="lq-homehero-lead" data-p60-field="lead">{{ section.lead }}</p>{% endif %}
65
65
  <div class="lq-homehero-actions">
66
66
  {% if section.action or section.primaryLabel %}<a class="button button-primary" href="{{ section.primaryHref | default: section.action.href | default: '#donate' }}">{{ section.primaryLabel | default: section.action.label | default: 'Donate now' }}</a>{% endif %}
67
67
  <a class="button" href="{{ section.secondaryHref | default: '/about' }}">{{ section.secondaryLabel | default: 'Our story' }}</a>
@@ -94,9 +94,9 @@
94
94
  {% endif %}
95
95
  <div class="container lq-homehero-inner">
96
96
  <div>
97
- <p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
98
- <h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
99
- {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
97
+ <p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow | default: site.brand.name }}</p>
98
+ <h1 data-p60-field="title">{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
99
+ {% if section.lead %}<p class="lq-homehero-lead" data-p60-field="lead">{{ section.lead }}</p>{% endif %}
100
100
  <div class="lq-homehero-actions">
101
101
  {% if section.actionStyle == 'button' or section.givingStyle == 'button' %}
102
102
  {% if section.action or section.primaryLabel %}<a class="button button-primary" href="{{ section.primaryHref | default: section.action.href | default: '/donate' }}">{{ section.primaryLabel | default: section.action.label | default: 'Donate now' }}</a>{% endif %}
@@ -7,18 +7,19 @@
7
7
  <div class="container">
8
8
  {% if section.eyebrow or section.heading or section.text %}
9
9
  <div class="lq-section-head">
10
- {% if section.eyebrow %}<p class="lq-kicker">{{ section.eyebrow }}</p>{% endif %}
11
- {% if section.heading %}<h2>{{ section.heading }}</h2>{% endif %}
12
- {% if section.text %}<p class="lq-muted">{{ section.text }}</p>{% endif %}
10
+ {% if section.eyebrow %}<p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow }}</p>{% endif %}
11
+ {% if section.heading %}<h2 data-p60-field="heading">{{ section.heading }}</h2>{% endif %}
12
+ {% if section.text %}<p class="lq-muted" data-p60-field="text">{{ section.text }}</p>{% endif %}
13
13
  </div>
14
14
  {% endif %}
15
15
  <div class="lq-people">
16
16
  {% for person in section.items %}
17
17
  <article class="lq-person">
18
18
  {% if person.imageUrl %}<img class="lq-person-photo" src="{{ person.imageUrl }}" alt="" loading="lazy">{% endif %}
19
- <h3>{{ person.name }}</h3>
20
- <p class="lq-person-role">{{ person.role }}</p>
21
- {% if person.statement %}<p class="lq-person-statement">“{{ person.statement }}”</p>{% endif %}
19
+ <h3 data-p60-field="items.{{ forloop.index0 }}.name">{{ person.name }}</h3>
20
+ <p class="lq-person-role" data-p60-field="items.{{ forloop.index0 }}.role">{{ person.role }}</p>
21
+ {%- comment -%} The quotation marks are ours, so the marker goes on a span holding the statement alone. {%- endcomment -%}
22
+ {% if person.statement %}<p class="lq-person-statement">“<span data-p60-field="items.{{ forloop.index0 }}.statement">{{ person.statement }}</span>”</p>{% endif %}
22
23
  </article>
23
24
  {% endfor %}
24
25
  </div>
@@ -1,6 +1,6 @@
1
1
  <section class="lq-values">
2
2
  <div class="container">
3
- <h2>{{ section.heading | default: "What guides us" }}</h2>
3
+ <h2 data-p60-field="heading">{{ section.heading | default: "What guides us" }}</h2>
4
4
  {% comment %}
5
5
  data-p60-reveal-group: the engine stamps --p60-reveal-index on each child and adds
6
6
  is-revealed when the rail scrolls into view, the stagger and motion are OURS, in theme.css,
@@ -10,8 +10,8 @@
10
10
  {% for item in section.items %}
11
11
  <li>
12
12
  <span class="lq-value-dot"></span>
13
- <h3>{{ item.title }}</h3>
14
- <p>{{ item.description }}</p>
13
+ <h3 data-p60-field="items.{{ forloop.index0 }}.title">{{ item.title }}</h3>
14
+ <p data-p60-field="items.{{ forloop.index0 }}.description">{{ item.description }}</p>
15
15
  </li>
16
16
  {% endfor %}
17
17
  </ol>