ferst-core 0.6.1 → 0.7.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.
@@ -46,6 +46,8 @@ export function isDirectInvocation(argv1) {
46
46
  export const DEFAULT_UPSTREAM = 'https://github.com/MyAI4WebDev/ferst-client-template.git';
47
47
  /** The ownership manifest a seeded client repo carries at its root. */
48
48
  export const MANIFEST = 'ferst-template.json';
49
+ /** The per-site provenance/config file a client repo carries at its root (gitBase, etc.). */
50
+ export const SITE_META = 'ferst-site.json';
49
51
 
50
52
  // ── pure helpers (unit-tested; no shell, no fs writes) ───────────────────────────
51
53
 
@@ -97,11 +99,17 @@ export function resolveUpstream(manifest) {
97
99
  return { url: DEFAULT_UPSTREAM, defaulted: true };
98
100
  }
99
101
 
100
- /** The caret range that pins an installed version to its major.minor (e.g. 0.6.3 → ^0.6). */
102
+ /**
103
+ * The caret range that pins an installed version to its major.minor, in the SAME
104
+ * canonical form the template ships (`^major.minor.0`, e.g. 0.6.3 → ^0.6.0). Emitting
105
+ * the `.0` matters: the template's manifest + package.json carry `^0.6.0`, so a matching
106
+ * canonical form makes the ownedPath resync + this rewrite idempotent — no per-update
107
+ * churn, and no `^0.6` vs `^0.6.0` drift between clients. (`^0.6.0` ≡ `^0.6` to npm.)
108
+ */
101
109
  export function coreRangeFor(version) {
102
110
  const m = String(version).trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/);
103
111
  if (!m) throw new Error(`ferst-update: could not parse ferst-core version "${version}".`);
104
- return `^${m[1]}.${m[2]}`;
112
+ return `^${m[1]}.${m[2]}.0`;
105
113
  }
106
114
 
107
115
  /** Read the `version` out of a package.json's text. Throws if absent. */
@@ -139,6 +147,42 @@ export function replaceCoreRange(raw, next) {
139
147
  return raw.replace(RANGE, `$1${next}$2`);
140
148
  }
141
149
 
