@uniweb/build 0.31.0 → 0.33.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,14 +60,14 @@
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
62
  "@uniweb/schemas": "^0.2.13",
63
+ "@uniweb/projections": "^0.5.4",
63
64
  "@uniweb/theming": "^0.1.15",
64
- "@uniweb/semantic-parser": "^1.4.0",
65
65
  "@uniweb/content-reader": "^1.2.4",
66
- "@uniweb/projections": "^0.5.3",
66
+ "@uniweb/semantic-parser": "^1.4.0",
67
67
  "@uniweb/content-writer": "^0.3.4"
68
68
  },
69
69
  "optionalDependencies": {
70
- "@uniweb/runtime": "^0.13.4"
70
+ "@uniweb/runtime": "^0.13.5"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -76,7 +76,7 @@
76
76
  "@tailwindcss/vite": "^4.0.0",
77
77
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
78
78
  "vite-plugin-svgr": "^4.0.0",
79
- "@uniweb/core": "^0.15.0"
79
+ "@uniweb/core": "^0.16.0"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "vite": {
@@ -23,6 +23,7 @@ import { existsSync } from 'node:fs'
23
23
  import { join, relative, dirname } from 'node:path'
24
24
  import yaml from 'js-yaml'
25
25
  import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
26
+ import { parseFrontmatter } from '../utils/frontmatter.js'
26
27
 
27
28
  // Try to import content-reader for markdown → ProseMirror conversion
28
29
  let markdownToProseMirror
@@ -42,29 +43,6 @@ try {
42
43
  })
43
44
  }
44
45
 
45
- /**
46
- * Parse YAML frontmatter from markdown content
47
- * @param {string} content - Raw markdown content
48
- * @returns {{ frontmatter: Object, body: string }}
49
- */
50
- function parseFrontmatter(content) {
51
- if (!content.trim().startsWith('---')) {
52
- return { frontmatter: {}, body: content }
53
- }
54
-
55
- const parts = content.split('---\n')
56
- if (parts.length < 3) {
57
- return { frontmatter: {}, body: content }
58
- }
59
-
60
- try {
61
- const frontmatter = yaml.load(parts[1]) || {}
62
- const body = parts.slice(2).join('---\n')
63
- return { frontmatter, body }
64
- } catch {
65
- return { frontmatter: {}, body: content }
66
- }
67
- }
68
46
 
