@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 +5 -5
- package/src/i18n/freeform.js +3 -25
- package/src/site/config.js +25 -1
- package/src/site/content-collector.js +143 -48
- package/src/site/query-processor.js +1 -61
- package/src/utils/frontmatter.js +85 -0
- package/src/uwx/backfill.js +1 -1
- package/src/uwx/entity-source.js +14 -26
- package/src/uwx/project-writer.js +1 -1
- package/src/uwx/records-project.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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/
|
|
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.
|
|
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.
|
|
79
|
+
"@uniweb/core": "^0.16.0"
|
|
80
80
|
},
|
|
81
81
|
"peerDependenciesMeta": {
|
|
82
82
|
"vite": {
|
package/src/i18n/freeform.js
CHANGED
|
@@ -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
|
package/src/site/config.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
//
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2009
|
-
|
|
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
|
+
}
|
package/src/uwx/backfill.js
CHANGED
|
@@ -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` }
|
package/src/uwx/entity-source.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|