ferst-core 0.6.2 → 0.7.2

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.
@@ -99,11 +99,17 @@ export function resolveUpstream(manifest) {
99
99
  return { url: DEFAULT_UPSTREAM, defaulted: true };
100
100
  }
101
101
 
102
- /** 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
+ */
103
109
  export function coreRangeFor(version) {
104
110
  const m = String(version).trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/);
105
111
  if (!m) throw new Error(`ferst-update: could not parse ferst-core version "${version}".`);
106
- return `^${m[1]}.${m[2]}`;
112
+ return `^${m[1]}.${m[2]}.0`;
107
113
  }
108
114
 
109
115
  /** Read the `version` out of a package.json's text. Throws if absent. */
@@ -141,6 +147,42 @@ export function replaceCoreRange(raw, next) {
141
147
  return raw.replace(RANGE, `$1${next}$2`);
142
148
  }
143
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
+
144
186
  /**
145
187
  * The default branch name from `git ls-remote --symref <remote> HEAD` output
146
188
  * (`ref: refs/heads/<branch>\tHEAD`). Null when it can't be read. Pure.
@@ -289,6 +331,10 @@ function main() {
289
331
  console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
290
332
  console.log(diff ? diff : ' (no scaffold changes)');
291
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 */ }
292
338
  for (const p of ownedPaths) {
293
339
  // Per-path so one path that upstream no longer ships (pathspec no-match) can't
294
340
  // abort the whole sync — warn and continue.
@@ -296,6 +342,16 @@ function main() {
296
342
  console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
297
343
  }
298
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
+ }
299
355
  const status = (tryGit('status --short', cwd) || '').trim();
300
356
  scaffoldCount = status ? status.split(/\r?\n/).filter(Boolean).length : 0;
301
357
  console.log('ferst-update: scaffold synced. git status --short:');
@@ -339,6 +395,21 @@ function main() {
339
395
  console.warn(`ferst-update: no "coreRange" field in ${MANIFEST} to update (installed ${after}).`);
340
396
  }
341
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
+ }
342
413
  }
343
414
 
344
415
  // 4. Verify — a failed @latest bump is a real incompatibility to surface, so on
@@ -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>