69
47
  /**
70
48
  * Normalize route for filesystem path
@@ -120,7 +98,7 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
120
98
 
121
99
  try {
122
100
  const content = await readFile(filePath, 'utf-8')
123
- const { frontmatter, body } = parseFrontmatter(content)
101
+ const { frontmatter, body } = parseFrontmatter(content, filePath)
124
102
 
125
103
  // Convert markdown body to ProseMirror
126
104
  const proseMirrorContent = markdownToProseMirror(body)
@@ -174,7 +152,7 @@ export async function loadFreeformRecord(item, schema, locale, localesDir) {
174
152
 
175
153
  try {
176
154
  const content = await readFile(filePath, 'utf-8')
177
- const { frontmatter, body } = parseFrontmatter(content)
155
+ const { frontmatter, body } = parseFrontmatter(content, filePath)
178
156
 
179
157
  // Convert markdown body to ProseMirror (if body exists)
180
158
  const proseMirrorContent = body.trim() ? markdownToProseMirror(body) : null
@@ -320,7 +320,31 @@ export async function defineSiteConfig(options = {}) {
320
320
  // Point #foundation at a virtual noop module.
321
321
  alias['#foundation'] = '\0__foundation-noop__'
322
322
  } else if (foundationInfo.type !== 'url') {
323
- // Bundled mode: #foundation points to the actual package
323
+ // Bundled mode: #foundation points to the actual package.
324
+ //
325
+ // ⛔ **A BARE SPECIFIER, NOT `foundationInfo.path` — and the obvious
326
+ // "improvement" is measured and wrong.** We hold the resolved path right
327
+ // here, and handing vite the *name* instead is what lets its node_modules
328
+ // lookup disagree with ours (the whole reason
329
+ // `uniweb:ensure-foundation-entry` now runs `checkFoundationResolution`).
330
+ // Collapsing the two by aliasing to the path looks like it deletes that bug
331
+ // class. It does not:
332
+ //
333
+ // A vite alias is PREFIX replacement, so `#foundation/styles` — which the
334
+ // site's own `entry.js` imports — becomes `<path>/styles`, a filesystem
335
+ // path. That bypasses the foundation's `exports` map, and the map is where
336
+ // `./styles` → `./styles.css` lives (along with `./dist` and
337
+ // `./dist/styles`). Measured 2026-09-01 on a stock marketing scaffold:
338
+ // `[vite:load-fallback] Could not load <path>/src/styles (imported by
339
+ // entry.js): ENOENT`. A HEALTHY project fails to build.
340
+ //
341
+ // Splitting it — exact `#foundation` → path, `#foundation/*` → name — is
342
+ // worse still: on a diverged tree the entry would come from one directory
343
+ // and the stylesheet from another, which is a state neither consistent
344
+ // option can reach.
345
+ //
346
+ // ⇒ **The name is load-bearing because the exports map is.** Detecting the
347
+ // disagreement is the correct shape; eliminating it costs the exports map.
324
348
  alias['#foundation'] = foundationInfo.name
325
349
  }
326
350
 
@@ -36,6 +36,7 @@ import { parseFetchConfig } from './data-fetcher.js'
36
36
  import { resolveExtensionUrls } from './extension-urls.js'
37
37
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
38
38
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
39
+ import { parseFrontmatter } from '../utils/frontmatter.js'
39
40
 
40
41
  // Try to import content-reader, fall back to simplified parser
41
42
  let markdownToProseMirror
@@ -159,13 +160,44 @@ function buildVersionMetadata(detectedVersions, pageConfig = {}) {
159
160
  }
160
161
 
161
162
  /**
162
- * Parse YAML string using js-yaml
163
+ * Malformed YAML met during the current collect.
164
+ *
165
+ * ⛔ **A PARSE FAILURE USED TO BE A `console.warn` AND AN EMPTY OBJECT**, and
166
+ * this function reads `site.yml`, `page.yml`, `folder.yml`, `theme.yml` and
167
+ * every section's frontmatter — i.e. every configuration surface an author
168
+ * writes. So a single typo silently discarded that file's entire contribution:
169
+ * page order, nesting, `sections:`, `data:` declarations, theme. **The build
170
+ * succeeded and shipped a site missing what the author asked for**, with one
171
+ * line on stderr that named no file.
172
+ *
173
+ * ⭐ **Collected rather than thrown at the point of failure, on purpose.**
174
+ * Throwing from `parseYaml` would end the collect at the FIRST bad file, so an
175
+ * author with three typos fixes one, rebuilds, and learns about the next.
176
+ * Accumulating lets `collectSiteContent` report every one at once and then
177
+ * decide, which is also where the `strict` flag already lives.
178
+ *
179
+ * ⚠️ **Module-scoped, so it assumes one collect at a time in a process.** True
180
+ * of the production path (`build-site-data.js` runs one) and harmless in dev,
181
+ * where the outcome is a warning either way and an interleaved rebuild can only
182
+ * mis-attribute a message. Do not read this as a general-purpose registry.
183
+ */
184
+ let yamlFailures = []
185
+
186
+ /**
187
+ * Parse YAML, recording a failure against the file it came from.
188
+ *
189
+ * @param {string} yamlString
190
+ * @param {string} source - path or label of the file this text came from. It is
191
+ * the whole point of the record: the old message named nothing, so an author
192
+ * with a typo learned only that *some* YAML somewhere was bad.
193
+ * @returns {Object} the parsed value, or `{}` when it could not be parsed
163
194
  */
