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.
- package/bin/ferst-update.mjs +58 -8
- package/cms/config.template.yml +1 -0
- package/components/PostArticle.astro +4 -2
- package/components/SiteFooter.astro +1 -2
- package/components/recipes/Announcement.astro +2 -1
- package/components/recipes/ContactForm.astro +2 -1
- package/content/posts.ts +2 -0
- package/lib/autocomplete.ts +25 -0
- package/package.json +1 -1
- package/routes/posts/[...slug].astro +1 -0
package/bin/ferst-update.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
607
|
+
`${restamp ? 'stamped' : 'already recorded in'} ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
|
|
558
608
|
);
|
|
559
609
|
}
|
|
560
610
|
|
package/cms/config.template.yml
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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"
|