150
+ /**
151
+ * Set package.json's `ferst-core` dependency to the canonical range in place,
152
+ * formatting-preserving (same approach as replaceCoreRange). `npm install --save`
153
+ * would otherwise pin it to the exact installed patch (e.g. ^0.6.2), which churns
154
+ * against the template's `^0.6.0` on every resync; the exact version lives in the
155
+ * lockfile + the ferst-site.json drift stamp, so the dep range stays canonical.
156
+ * Returns the new text, or null when there is no `ferst-core` dependency to replace.
157
+ */
158
+ export function replacePackageCoreDep(raw, next) {
159
+ const DEP = /("ferst-core"\s*:\s*")[^"]*(")/;
160
+ if (!DEP.test(raw)) return null;
161
+ return raw.replace(DEP, `$1${next}$2`);
162
+ }
163
+
164
+ /** package.json fields that describe the CLIENT's site, not the template's scaffold. */
165
+ export const CLIENT_PACKAGE_FIELDS = ['name', 'description', 'version', 'private', 'author', 'homepage', 'repository'];
166
+
167
+ /**
168
+ * After the scaffold sync overwrites package.json (a template-owned path), put the
169
+ * client's own identity fields back (name, description, …) so an update never renames
170
+ * the site. Keeps the synced file's indentation and key order. Returns the new text,
171
+ * or null when either side isn't valid JSON (then the synced file is left as is).
172
+ */
173
+ export function keepClientPackageFields(syncedRaw, clientRaw) {
174
+ let synced, client;
175
+ try {
176
+ synced = JSON.parse(syncedRaw);
177
+ client = JSON.parse(clientRaw);
178
+ } catch {
179
+ return null;
180
+ }
181
+ for (const k of CLIENT_PACKAGE_FIELDS) if (k in client) synced[k] = client[k];
182
+ const indent = (syncedRaw.match(/\n([ \t]+)"/) || [, ' '])[1];
183
+ return JSON.stringify(synced, null, indent) + (syncedRaw.endsWith('\n') ? '\n' : '');
184
+ }
185
+
142
186
  /**
143
187
  * The default branch name from `git ls-remote --symref <remote> HEAD` output
144
188
  * (`ref: refs/heads/<branch>\tHEAD`). Null when it can't be read. Pure.
@@ -155,6 +199,33 @@ export function tail(text, n = 40) {
155
199
  return lines.slice(-n).join('\n');
156
200
  }
157
201
 
202
+ /**
203
+ * Build the provenance "drift stamp" for a completed update: WHICH template commit and
204
+ * core version this repo landed on, and when. Pure — the write is the caller's job.
205
+ * @param {{ upstream?: string, commit?: string|null, coreVersion?: string|null, now?: Date|string }} p
206
+ * @returns {{ templateUpstream: string|null, templateCommit: string|null, coreVersion: string|null, at: string }}
207
+ */
208
+ export function buildProvenance({ upstream, commit, coreVersion, now = new Date() } = {}) {
209
+ return {
210
+ templateUpstream: upstream ?? null,
211
+ templateCommit: commit ?? null,
212
+ coreVersion: coreVersion ?? null,
213
+ at: typeof now === 'string' ? now : now.toISOString(),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Merge a provenance stamp into an existing ferst-site.json object under `lastUpdate`,
219
+ * preserving every other field (gitBase, productionBranch, unknown keys) and overwriting
220
+ * any prior `lastUpdate`. Pass null (no ferst-site.json yet) → a fresh object with just
221
+ * the stamp. Pure — never mutates the input.
222
+ * @returns {object} the object to write back
223
+ */
224
+ export function mergeSiteMeta(existing, provenance) {
225
+ const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
226
+ return { ...base, lastUpdate: provenance };
227
+ }
228
+
158
229
  // ── thin side-effecting shell (kept out of the pure helpers above) ────────────────
159
230
 
160
231
  const exec = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -181,6 +252,16 @@ function readInstalledCoreVersion(cwd) {
181
252
  }
182
253
  }
183
254
 
255
+ /** The repo's ferst-site.json parsed to an object, or null when absent/unreadable/non-object. */
256
+ function readSiteMeta(cwd) {
257
+ try {
258
+ const parsed = JSON.parse(readFileSync(resolve(cwd, SITE_META), 'utf8'));
259
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
184
265
  /** Ensure an `upstream` remote points at `url` (add, or set-url if it already exists). */
185
266
  function ensureUpstream(url, cwd) {
186
267
  const remotes = (tryGit('remote', cwd) || '').split(/\r?\n/).map((s) => s.trim());
@@ -240,6 +321,8 @@ function main() {
240
321
  }
241
322
  const branch = parseDefaultBranch(tryGit('ls-remote --symref upstream HEAD', cwd)) || 'main';
242
323
  console.log(`ferst-update: upstream default branch = ${branch}`);
324
+ // The exact template commit this run syncs from (for the provenance stamp / #18 ledger).
325
+ const templateCommit = (tryGit(`rev-parse upstream/${branch}`, cwd) || '').trim() || null;
243
326
 
244
327
  let scaffoldCount = 0; // files the scaffold sync touched (counted BEFORE the npm churn)
245
328
  if (dryRun) {
@@ -248,6 +331,10 @@ function main() {
248
331
  console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
249
332
  console.log(diff ? diff : ' (no scaffold changes)');
250
333
  } else {
334
+ // package.json is template-owned, but its identity fields are the client's — keep them.
335
+ const pkgPath = resolve(cwd, 'package.json');
336
+ let clientPkg = null;
337
+ try { clientPkg = readFileSync(pkgPath, 'utf8'); } catch { /* none yet */ }
251
338
  for (const p of ownedPaths) {
252
339
  // Per-path so one path that upstream no longer ships (pathspec no-match) can't
253
340
  // abort the whole sync — warn and continue.
@@ -255,6 +342,16 @@ function main() {
255
342
  console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
256
343
  }
257
344
  }
345
+ if (clientPkg) {
346
+ try {
347
+ const synced = readFileSync(pkgPath, 'utf8');
348
+ const kept = keepClientPackageFields(synced, clientPkg);
349
+ if (kept && kept !== synced) {
350
+ writeFileSync(pkgPath, kept);
351
+ console.log("ferst-update: kept the site's own package.json name/description.");
352
+ }
353
+ } catch { /* package.json not synced — nothing to restore */ }
354
+ }
258
355
  const status = (tryGit('status --short', cwd) || '').trim();
259
356
  scaffoldCount = status ? status.split(/\r?\n/).filter(Boolean).length : 0;
260
357
  console.log('ferst-update: scaffold synced. git status --short:');
@@ -263,12 +360,13 @@ function main() {
263
360
 
264
361
  // 3. Engine (ferst-core) update.
265
362
  let coreBump = null; // { before, after } once bumped
363
+ let dryLatest = null; // dry-run only: the version `@latest` WOULD install (for the stamp preview)
266
364
  if (noCore) {
267
365
  console.log('ferst-update: --no-core — skipping the ferst-core npm bump.');
268
366
  } else if (dryRun) {
269
- const latest = (tryExec('npm view ferst-core version', cwd) || '').trim();
367
+ dryLatest = (tryExec('npm view ferst-core version', cwd) || '').trim() || null;
270
368
  const current = readInstalledCoreVersion(cwd);
271
- console.log(`ferst-update: ferst-core installed=${current ?? '(none)'} latest=${latest || '(unknown)'}`);
369
+ console.log(`ferst-update: ferst-core installed=${current ?? '(none)'} latest=${dryLatest || '(unknown)'}`);
272
370
  } else {
273
371
  const before = readInstalledCoreVersion(cwd);
274
372
  console.log('ferst-update: npm install ferst-core@latest --save ...');
@@ -297,11 +395,29 @@ function main() {
297
395
  console.warn(`ferst-update: no "coreRange" field in ${MANIFEST} to update (installed ${after}).`);
298
396
  }
299
397
  }
398
+
399
+ // Normalise package.json's ferst-core dep to the same canonical range, undoing
400
+ // `--save`'s exact-patch pin (^0.6.2 → ^0.6.0) so it never churns against the
401
+ // template's ownedPath value. The exact version stays in the lock + drift stamp.
402
+ const pkgPath = resolve(cwd, 'package.json');
403
+ try {
404
+ const pkgRaw = readFileSync(pkgPath, 'utf8');
405
+ const pkgNext = replacePackageCoreDep(pkgRaw, next);
406
+ if (pkgNext && pkgNext !== pkgRaw) {
407
+ writeFileSync(pkgPath, pkgNext);
408
+ console.log(`ferst-update: package.json ferst-core → ${next}`);
409
+ }
410
+ } catch {
411
+ /* no root package.json (shouldn't happen in a client) — skip silently */
412
+ }
300
413
  }
301
414
 
302
415
  // 4. Verify — a failed @latest bump is a real incompatibility to surface, so on
303
416
  // failure we print the tail and exit non-zero WITHOUT reverting.
304
417
  if (dryRun) {
418
+ const wouldCommit = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
419
+ const wouldCore = (noCore ? readInstalledCoreVersion(cwd) : dryLatest) ?? '(unknown)';
420
+ console.log(`ferst-update: --dry-run — WOULD stamp ${SITE_META}: template ${wouldCommit}, core ${wouldCore} (no write).`);
305
421
  console.log('ferst-update: --dry-run complete (no writes, no verify).');
306
422
  return;
307
423
  }
@@ -315,12 +431,25 @@ function main() {
315
431
  }
316
432
  }
317
433
 
318
- // 5. Summary.
434
+ // 5. Provenance drift stamp — record WHICH template commit + core version this repo
435
+ // landed on (only after a fully-successful update), so the repo and the future fleet
436
+ // drift ledger (#18) always know its state. MERGE: preserve gitBase/productionBranch
437
+ // and any other existing ferst-site.json fields.
438
+ const coreVersion = coreBump?.after ?? readInstalledCoreVersion(cwd);
439
+ const provenance = buildProvenance({ upstream: upstream.url, commit: templateCommit, coreVersion });
440
+ writeFileSync(
441
+ resolve(cwd, SITE_META),
442
+ `${JSON.stringify(mergeSiteMeta(readSiteMeta(cwd), provenance), null, 2)}\n`,
443
+ );
444
+ const stampSha = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
445
+
446
+ // 6. Summary.
319
447
  const coreLine = coreBump
320
448
  ? `ferst-core ${coreBump.before ?? '(none)'} → ${coreBump.after}`
321
449
  : 'ferst-core unchanged (--no-core)';
322
450
  console.log(
323
- `ferst-update: DONE — scaffold files updated: ${scaffoldCount}; ${coreLine}; build + check:thin green.`,
451
+ `ferst-update: DONE — scaffold files updated: ${scaffoldCount}; ${coreLine}; ` +
452
+ `stamped ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
324
453
  );
325
454
  }
326
455
 
@@ -1,103 +1,107 @@
1
- ---
2
- /**
3
- * Renders an ordered list of blocks by mapping each block's `type` to its
4
- * component via a registry. Disabled blocks are skipped. The registry is typed
5
- * `Record<BlockType, …>`, so adding a block type to the union without a renderer
6
- * here is a compile error — no silent `undefined` component at runtime.
7
- */
8
- import type { Block, BlockType } from '../content/blocks';
9
- import Heading from './blocks/Heading.astro';
10
- import Prose from './blocks/Prose.astro';
11
- import ImageBlock from './blocks/ImageBlock.astro';
12
- import ButtonBlock from './blocks/ButtonBlock.astro';
13
- import Quote from './blocks/Quote.astro';
14
- import Divider from './blocks/Divider.astro';
15
- import Spacer from './blocks/Spacer.astro';
16
- import Badge from './blocks/Badge.astro';
17
- import Stat from './blocks/Stat.astro';
18
- import List from './blocks/List.astro';
19
- import Section from './blocks/Section.astro';
20
- import Grid from './blocks/Grid.astro';
21
- import Stack from './blocks/Stack.astro';
22
- import Hero from './recipes/Hero.astro';
23
- import FeatureCards from './recipes/FeatureCards.astro';
24
- import Cta from './recipes/Cta.astro';
25
- import Faq from './recipes/Faq.astro';
26
- import Gallery from './recipes/Gallery.astro';
27
- import Testimonial from './recipes/Testimonial.astro';
28
- import ContactForm from './recipes/ContactForm.astro';
29
- import Accordion from './recipes/Accordion.astro';
30
- import Tiles from './recipes/Tiles.astro';
31
- import Banner from './recipes/Banner.astro';
32
- import Stats from './recipes/Stats.astro';
33
- import Steps from './recipes/Steps.astro';
34
- import Pricing from './recipes/Pricing.astro';
35
- import Logos from './recipes/Logos.astro';
36
- import Tabs from './recipes/Tabs.astro';
37
- import Announcement from './recipes/Announcement.astro';
38
- import Bento from './recipes/Bento.astro';
39
- import Marquee from './recipes/Marquee.astro';
40
- import LatestPosts from './recipes/LatestPosts.astro';
41
- import MapEmbed from './recipes/MapEmbed.astro';
42
- import Table from './recipes/Table.astro';
43
- import Notice from './recipes/Notice.astro';
44
- import MediaCards from './recipes/MediaCards.astro';
45
- import ContactCard from './recipes/ContactCard.astro';
46
- import Locations from './recipes/Locations.astro';
47
-
48
- interface Props {
49
- blocks: Block[];
50
- }
51
-
52
- const { blocks } = Astro.props;
53
-
54
- const registry: Record<BlockType, any> = {
55
- heading: Heading,
56
- prose: Prose,
57
- image: ImageBlock,
58
- button: ButtonBlock,
59
- quote: Quote,
60
- divider: Divider,
61
- spacer: Spacer,
62
- badge: Badge,
63
- stat: Stat,
64
- list: List,
65
- section: Section,
66
- grid: Grid,
67
- stack: Stack,
68
- hero: Hero,
69
- featureCards: FeatureCards,
70
- cta: Cta,
71
- faq: Faq,
72
- gallery: Gallery,
73
- testimonial: Testimonial,
74
- contactForm: ContactForm,
75
- accordion: Accordion,
76
- tiles: Tiles,
77
- banner: Banner,
78
- stats: Stats,
79
- steps: Steps,
80
- pricing: Pricing,
81
- logos: Logos,
82
- tabs: Tabs,
83
- announcement: Announcement,
84
- bento: Bento,
85
- marquee: Marquee,
86
- latestPosts: LatestPosts,
87
- mapEmbed: MapEmbed,
88
- table: Table,
89
- notice: Notice,
90
- mediaCards: MediaCards,
91
- contactCard: ContactCard,
92
- locations: Locations,
93
- };
94
- ---
95
-
96
- {
97
- blocks
98
- .filter((block) => block.enabled !== false)
99
- .map((block) => {
100
- const Component = registry[block.type];
101
- return <Component {...block} />;
102
- })
103
- }
1
+ ---
2
+ /**
3
+ * Renders an ordered list of blocks by mapping each block's `type` to its
4
+ * component via a registry. Disabled blocks are skipped. The registry is typed
5
+ * `Record<BlockType, …>`, so adding a block type to the union without a renderer
6
+ * here is a compile error — no silent `undefined` component at runtime.
7
+ */
8
+ import type { Block, BlockType } from '../content/blocks';
9
+ import Heading from './blocks/Heading.astro';
10
+ import Prose from './blocks/Prose.astro';
11
+ import ImageBlock from './blocks/ImageBlock.astro';
12
+ import ButtonBlock from './blocks/ButtonBlock.astro';
13
+ import Quote from './blocks/Quote.astro';
14
+ import Divider from './blocks/Divider.astro';
15
+ import Spacer from './blocks/Spacer.astro';
16
+ import Badge from './blocks/Badge.astro';
17
+ import Stat from './blocks/Stat.astro';
18
+ import List from './blocks/List.astro';
19
+ import Section from './blocks/Section.astro';
20
+ import Grid from './blocks/Grid.astro';
21
+ import Stack from './blocks/Stack.astro';
22
+ import Hero from './recipes/Hero.astro';
23
+ import FeatureCards from './recipes/FeatureCards.astro';
24
+ import Cta from './recipes/Cta.astro';
25
+ import Faq from './recipes/Faq.astro';
26
+ import Gallery from './recipes/Gallery.astro';
27
+ import Testimonial from './recipes/Testimonial.astro';
28
+ import ContactForm from './recipes/ContactForm.astro';
29
+ import Accordion from './recipes/Accordion.astro';
30
+ import Tiles from './recipes/Tiles.astro';
31
+ import Banner from './recipes/Banner.astro';
32
+ import Stats from './recipes/Stats.astro';
33
+ import Steps from './recipes/Steps.astro';
34
+ import Pricing from './recipes/Pricing.astro';
35
+ import Logos from './recipes/Logos.astro';
36
+ import Tabs from './recipes/Tabs.astro';
37
+ import Announcement from './recipes/Announcement.astro';
38
+ import Bento from './recipes/Bento.astro';
39
+ import Marquee from './recipes/Marquee.astro';
40
+ import LatestPosts from './recipes/LatestPosts.astro';
41
+ import MapEmbed from './recipes/MapEmbed.astro';
42
+ import Table from './recipes/Table.astro';
43
+ import Notice from './recipes/Notice.astro';
44
+ import MediaCards from './recipes/MediaCards.astro';
45
+ import ContactCard from './recipes/ContactCard.astro';
46
+ import Locations from './recipes/Locations.astro';
47
+ import Video from './recipes/Video.astro';
48
+ import Documents from './recipes/Documents.astro';
49
+
50
+ interface Props {
51
+ blocks: Block[];
52
+ }
53
+
54
+ const { blocks } = Astro.props;
55
+
56
+ const registry: Record<BlockType, any> = {
57
+ heading: Heading,
58
+ prose: Prose,
59
+ image: ImageBlock,
60
+ button: ButtonBlock,
61
+ quote: Quote,
62
+ divider: Divider,
63
+ spacer: Spacer,
64
+ badge: Badge,
65
+ stat: Stat,
66
+ list: List,
67
+ section: Section,
68
+ grid: Grid,
69
+ stack: Stack,
70
+ hero: Hero,
71
+ featureCards: FeatureCards,
72
+ cta: Cta,
73
+ faq: Faq,
74
+ gallery: Gallery,
75
+ testimonial: Testimonial,
76
+ contactForm: ContactForm,
77
+ accordion: Accordion,
78
+ tiles: Tiles,
79
+ banner: Banner,
80
+ stats: Stats,
81
+ steps: Steps,
82
+ pricing: Pricing,
83
+ logos: Logos,
84
+ tabs: Tabs,
85
+ announcement: Announcement,
86
+ bento: Bento,
87
+ marquee: Marquee,
88
+ latestPosts: LatestPosts,
89
+ mapEmbed: MapEmbed,
90
+ table: Table,
91
+ notice: Notice,
92
+ mediaCards: MediaCards,
93
+ contactCard: ContactCard,
94
+ locations: Locations,
95
+ video: Video,
96
+ documents: Documents,
97
+ };
98
+ ---
99
+
100
+ {
101
+ blocks
102
+ .filter((block) => block.enabled !== false)
103
+ .map((block) => {
104
+ const Component = registry[block.type];
105
+ return <Component {...block} />;
106
+ })
107
+ }
@@ -1,39 +1,56 @@
1
- ---
2
- interface Props {
3
- text: string;
4
- }
5
- const { text } = Astro.props;
6
- // Blank lines separate paragraphs. (Markdown rendering upgrades this later.)
7
- const paragraphs = text
8
- .split(/\n\s*\n/)
9
- .map((p) => p.trim())
10
- .filter(Boolean);
11
- ---
12
-
13
- <div class="b-prose">
14
- {paragraphs.map((p) => <p>{p}</p>)}
15
- </div>
16
-
17
- <style>
18
- .b-prose {
19
- color: var(--fg);
20
- }
21
- .b-prose p {
22
- margin: 0 0 1rem;
23
- line-height: 1.7;
24
- max-width: var(--max);
25
- }
26
- .b-prose p:last-child {
27
- margin-bottom: 0;
28
- }
29
- /* The max-width reading column is left-anchored by default; when the page/
30
- section is centred (or end-aligned) re-anchor it so the text reads as
31
- deliberately centred, not centred inside a left column. Text-align itself
32
- is inherited from the content-align cascade. */
33
- :global([data-content-align='center']) .b-prose p {
34
- margin-inline: auto;
35
- }
36
- :global([data-content-align='end']) .b-prose p {
37
- margin-inline-start: auto;
38
- }
39
- </style>
1
+ ---
2
+ import { proseParts } from '../../lib/prose';
3
+ interface Props {
4
+ text: string;
5
+ }
6
+ const { text } = Astro.props;
7
+ // Blank lines separate paragraphs; `- ` / `1. ` lines make lists; links, bold and
8
+ // italic are rendered safely (lib/prose.ts → inlineMarkdown).
9
+ const parts = proseParts(text);
10
+ ---
11
+
12
+ <div class="b-prose">
13
+ {parts.map((part) =>
14
+ part.kind === 'p' ? <p set:html={part.html} />
15
+ : part.kind === 'ul' ? <ul>{part.items.map((i) => <li set:html={i} />)}</ul>
16
+ : <ol>{part.items.map((i) => <li set:html={i} />)}</ol>
17
+ )}
18
+ </div>
19
+
20
+ <style>
21
+ .b-prose {
22
+ color: var(--fg);
23
+ }
24
+ .b-prose p,
25
+ .b-prose ul,
26
+ .b-prose ol {
27
+ margin: 0 0 1rem;
28
+ line-height: 1.7;
29
+ max-width: var(--max);
30
+ }
31
+ .b-prose ul,
32
+ .b-prose ol {
33
+ padding-inline-start: 1.4em;
34
+ }
35
+ .b-prose li + li {
36
+ margin-top: 0.25rem;
37
+ }
38
+ .b-prose > :last-child {
39
+ margin-bottom: 0;
40
+ }
41
+ .b-prose :global(a) {
42
+ color: var(--accent);
43
+ text-decoration: underline;
44
+ text-underline-offset: 0.15em;
45
+ }
46
+ /* The max-width reading column is left-anchored by default; when the page/
47
+ section is centred (or end-aligned) re-anchor it so the text reads as
48
+ deliberately centred, not centred inside a left column. Text-align itself
49
+ is inherited from the content-align cascade. */
50
+ :global([data-content-align='center']) .b-prose > * {
51
+ margin-inline: auto;
52
+ }
53
+ :global([data-content-align='end']) .b-prose > * {
54
+ margin-inline-start: auto;
55
+ }
56
+ </style>