ferst-core 0.2.1 → 0.2.3

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.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // ferst-set-cms-branch — stamp the Sveltia CMS backend branch to match THIS
3
+ // deployment's branch, so config.yml never drifts from the platform git-proxy's
4
+ // branch policy (dev deploy → dev, main → main). Run it AFTER `astro build`:
5
+ //
6
+ // "build": "astro build && ferst-set-cms-branch"
7
+ //
8
+ // It rewrites the built CMS config's `branch:` from CF_PAGES_BRANCH (Cloudflare
9
+ // Pages), falling back to the current git branch, then `main`. The COMMITTED
10
+ // config.yml stays a neutral placeholder, identical on every branch — no
11
+ // per-branch config, no merge hazard.
12
+ //
13
+ // Why it matters: Sveltia READS content from `branch`, and the git-proxy rejects
14
+ // writes whose branch != the project's branch policy. A wrong value = the CMS
15
+ // shows no content and any save 403s. This makes that impossible to ship
16
+ // silently — it fails the build loudly if it can't stamp.
17
+ //
18
+ // Config path: `dist/admin/config.yml` under the current working directory
19
+ // (the client repo root at build time). Override with a first CLI arg.
20
+ import { readFileSync, writeFileSync } from 'node:fs';
21
+ import { execSync } from 'node:child_process';
22
+ import { resolve } from 'node:path';
23
+
24
+ const CONFIG = resolve(process.cwd(), process.argv[2] ?? 'dist/admin/config.yml');
25
+
26
+ function deploymentBranch() {
27
+ const cf = process.env.CF_PAGES_BRANCH?.trim();
28
+ if (cf) return cf;
29
+ try {
30
+ const git = execSync('git rev-parse --abbrev-ref HEAD', {
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).toString().trim();
33
+ if (git && git !== 'HEAD') return git;
34
+ } catch {
35
+ /* not a git checkout — fall through */
36
+ }
37
+ return 'main';
38
+ }
39
+
40
+ const branch = deploymentBranch();
41
+
42
+ let yml;
43
+ try {
44
+ yml = readFileSync(CONFIG, 'utf8');
45
+ } catch {
46
+ console.error(`ferst-set-cms-branch: ${CONFIG} not found — run this AFTER astro build.`);
47
+ process.exit(1);
48
+ }
49
+
50
+ // Check the `branch:` line EXISTS separately from whether it changed — stamping
51
+ // the same value (e.g. main→main on the prod placeholder) is a valid no-op.
52
+ const BRANCH_LINE = /^(\s*branch:\s*)\S.*$/m;
53
+ if (!BRANCH_LINE.test(yml)) {
54
+ console.error('ferst-set-cms-branch: no "branch:" line found in the CMS config backend.');
55
+ process.exit(1);
56
+ }
57
+ writeFileSync(CONFIG, yml.replace(BRANCH_LINE, `$1${branch}`));
58
+ console.log(`ferst-set-cms-branch: CMS backend branch → "${branch}"`);
@@ -5,22 +5,26 @@ interface Props {
5
5
  align?: 'start' | 'center' | 'end';
6
6
  gradient?: boolean;
7
7
  }
8
- const { text, level = 2, align = 'start', gradient = false } = Astro.props;
8
+ const { text, level = 2, align, gradient = false } = Astro.props;
9
9
  const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
10
10
  ---
11
11
 
12
12
  <Tag class="b-heading" data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
13
13
 
14
14
  <style>
15
- /* A heading owns its own alignment via the `align` prop it never depends on
16
- an ancestor rule. (Base no longer force-centres headings, so a recipe that
17
- asks for `start` really is left-aligned, and `center` really is centred,
18
- wherever the block is placed including AI-authored block JSON.) */
15
+ /* By default a heading INHERITS the page/section content alignment (the
16
+ `--content-align` cascade set on `.site-main` / a `section`), so a whole
17
+ template stays coherent. An explicit `align` prop overrides it for this one
18
+ heading. (`text-align` inherits, and inheritance carries the resolved
19
+ alignment; the `[data-align]` rules win over it when set.) */
19
20
  .b-heading {
20
21
  color: var(--fg);
21
22
  margin: 0;
22
23
  text-wrap: balance;
23
24
  line-height: 1.2;
25
+ text-align: inherit;
26
+ }
27
+ .b-heading[data-align='start'] {
24
28
  text-align: start;
25
29
  }
26
30
  .b-heading[data-align='center'] {
@@ -29,6 +29,13 @@ const Tag = style === 'number' ? 'ol' : 'ul';
29
29
  gap: 0.6rem;
30
30
  color: var(--muted);
31
31
  line-height: 1.6;
32
+ text-align: start;
33
+ }
34
+ /* A bulleted/numbered list always reads left; on a centred page centre the
35
+ list as a block but keep the items left-aligned (centred markers read badly). */
36
+ :global([data-content-align='center']) .b-list {
37
+ width: fit-content;
38
+ margin-inline: auto;
32
39
  }
33
40
  .b-list__item {
34
41
  display: flex;
@@ -26,4 +26,14 @@ const paragraphs = text
26
26
  .b-prose p:last-child {
27
27
  margin-bottom: 0;
28
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
+ }
29
39
  </style>
@@ -17,6 +17,12 @@ const { text, cite } = Astro.props;
17
17
  border-inline-start: 3px solid var(--gold);
18
18
  padding-inline-start: 1rem;
19
19
  }
20
+ /* On a centred page/section the left rule reads as off-axis — drop it so the
21
+ quote centres cleanly. (Inherits text-align from the content-align cascade.) */
22
+ :global([data-content-align='center']) .b-quote {
23
+ border-inline-start: none;
24
+ padding-inline-start: 0;
25
+ }
20
26
  .b-quote blockquote {
21
27
  margin: 0;
22
28
  color: var(--fg);
@@ -10,13 +10,15 @@ import BlockRenderer from '../BlockRenderer.astro';
10
10
  interface Props {
11
11
  background?: 'none' | 'muted' | 'accent';
12
12
  width?: 'normal' | 'wide' | 'full';
13
+ /** Override the page's content alignment for this section's subtree. */
14
+ align?: 'start' | 'center' | 'end';
13
15
  blocks?: any[];
14
16
  }
15
- const { background = 'none', width = 'normal', blocks = [] } = Astro.props;
17
+ const { background = 'none', width = 'normal', align, blocks = [] } = Astro.props;
16
18
  ---
17
19
 
18
20
  <section class="b-section" data-bg={background}>
19
- <div class="b-section__inner" data-width={width}>
21
+ <div class="b-section__inner" data-width={width} data-content-align={align}>
20
22
  <BlockRenderer blocks={blocks} />
21
23
  </div>
22
24
  </section>
package/content/blocks.ts CHANGED
@@ -19,7 +19,9 @@ export const headingBlock = z.object({
19
19
  enabled: z.boolean().default(true),
20
20
  text: z.string(),
21
21
  level: z.number().int().min(1).max(6).default(2),
22
- align: z.enum(['start', 'center', 'end']).default('start'),
22
+ /** Explicit per-heading alignment. OMITTED ⇒ inherit the page/section
23
+ * `--content-align` cascade; set it only to override for this one heading. */
24
+ align: z.enum(['start', 'center', 'end']).optional(),
23
25
  /** Paint the text with a brand → accent-2 gradient (falls back to solid ink
24
26
  * where background-clip:text is unsupported). A per-block opt-in effect. */
25
27
  gradient: z.boolean().default(false),
@@ -446,6 +448,8 @@ export const sectionBlock = z.object({
446
448
  enabled: z.boolean().default(true),
447
449
  background: z.enum(['none', 'muted', 'accent']).default('none'),
448
450
  width: z.enum(['normal', 'wide', 'full']).default('normal'),
451
+ /** Override the page's content alignment for this section's subtree. */
452
+ align: z.enum(['start', 'center', 'end']).optional(),
449
453
  blocks: z.lazy(() => z.array(blockSchema)).default([]),
450
454
  });
451
455
 
@@ -512,6 +516,15 @@ export const pageSchema = z.object({
512
516
  description: z.string().optional(),
513
517
  /** Preloaded as the LCP image (passed to the layout) when set. */
514
518
  heroImage: z.string().optional(),
519
+ /**
520
+ * Template-level content alignment — the default for text-bearing blocks on the
521
+ * page (headings, prose, quote). Cascades as `--content-align` so every recipe
522
+ * inherits one coherent alignment instead of each hard-coding its own. A
523
+ * `section` block can override it for its subtree; a `heading` can override per
524
+ * block. `start` (left) is the readable default for long-form; `center` suits
525
+ * short, hero-led pages.
526
+ */
527
+ align: z.enum(['start', 'center', 'end']).default('start'),
515
528
  /**
516
529
  * Provenance — the ferst-library template this page was instantiated from.
517
530
  * The triangular model (docs/BLOCK-ARCHITECTURE.md §5): lego box → a template
@@ -37,6 +37,9 @@ interface Props {
37
37
  footerClass?: string;
38
38
  /** Render the secondary logo centred at the bottom of the page content. */
39
39
  showSecondaryLogo?: boolean;
40
+ /** Template-level content alignment — cascades to text-bearing blocks via
41
+ * `data-content-align` on <main>. `start` (default) keeps long-form readable. */
42
+ align?: 'start' | 'center' | 'end';
40
43
  }
41
44
 
42
45
  const {
@@ -47,6 +50,7 @@ const {
47
50
  mainClass,
48
51
  footerClass,
49
52
  showSecondaryLogo = false,
53
+ align = 'start',
50
54
  } = Astro.props;
51
55
 
52
56
  const settings = await loadSiteSettings();
@@ -144,7 +148,7 @@ const metaDescription = description ?? identity.description;
144
148
  dropdown={navigation.dropdown}
145
149
  />
146
150
 
147
- <main class:list={['site-main', mainClass]}>
151
+ <main class:list={['site-main', mainClass]} data-content-align={align}>
148
152
  <slot name="hero" />
149
153
  <slot />
150
154
  {showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
@@ -356,6 +360,23 @@ const metaDescription = description ?? identity.description;
356
360
  margin-block: 0;
357
361
  }
358
362
 
363
+ /* ── Content alignment cascade ──────────────────────────────────────
364
+ A page sets its default alignment via the `align` prop (→
365
+ data-content-align on <main>); a `section` block can override it for
366
+ its own subtree. Text-bearing blocks inherit `text-align` from here, so
367
+ a whole template stays on one alignment axis instead of each recipe
368
+ hard-coding its own. A few blocks adjust when centred so the result
369
+ reads as deliberately centred, not centred-inside-a-left-column.
370
+ `start` (left) is the readable default for long-form; a heading can
371
+ still override per-block via its own `align`. */
372
+ [data-content-align='start'] { text-align: start; }
373
+ [data-content-align='center'] { text-align: center; }
374
+ [data-content-align='end'] { text-align: end; }
375
+ /* Each content block owns how it ADJUSTS when centred/ended (re-centring
376
+ its reading column, dropping a left rule, etc.) in its own component,
377
+ so the rule out-specifies the block's own scoped styles. Here we only
378
+ set the inherited text-align axis. */
379
+
359
380
  /* ── Secondary logo block: centered ── */
360
381
  .secondary-logo {
361
382
  display: flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -44,7 +44,8 @@
44
44
  "LICENSE-Apache-2.0"
45
45
  ],
46
46
  "bin": {
47
- "ferst-check-thin": "bin/check-thin.mjs"
47
+ "ferst-check-thin": "bin/check-thin.mjs",
48
+ "ferst-set-cms-branch": "bin/set-cms-branch.mjs"
48
49
  },
49
50
  "dependencies": {
50
51
  "zod": "^3.25.0"