164
- function parseYaml(yamlString) {
195
+ function parseYaml(yamlString, source = '<unknown>') {
165
196
  try {
166
197
  return yaml.load(yamlString) || {}
167
198
  } catch (err) {
168
- console.warn('[content-collector] YAML parse error:', err.message)
199
+ yamlFailures.push({ source, message: err.message })
200
+ console.warn(`[content-collector] YAML parse error in ${source}: ${err.message}`)
169
201
  return {}
170
202
  }
171
203
  }
@@ -176,7 +208,7 @@ function parseYaml(yamlString) {
176
208
  async function readYamlFile(filePath) {
177
209
  try {
178
210
  const content = await readFile(filePath, 'utf8')
179
- return parseYaml(content)
211
+ return parseYaml(content, filePath)
180
212
  } catch (err) {
181
213
  if (err.code === 'ENOENT') return {}
182
214
  throw err
@@ -802,13 +834,22 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
802
834
  let frontMatter = {}
803
835
  let markdown = content
804
836
 
805
- // Extract frontmatter
806
- if (content.trim().startsWith('---')) {
807
- const parts = content.split('---\n')
808
- if (parts.length >= 3) {
809
- frontMatter = parseYaml(parts[1])
810
- markdown = parts.slice(2).join('---\n')
811
- }
837
+ // Extract frontmatter through the ONE splitter (`utils/frontmatter.js`),
838
+ // rather than re-implementing the `---\n` split for a fourth time.
839
+ //
840
+ // **The catch is the point, not a workaround.** That function always
841
+ // throws, and its header says a caller wanting tolerance should say so with a
842
+ // `try` — explicit and local, instead of a helper that swallowed on behalf of
843
+ // everyone. This IS that caller: a collect accumulates failures so it can
844
+ // report every bad file at once and let `strict` decide, which is a policy
845
+ // only the collect can hold.
846
+ try {
847
+ const parsed = parseFrontmatter(content, filePath)
848
+ frontMatter = parsed.frontmatter
849
+ markdown = parsed.body
850
+ } catch (err) {
851
+ yamlFailures.push({ source: filePath, message: err.message })
852
+ console.warn(`[content-collector] ${err.message}`)
812
853
  }
813
854
 
814
855
  const { type, component, preset, input, props, fetch, data, id: frontmatterId, ...params } = frontMatter
@@ -1966,48 +2007,60 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1966
2007
  export async function loadFoundationInfo(foundationPath) {
1967
2008
  if (!foundationPath) return { vars: {}, layoutNames: new Set() }
1968
2009
 
1969
- // Try dist/meta/schema.json first (built foundation), then root schema.json
1970
- const distSchemaPath = join(foundationPath, 'dist', 'meta', 'schema.json')
1971
- const rootSchemaPath = join(foundationPath, 'schema.json')
1972
-
1973
- const schemaPath = existsSync(distSchemaPath)
1974
- ? distSchemaPath
1975
- : existsSync(rootSchemaPath)
1976
- ? rootSchemaPath
1977
- : null
1978
-
1979
- if (schemaPath) {
1980
- try {
1981
- const schemaContent = await readFile(schemaPath, 'utf8')
1982
- const schema = JSON.parse(schemaContent)
1983
- // Foundation config is in _self, support both 'vars' (new) and 'themeVars' (legacy)
1984
- const vars = schema._self?.vars || schema._self?.themeVars || schema.themeVars || {}
1985
- // Layout names from _layouts (keys are layout component names)
1986
- const layoutNames = new Set(schema._layouts ? Object.keys(schema._layouts) : [])
1987
- return { vars, layoutNames }
1988
- } catch (err) {
1989
- console.warn('[content-collector] Failed to load foundation schema:', err.message)
1990
- // Fall through to the source-config fallback below.
1991
- }
2010
+ // **NOT `dist/meta/schema.json`.** That file is the EDITOR's artifact — the
2011
+ // rich per-section declaration a visual editor needs to render parameter forms
2012
+ // and component pickers — and it is not a source of truth for a site build.
2013
+ // The architecture is explicit that the declaration is emitted in two shapes
2014
+ // for two audiences (`kb/framework/architecture/site-foundation-runtime-model.md`
2015
+ // § The two-audience schema): the lean runtime half ships INSIDE `dist/entry.js`
2016
+ // as `capabilities`, and the rich half is for authoring tools only.
2017
+ //
2018
+ // Reading the editor's copy here was wrong twice over. It made a site build
2019
+ // depend on the foundation having been BUILT, and it read a derived artifact
2020
+ // when the source it derives from is right there — `generate-entry.js` calls
2021
+ // these same two functions (`:197` for vars, `:287` for layouts) to produce the
2022
+ // very `schema.json` this used to read back.
2023
+ //
2024
+ // **And it made dev and build classify layouts differently.** The old
2025
+ // fallback returned an EMPTY layout set whenever no built schema existed
2026
+ // "the normal state for `uniweb dev`" and `collectLayouts` treats a
2027
+ // `layout/<Name>/` directory as a named layout only when `<Name>` is in that
2028
+ // set, otherwise as a folder-form area of the DEFAULT layout. So the same site
2029
+ // rendered `layout/Wide/` as the Wide layout after a build and as a default
2030
+ // area called "Wide" in dev. Measured 2026-09-02 before this change:
2031
+ // built layouts ["default","Wide"], default areas ["footer","header"]
2032
+ // dev layouts ["default"], default areas ["Wide","footer","header"]
2033
+ // Reading from source removes the asymmetry: there is one answer, and it does
2034
+ // not depend on whether `dist/` happens to exist.
2035
+ const { resolveFoundationSrcPath } = await import('../utils/foundation-source-root.js')
2036
+ const { loadFoundationConfig, discoverLayoutsInPath } = await import('../schema.js')
2037
+ const srcDir = resolveFoundationSrcPath(foundationPath)
2038
+
2039
+ // Two independent reads, so a failure in one does not cost the other. The
2040
+ // previous single try/catch lost the layouts when only the config was broken.
2041
+ let vars = {}
2042
+ try {
2043
+ const config = await loadFoundationConfig(srcDir)
2044
+ vars = config?.vars || {}
2045
+ } catch (err) {
2046
+ console.warn(
2047
+ `[content-collector] Could not read the foundation's declared theme vars from ${srcDir}: ${err.message}\n` +
2048
+ `[content-collector] Continuing WITHOUT them — the wrapped error above is about loading that config, not about this build stopping.\n` +
2049
+ `[content-collector] Every foundation theme var is therefore undefined, and sections styled with var(--…) will render with collapsed spacing.`
2050
+ )
1992
2051
  }
1993
2052
 
1994
- // No built schema.json — the normal state for `uniweb dev` on a bundled-mode
1995
- // site that was never built (dev doesn't build the foundation to dist/). Read
1996
- // the foundation's declared vars straight from its source config
1997
- // (main.js / foundation.js) so theme tokens like --section-padding-y are
1998
- // defined; without them, components using py-[var(--section-padding-y)] render
1999
- // with collapsed section spacing. Layouts aren't resolved from source (they
2000
- // need component discovery), but they aren't needed to build the theme CSS.
2053
+ let layoutNames = new Set()
2001
2054
  try {
2002
- const { resolveFoundationSrcPath } = await import('../utils/foundation-source-root.js')
2003
- const { loadFoundationConfig } = await import('../schema.js')
2004
- const srcDir = resolveFoundationSrcPath(foundationPath)
2005
- const config = await loadFoundationConfig(srcDir)
2006
- return { vars: config?.vars || {}, layoutNames: new Set() }
2055
+ layoutNames = new Set(Object.keys(await discoverLayoutsInPath(srcDir)))
2007
2056
  } catch (err) {
2008
- console.warn('[content-collector] Failed to load foundation source config:', err.message)
2009
- return { vars: {}, layoutNames: new Set() }
2057
+ console.warn(
2058
+ `[content-collector] Could not discover the foundation's layouts in ${srcDir}: ${err.message}\n` +
2059
+ `[content-collector] Continuing WITHOUT them — a site \`layout/<Name>/\` directory will be collected as an area of the default layout rather than as that named layout.`
2060
+ )
2010
2061
  }
2062
+
2063
+ return { vars, layoutNames }
2011
2064
  }
2012
2065
 
2013
2066
  /**
@@ -2151,6 +2204,11 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
2151
2204
  export async function collectSiteContent(sitePath, options = {}) {
2152
2205
  const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false, base = '/', strict = false } = options
2153
2206
 
2207
+ // Fresh accounting per collect — see `yamlFailures`. A dev rebuild runs this
2208
+ // function again, so without the reset a typo fixed three saves ago would
2209
+ // still be counted.
2210
+ yamlFailures = []
2211
+
2154
2212
  // Read site config and raw theme config
2155
2213
  const siteConfig = await readYamlFile(join(sitePath, configFile))
2156
2214
 
@@ -2407,6 +2465,43 @@ export async function collectSiteContent(sitePath, options = {}) {
2407
2465
  if (key.startsWith('$')) delete runtimeSiteConfig[key]
2408
2466
  }
2409
2467
 
2468
+ // ⛔ **A BUILD does not ship a site whose config failed to parse.**
2469
+ // `strict` is already this codebase's word for it — `build-site-data.js` sets
2470
+ // it, and `plugin.js` passes `strict: isProduction` — so the decision lands
2471
+ // where the flag already is, rather than in `parseYaml`, which cannot know.
2472
+ //
2473
+ // ⚠️ **`isProduction` is a misnomer worth not repeating: it is
2474
+ // `config.command === 'build'`** (`plugin.js:788`), i.e. a vite BUILD rather
2475
+ // than a dev server. It says nothing about deployment environments, which
2476
+ // backend a site talks to, or `NODE_ENV`. Both build lanes get it — `export`
2477
+ // and `deploy --host` through vite, `publish` through `build-site-data.js` —
2478
+ // and only `uniweb dev` does not.
2479
+ //
2480
+ // ⭐ Every bad file at once, not the first. Ending the collect at the first
2481
+ // failure would make an author with three typos fix one, rebuild, and meet the
2482
+ // next; the list is the difference between one round trip and three.
2483
+ //
2484
+ // ⚖️ **The dev server deliberately continues.** The author is mid-keystroke
2485
+ // and a half-typed `page.yml` must not blank their running site — they have
2486
+ // the per-file warning above, and the next save fixes it. The asymmetry is the
2487
+ // same one `plugin.js` already draws around the collect as a whole; this puts
2488
+ // the author's own files on the same footing as the machinery around them.
2489
+ // ⚠️ De-duplicated by source: several call sites legitimately read the same
2490
+ // file (a directory's `page.yml` is read once as its own config and again when
2491
+ // its parent resolves nesting), so the raw list counts one typo twice and
2492
+ // "2 files" would be a lie about the author's tree.
2493
+ const distinctFailures = [...new Map(yamlFailures.map((f) => [f.source, f])).values()]
2494
+ if (distinctFailures.length > 0 && strict) {
2495
+ const lines = distinctFailures.map((f) => ` ${f.source}\n ${f.message}`)
2496
+ throw new Error(
2497
+ `${distinctFailures.length} file${distinctFailures.length === 1 ? '' : 's'} could not be parsed as YAML:\n` +
2498
+ `${lines.join('\n')}\n\n` +
2499
+ ` Each one contributed NOTHING to this build — page order, nesting,\n` +
2500
+ ` sections:, data: and theme settings in these files were dropped.\n` +
2501
+ ` Fix them and rebuild; the dev server reports the same files without failing.`
2502
+ )
2503
+ }
2504
+
2410
2505
  return {
2411
2506
  config: {
2412
2507
  ...runtimeSiteConfig,
@@ -59,6 +59,7 @@ import { applyWhere, applyFilter, applySort } from './data-fetcher.js'
59
59
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
60
60
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
61
61
  import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
62
+ import { parseFrontmatter } from '../utils/frontmatter.js'
62
63
 
63
64
  // Try to import content-reader for markdown parsing
64
65
  let markdownToProseMirror
@@ -168,67 +169,6 @@ function parseQueryConfig(name, config) {
168
169
  }
169
170
  }
170
171
 
171
- /**
172
- * Parse YAML frontmatter from markdown content.
173
- *
174
- * Two cases, and keeping them apart is the whole point:
175
- *
176
- * NO frontmatter — the file does not open with `---`, or never closes the
177
- * block. Legitimate: a record can be pure body. Returns {}.
178
- *
179
- * DECLARED frontmatter that does not parse — an error, because every field
180
- * is gone at once. Not just the one with the typo: title, slug, date, image,
181
- * category, all of it. The record still builds, still ships, and lands at a
182
- * filename-derived slug with no title and no cover.
183
- *
184
- * ⛔ THIS USED TO WARN AND CONTINUE, and the warning could not be found.
185
- * Measured 2026-08-24 on a real post: an unquoted colon inside a description
186
- * ("...on a website framework: everything hard about docs...") voided six
187
- * fields and moved the page from /blog/docs-sites to /blog/11_docs_sites. The
188
- * only trace was
189
- *
190
- * [query-processor] YAML parse error: bad indentation of a mapping entry (4:72)
191
- *
192
- * on line 16 of 857 lines of build output, naming no file, nine lines above
193
- * "Processed articles: 6 items" — a success line that reads as everything
194
- * being fine. The build exited 0 and the broken record shipped.
195
- *
196
- * A parse error now names the file and says what it costs, because "which of
197
- * my 200 records is (4:72) in?" is the question the old message left you with.
198
- *
199
- * @param {string} raw - Raw file content
200
- * @param {string} [filepath] - Path to the file, for the error message
201
- * @returns {{ frontmatter: Object, body: string }}
202
- */
203
- function parseFrontmatter(raw, filepath) {
204
- if (!raw.trim().startsWith('---')) {
205
- return { frontmatter: {}, body: raw }
206
- }
207
-
208
- const parts = raw.split('---\n')
209
- if (parts.length < 3) {
210
- return { frontmatter: {}, body: raw }
211
- }
212
-
213
- try {
214
- const frontmatter = yaml.load(parts[1]) || {}
215
- const body = parts.slice(2).join('---\n')
216
- return { frontmatter, body }
217
- } catch (err) {
218
- const where = filepath ? `${filepath}: ` : ''
219
- throw new Error(
220
- `${where}frontmatter is not valid YAML — ${err.message}\n` +
221
- ` The file opens with \`---\`, so it is declaring frontmatter. Since the block does not\n` +
222
- ` parse, EVERY field in it is lost — title, slug, date, image, category — and the record\n` +
223
- ` would build as an untitled entry at a slug derived from its filename.\n` +
224
- ` A common cause is an unquoted value containing a colon followed by a space:\n` +
225
- ` description: Building on a framework: everything hard is a website problem\n` +
226
- ` Quote the value and it parses:\n` +
227
- ` description: "Building on a framework: everything hard is a website problem"`,
228
- { cause: err },
229
- )
230
- }
231
- }
232
172
 
233
173
  /**
234
174
  * Extract plain text from ProseMirror content
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Splitting YAML frontmatter from a markdown body. **The one implementation.**
3
+ *
4
+ * ## Why this file exists
5
+ *
6
+ * There were three, and they disagreed about the only thing that matters — what
7
+ * happens when the YAML does not parse. The success paths were character-for-
8
+ * character identical:
9
+ *
10
+ * `site/query-processor.js` threw, named the file, said what was lost, and
11
+ * gave the most common cause with its fix
12
+ * `uwx/entity-source.js` `catch {}` — silent, and returned `body: raw`
13
+ * `i18n/freeform.js` `catch {}` — silent
14
+ *
15
+ * ⛔ **The silent two were on the paths where it costs most.** `entity-source`
16
+ * feeds the sync lane, so a typo'd colon pushed to a backend with the record's
17
+ * frontmatter dropped *and* the broken block embedded in the body — because
18
+ * `body: raw` returns the text including the `---` fence. And `project-writer`'s
19
+ * `writeSectionFile` reads through it before writing back: with `frontmatter`
20
+ * empty, the `key in frontmatter` guard that protects a developer's reserved
21
+ * keys cannot fire, so a pull clobbers them and writes the broken block into the
22
+ * content. A malformed file was being converted into a corrupted one.
23
+ *
24
+ * ## It always throws, and that is the whole design
25
+ *
26
+ * A caller that genuinely wants tolerance says so with a `try`, which is
27
+ * explicit and local — `uwx/records-project.js`'s `readFileUuid` already does
28
+ * exactly that, because "this file does not parse" and "this file is not the one
29
+ * I am looking for" are the same answer to a best-effort probe. Every other
30
+ * caller was tolerant by accident.
31
+ *
32
+ * ## ⚠️ js-yaml, deliberately — do not "upgrade" this to `yaml`
33
+ *
34
+ * `@uniweb/build` carries both. The `yaml` package's Document API preserves
35
+ * comments through a round trip and is used in exactly one place —
36
+ * `site/deploy-config-writer.js` — because that file WRITES BACK to a config a
37
+ * human maintains and must not eat their comments. It is slower and its API is
38
+ * larger, so everything that only READS uses `js-yaml`. This is a read path.
39
+ */
40
+
41
+ import yaml from 'js-yaml'
42
+
43
+ /**
44
+ * Split YAML frontmatter from a markdown body.
45
+ *
46
+ * A file with no frontmatter yields an empty mapping and the whole text as
47
+ * body — that is not an error, it is a markdown file.
48
+ *
49
+ * @param {string} raw - the file's full text
50
+ * @param {string} [filepath] - where it came from. Optional only because a
51
+ * caller may genuinely not have one; pass it whenever you do, since the file
52
+ * name is the difference between a fixable error and a hunt.
53
+ * @returns {{ frontmatter: object, body: string }}
54
+ * @throws {Error} when a `---` block is present and does not parse as YAML
55
+ */
56
+ export function parseFrontmatter(raw, filepath) {
57
+ const text = raw ?? ''
58
+ if (!text.trimStart().startsWith('---')) {
59
+ return { frontmatter: {}, body: text }
60
+ }
61
+
62
+ const parts = text.split('---\n')
63
+ if (parts.length < 3) {
64
+ return { frontmatter: {}, body: text }
65
+ }
66
+
67
+ try {
68
+ const frontmatter = yaml.load(parts[1]) || {}
69
+ const body = parts.slice(2).join('---\n')
70
+ return { frontmatter, body }
71
+ } catch (err) {
72
+ const where = filepath ? `${filepath}: ` : ''
73
+ throw new Error(
74
+ `${where}frontmatter is not valid YAML — ${err.message}\n` +
75
+ ` The file opens with \`---\`, so it is declaring frontmatter. Since the block does not\n` +
76
+ ` parse, EVERY field in it is lost — title, slug, date, image, category — and the record\n` +
77
+ ` would build as an untitled entry at a slug derived from its filename.\n` +
78
+ ` A common cause is an unquoted value containing a colon followed by a space:\n` +
79
+ ` description: Building on a framework: everything hard is a website problem\n` +
80
+ ` Quote the value and it parses:\n` +
81
+ ` description: "Building on a framework: everything hard is a website problem"`,
82
+ { cause: err },
83
+ )
84
+ }
85
+ }
@@ -94,7 +94,7 @@ export function backfillUuid(filePath, uuid) {
94
94
  }
95
95
  next = yaml.dump(withUuidFirst(obj && typeof obj === 'object' ? obj : {}, uuid))
96
96
  } else if (ext === '.md') {
97
- const { frontmatter, body } = parseFrontmatter(text)
97
+ const { frontmatter, body } = parseFrontmatter(text, filePath)
98
98
  next = `---\n${yaml.dump(withUuidFirst(frontmatter, uuid))}---\n${body}`
99
99
  } else {
100
100
  return { status: 'deferred', message: `${ext || '(no extension)'} back-fill is not yet implemented` }
@@ -27,31 +27,19 @@ import { parseBibtex } from '@citestyle/bibtex'
27
27
 
28
28
  const SOURCE_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
29
29
 
30
- /**
31
- * Split YAML frontmatter from a markdown body. Mirrors the collection
32
- * processor's split (`---\n` delimited) so a record read here re-renders to the
33
- * same shape the back-fill writer produces. A file with no frontmatter yields an
34
- * empty mapping and the whole text as body.
35
- *
36
- * @param {string} raw
37
- * @returns {{ frontmatter: object, body: string }}
38
- */
39
- export function parseFrontmatter(raw) {
40
- if (!raw.trimStart().startsWith('---')) {
41
- return { frontmatter: {}, body: raw }
42
- }
43
- const parts = raw.split('---\n')
44
- if (parts.length < 3) {
45
- return { frontmatter: {}, body: raw }
46
- }
47
- try {
48
- const frontmatter = yaml.load(parts[1]) || {}
49
- const body = parts.slice(2).join('---\n')
50
- return { frontmatter, body }
51
- } catch {
52
- return { frontmatter: {}, body: raw }
53
- }
54
- }
30
+ // ⭐ `parseFrontmatter` is re-exported, not reimplemented. This file had its own
31
+ // copy whose `catch {}` returned `{ frontmatter: {}, body: raw }` — silent, and
32
+ // with the unparsed `---` block left in the BODY, so a typo on this lane pushed
33
+ // a record to a backend with its fields dropped and its own broken frontmatter
34
+ // embedded as content. The shared version throws and names the file; a caller
35
+ // that wants tolerance says so with a `try` (see `records-project.js`'s
36
+ // `readFileUuid`, which correctly treats "does not parse" as "not a match").
37
+ // Imported AND re-exported: this module calls `parseFrontmatter` itself
38
+ // (below), and a bare `export … from` creates NO local binding — the same
39
+ // trap `core/src/index.js` carries a note about. It fails at runtime, not at
40
+ // import time, so the suite is what catches it.
41
+ import { parseFrontmatter } from '../utils/frontmatter.js'
42
+ export { parseFrontmatter }
55
43
 
56
44
  // Format key from a file extension. Single source of the format vocabulary the
57
45
  // reader emits and the writer dispatches on.
@@ -86,7 +74,7 @@ async function readOneFile(filepath) {
86
74
  const raw = await readFile(filepath, 'utf-8')
87
75
 
88
76
  if (format === 'md') {
89
- const { frontmatter, body } = parseFrontmatter(raw)
77
+ const { frontmatter, body } = parseFrontmatter(raw, filepath)
90
78
  const slug = frontmatter.slug || slugFromName
91
79
  return [{ slug, format, data: frontmatter, body, sourceFile: filepath, multiRecord: false }]
92
80
  }
@@ -165,7 +165,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
165
165
  } catch {
166
166
  // new file
167
167
  }
168
- const { frontmatter, body: existingBody } = parseFrontmatter(existing)
168
+ const { frontmatter, body: existingBody } = parseFrontmatter(existing, filePath)
169
169
 
170
170
  const nextFrontmatter = { ...frontmatter }
171
171
  if (params) {
@@ -63,7 +63,7 @@ function readFileUuid(filePath, format) {
63
63
  return null
64
64
  }
65
65
  try {
66
- if (format === 'md') return parseFrontmatter(raw).frontmatter?.$uuid ?? null
66
+ if (format === 'md') return parseFrontmatter(raw, filePath).frontmatter?.$uuid ?? null
67
67
  const parsed = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
68
68
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
69
69
  return parsed.$uuid ?? null