ferst-core 0.9.4 → 0.9.6

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.
@@ -294,6 +294,30 @@ export function mergeSiteMeta(existing, provenance) {
294
294
  return { ...base, lastUpdate: provenance };
295
295
  }
296
296
 
297
+ /**
298
+ * What the update would leave in one template-owned file: the template's copy, then the
299
+ * same adjustments the real run makes (package.json keeps the site's own name/description and
300
+ * takes the canonical engine range; the manifest takes the canonical coreRange). The dry run
301
+ * compares this, not the raw template copy, with the site's file, so it reports only real
302
+ * changes (CTK #20, OLR #23). Pure.
303
+ */
304
+ export function scaffoldFileAfterUpdate(path, upstreamRaw, currentRaw, coreRange) {
305
+ let out = upstreamRaw;
306
+ if (path === 'package.json') {
307
+ if (currentRaw != null) out = keepClientPackageFields(out, currentRaw) ?? out;
308
+ if (coreRange) out = replacePackageCoreDep(out, coreRange) ?? out;
309
+ } else if (path === MANIFEST && coreRange) {
310
+ out = replaceCoreRange(out, coreRange) ?? out;
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /** Whether a new drift stamp records a different state than the last (the time alone isn't). */
316
+ export function stampChanged(previous, next) {
317
+ if (!previous || typeof previous !== 'object') return true;
318
+ return ['templateUpstream', 'templateCommit', 'templateVersion', 'coreVersion'].some((k) => (previous[k] ?? null) !== (next[k] ?? null));
319
+ }
320
+
297
321
  // ── thin side-effecting shell (kept out of the pure helpers above) ────────────────
298
322
 
299
323
  const exec = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -413,11 +437,25 @@ function main() {
413
437
 
414
438
  let templateVersion = null; // the template version this sync brought (for the manifest + stamp)
415
439
  let scaffoldCount = 0; // files the scaffold sync touched (counted BEFORE the npm churn)
440
+ let dryScaffold = []; // dry-run only: the files the update would really change
416
441
  if (dryRun) {
442
+ // Compare what the update would leave (the site's package identity, the canonical engine
443
+ // range), not the raw template copy, so only real changes count (CTK #20, OLR #23).
417
444
  const spec = syncPaths.map(q).join(' ');
418
- const diff = (tryGit(`diff --stat ${TEMPLATE_REMOTE}/${branch} -- ${spec}`, cwd) || '').trim();
445
+ const target = noCore
446
+ ? readInstalledCoreVersion(cwd)
447
+ : (tryExec('npm view ferst-core version', cwd) || '').trim() || readInstalledCoreVersion(cwd);
448
+ const range = target ? coreRangeFor(target) : null;
449
+ const candidates = (tryGit(`diff --name-only ${TEMPLATE_REMOTE}/${branch} -- ${spec}`, cwd) || '').split(/\r?\n/).filter(Boolean);
450
+ for (const f of candidates) {
451
+ const upstreamRaw = tryGit(`show ${TEMPLATE_REMOTE}/${branch}:${f}`, cwd);
452
+ let current = null;
453
+ try { current = readFileSync(resolve(cwd, f), 'utf8'); } catch { /* a file the site doesn't have yet */ }
454
+ const lf = (t) => t.replace(/\r\n/g, '\n'); // a checkout's line endings aren't a change
455
+ if (upstreamRaw === null || current === null || lf(scaffoldFileAfterUpdate(f, lf(upstreamRaw), lf(current), range)) !== lf(current)) dryScaffold.push(f);
456
+ }
419
457
  console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
420
- console.log(diff ? diff : ' (no scaffold changes)');
458
+ console.log(dryScaffold.length ? dryScaffold.map((f) => ` ${f}`).join('\n') : ' (no scaffold changes)');
421
459
  } else {
422
460
  // package.json is template-owned, but its identity fields are the client's — keep them.
423
461
  const pkgPath = resolve(cwd, 'package.json');
@@ -430,6 +468,10 @@ function main() {
430
468
  console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
431
469
  }
432
470
  }
471
+ // `git checkout <tree> -- path` also stages the template's copy. Unstage it, so the index
472
+ // never holds a copy that the steps below put back (OLR #23 saw `MM`), and a commit takes
473
+ // only what the update really changed.
474
+ tryGit(`reset -q -- ${syncPaths.map(q).join(' ')}`, cwd);
433
475
  if (clientPkg) {
434
476
  try {
435
477
  const synced = readFileSync(pkgPath, 'utf8');
@@ -522,7 +564,10 @@ function main() {
522
564
  if (dryRun) {
523
565
  const wouldCommit = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
524
566
  const wouldCore = (noCore ? readInstalledCoreVersion(cwd) : dryLatest) ?? '(unknown)';
525
- console.log(`ferst-update: --dry-run — WOULD stamp ${SITE_META}: template ${wouldCommit}, core ${wouldCore} (no write).`);
567
+ const prev = readSiteMeta(cwd)?.lastUpdate;
568
+ const wouldMove = stampChanged(prev, { ...(prev ?? {}), templateCommit: templateCommit ?? null, coreVersion: wouldCore === '(unknown)' ? prev?.coreVersion ?? null : wouldCore });
569
+ if (!dryScaffold.length && !wouldMove) console.log('ferst-update: --dry-run — nothing to update (scaffold and engine current).');
570
+ else console.log(`ferst-update: --dry-run — WOULD stamp ${SITE_META}: template ${wouldCommit}, core ${wouldCore} (no write).`);
526
571
  console.log('ferst-update: --dry-run complete (no writes, no verify).');
527
572
  return;
528
573
  }
@@ -542,10 +587,15 @@ function main() {
542
587
  // and any other existing ferst-site.json fields.
543
588
  const coreVersion = coreBump?.after ?? readInstalledCoreVersion(cwd);
544
589
  const provenance = buildProvenance({ upstream: upstream.url, commit: templateCommit, coreVersion, templateVersion: templateVersion ?? readTemplateVersion(raw) });
545
- writeFileSync(
546
- resolve(cwd, SITE_META),
547
- `${JSON.stringify(mergeSiteMeta(readSiteMeta(cwd), provenance), null, 2)}\n`,
548
- );
590
+ // Only when the site actually moved: re-stamping just the time left a pointless commit (and
591
+ // a test-copy redeploy) on every scheduled run.
592
+ const existingMeta = readSiteMeta(cwd);
593
+ const restamp = stampChanged(existingMeta?.lastUpdate, provenance);
594
+ if (restamp) {
595
+ writeFileSync(resolve(cwd, SITE_META), `${JSON.stringify(mergeSiteMeta(existingMeta, provenance), null, 2)}\n`);
596
+ } else {
597
+ console.log(`ferst-update: ${SITE_META} already records this template and engine — not re-stamped.`);
598
+ }
549
599
  const stampSha = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
550
600
 
551
601
  // 6. Summary.
@@ -554,7 +604,7 @@ function main() {
554
604
  : 'ferst-core unchanged (--no-core)';
555
605
  console.log(
556
606
  `ferst-update: DONE — scaffold files updated: ${scaffoldCount}; ${coreLine}; ` +
557
- `stamped ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
607
+ `${restamp ? 'stamped' : 'already recorded in'} ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
558
608
  );
559
609
  }
560
610
 
@@ -212,6 +212,7 @@ collections:
212
212
  search_fields: [label]
213
213
  multiple: true
214
214
  - { name: heroImage, label: Hero image, widget: image, required: false }
215
+ - { name: heroImageAlt, label: Picture description, widget: string, required: false, hint: "Who or what the picture shows, in a short sentence, for people who can't see it (e.g. Parishioners outside the church after the Easter Vigil). Leave empty if it's only decoration." }
215
216
  - { name: attachment, label: Attachment (e.g. PDF), widget: file, required: false }
216
217
  - { name: draft, label: Draft, widget: boolean, default: false, required: false }
217
218
  - { name: body, label: Body, widget: markdown, required: false }
@@ -10,6 +10,8 @@ interface Props {
10
10
  title: string;
11
11
  date: Date | string;
12
12
  heroImage?: string;
13
+ /** What the picture shows (its alt text); empty leaves it as decoration. */
14
+ heroImageAlt?: string;
13
15
  /** Optional downloadable file (e.g. a PDF newsletter) shown as a download link. */
14
16
  attachment?: string;
15
17
  /** Resolved tags for display + linking. */
@@ -20,7 +22,7 @@ interface Props {
20
22
  backHref?: string;
21
23
  backLabel?: string;
22
24
  }
23
- const { title, date, heroImage, attachment, tags = [], tagBase = '/tags', backHref, backLabel = 'Back to news' } = Astro.props;
25
+ const { title, date, heroImage, heroImageAlt = '', attachment, tags = [], tagBase = '/tags', backHref, backLabel = 'Back to news' } = Astro.props;
24
26
  // A human label for the download link, hinting the file type from its extension.
25
27
  const attachmentExt = attachment ? attachment.split('.').pop()?.toUpperCase() : undefined;
26
28
  const attachmentLabel = attachmentExt && attachmentExt.length <= 4 ? `Download (${attachmentExt})` : 'Download attachment';
@@ -47,7 +49,7 @@ const display = Number.isNaN(d.valueOf())
47
49
  </ul>
48
50
  )}
49
51
  </div>
50
- {heroImage && <img class="b-post__hero" src={heroImage} alt="" />}
52
+ {heroImage && <img class="b-post__hero" src={heroImage} alt={heroImageAlt} />}
51
53
  </header>
52
54
  <div class="b-post__body">
53
55
  <slot />
@@ -304,8 +304,7 @@ const year = new Date().getFullYear();
304
304
  font-weight: 600;
305
305
  letter-spacing: 0.1em;
306
306
  text-transform: uppercase;
307
- color: var(--text-footer);
308
- opacity: 0.8;
307
+ color: var(--text-footer); /* full strength: at 80% it fell below AA (3.8:1) */
309
308
  }
310
309
  .site-footer .footer-group ul {
311
310
  list-style: none;
@@ -35,7 +35,8 @@ const key = `ferst-dismissed-${id}`;
35
35
  }
36
36
  .b-announce[data-tone='accent'] { background: var(--gold); color: var(--on-brand); }
37
37
  .b-announce[data-tone='neutral'] { background: var(--bg-section); color: var(--fg); }
38
- .b-announce__text { margin: 0; line-height: 1.4; }
38
+ /* Its text takes the band's colour (on-brand or ink), not the site's grey paragraph colour. */
39
+ .b-announce__text { margin: 0; line-height: 1.4; color: inherit; }
39
40
  .b-announce__link {
40
41
  color: inherit;
41
42
  font-weight: 700;
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import Heading from '../blocks/Heading.astro';
13
13
  import Button from '../Button.astro';
14
+ import { autocompleteFor } from '../../lib/autocomplete';
14
15
 
15
16
  interface Field {
16
17
  name: string;
@@ -63,7 +64,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
63
64
  {type === 'textarea' ? (
64
65
  <textarea id={id} name={field.name} rows="5" required={field.required} placeholder={field.placeholder}></textarea>
65
66
  ) : (
66
- <input id={id} type={type} name={field.name} required={field.required} placeholder={field.placeholder} />
67
+ <input id={id} type={type} name={field.name} required={field.required} placeholder={field.placeholder} autocomplete={autocompleteFor({ ...field, type })} />
67
68
  )}
68
69
  </div>
69
70
  );
package/content/posts.ts CHANGED
@@ -30,6 +30,8 @@ export const postSchema = z.object({
30
30
  tags: z.array(z.string()).default([]),
31
31
  /** Optional hero / preview image. */
32
32
  heroImage: z.string().optional(),
33
+ /** What the picture shows, for people who can't see it (its alt text). Empty = decoration. */
34
+ heroImageAlt: z.string().optional(),
33
35
  /** Optional downloadable file attached to the post (e.g. a PDF newsletter or
34
36
  * notice). Rendered as a download link on the post. */
35
37
  attachment: z.string().optional(),
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What a form field asks for about the person filling it in, as an HTML `autocomplete`
3
+ * token (WCAG 2.2 AA 1.3.5, identify input purpose), so browsers and assistive tools can fill
4
+ * it in. Only the person's own name, email and phone; anything else says nothing rather than
5
+ * guess (a child's or a parish's name isn't the person's). Pure, so it's unit-testable.
6
+ */
7
+ export interface FieldLike {
8
+ name: string;
9
+ label: string;
10
+ type?: string;
11
+ }
12
+
13
+ export function autocompleteFor({ name, label, type }: FieldLike): string | undefined {
14
+ if (type === 'email') return 'email';
15
+ if (type === 'tel') return 'tel';
16
+ if (type === 'textarea') return undefined;
17
+ const words = `${name} ${label}`.toLowerCase().replace(/[_-]+/g, ' ');
18
+ const text = label.toLowerCase().trim();
19
+ if (/\b(e-?mail)\b/.test(words)) return 'email';
20
+ if (/\b(phone|telephone|mobile)\b/.test(words)) return 'tel';
21
+ if (/\b(first|given) name\b/.test(words)) return 'given-name';
22
+ if (/\b(last name|surname|family name)\b/.test(words)) return 'family-name';
23
+ if (/^(your |full )?name$/.test(text) || /^(full )?name$/.test(name.toLowerCase().replace(/[_-]+/g, ' '))) return 'name';
24
+ return undefined;
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",
@@ -20,6 +20,7 @@ const tags = await resolveTagLabels(entry.data.tags);
20
20
  title={entry.data.title}
21
21
  date={entry.data.date}
22
22
  heroImage={entry.data.heroImage}
23
+ heroImageAlt={entry.data.heroImageAlt}
23
24
  attachment={entry.data.attachment}
24
25
  tags={tags}
25
26
  tagBase="/posts/tag"