@uniweb/build 1.10.1 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,9 +58,9 @@
58
58
  "yaml": "^2.5.0",
59
59
  "@uniweb/content-reader": "~1.5.7",
60
60
  "@uniweb/content-writer": "~1.3.1",
61
- "@uniweb/projections": "~1.2.0",
62
- "@uniweb/semantic-parser": "~1.13.0",
61
+ "@uniweb/projections": "~1.2.1",
63
62
  "@uniweb/schemas": "~1.6.5",
63
+ "@uniweb/semantic-parser": "~1.13.0",
64
64
  "@uniweb/theming": "~1.2.1"
65
65
  },
66
66
  "optionalDependencies": {
package/src/prerender.js CHANGED
@@ -34,7 +34,7 @@ import { getAdapter } from './hosts/index.js'
34
34
  import { detectCiContext } from './hosts/detect-ci-context.js'
35
35
  import { stripBasePath } from './site/extension-urls.js'
36
36
  import { isSiteRelative, resolveExtensionDist } from './site/emit-extensions.js'
37
- import { crawlerFiles, sitemapEntry } from './site/seo-files.js'
37
+ import { crawlerFiles, sitemapEntriesOf } from './site/seo-files.js'
38
38
  import { absoluteUrl } from '@uniweb/core/site-address'
39
39
 
40
40
  /**
@@ -879,7 +879,7 @@ export async function prerenderSite(siteDir, options = {}) {
879
879
 
880
880
  // Pre-render each locale
881
881
  const renderedFiles = []
882
- // Each written page's sitemap entry, every locale's: the sitemap lists what this build serves.
882
+ // Each locale's sitemap entries, for the pages it wrote: the sitemap lists what this build serves.
883
883
  const sitemapEntries = []
884
884
  // A redirect stub names its target as the target's own pages do: absolutely, on the site's address.
885
885
  const stubCanonical = (target) => absoluteUrl(defaultSiteContent.config?.url, target) || target
@@ -908,6 +908,10 @@ export async function prerenderSite(siteDir, options = {}) {
908
908
  // Store fetchedData on siteContent for runtime DataStore pre-population
909
909
  siteContent.fetchedData = fetchedData
910
910
 
911
+ // The pages as authored, each parametric page its template: what the sitemap reads, with the
912
+ // record pages written from it (`./site/seo-files.js`).
913
+ const authoredPages = siteContent.pages
914
+
911
915
  // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
912
916
  if (siteContent.pages?.some(p => p.isDynamic)) {
913
917
  onProgress('Expanding dynamic routes...')
@@ -1010,6 +1014,8 @@ export async function prerenderSite(siteDir, options = {}) {
1010
1014
  * boot (measured).
1011
1015
  */
1012
1016
 
1017
+ // The route of each page written, as its head states it (`Page#getNavRoute`).
1018
+ const written = new Set()
1013
1019
  for (const page of website.pages) {
1014
1020
  // Skip dynamic template pages — they exist in the content for runtime
1015
1021
  // route matching but can't be pre-rendered (no concrete route)
@@ -1163,10 +1169,15 @@ export async function prerenderSite(siteDir, options = {}) {
1163
1169
  renderedFiles.push(outputPath)
1164
1170
  onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
1165
1171
  // The page whose head was written — a content-less folder renders its index child.
1166
- const entry = sitemapEntry(result.page || page)
1167
- if (entry) sitemapEntries.push(entry)
1172
+ written.add((result.page || page).getNavRoute())
1168
1173
  }
1169
1174
 
1175
+ sitemapEntries.push(...sitemapEntriesOf({ ...siteContent, pages: authoredPages }, {
1176
+ locale,
1177
+ records: (siteContent.pages || []).filter((page) => page.dynamicContext),
1178
+ written,
1179
+ }))
1180
+
1170
1181
  // Write 404.html — shared logic from @uniweb/runtime/ssr
1171
1182
  const fallbackBaseHtml = injectBuildData(htmlShell, siteContent, {
1172
1183
  splitContent,
@@ -1212,12 +1223,7 @@ export async function prerenderSite(siteDir, options = {}) {
1212
1223
 
1213
1224
  // ⭐ The crawler files, from the pages just written (`./site/seo-files.js`). A site's own
1214
1225
  // `public/robots.txt` or `public/sitemap.xml` is its author's, and is left as it is.
1215
- const crawl = crawlerFiles({
1216
- address: defaultSiteContent.config?.url || null,
1217
- base: defaultSiteContent.config?.base || '/',
1218
- entries: sitemapEntries,
1219
- robots: defaultSiteContent.config?.seo?.robots,
1220
- })
1226
+ const crawl = crawlerFiles({ content: defaultSiteContent, entries: sitemapEntries })
1221
1227
  for (const [file, body] of [['sitemap.xml', crawl.sitemap], ['robots.txt', crawl.robots]]) {
1222
1228
  if (!body) continue
1223
1229
  if (existsSync(join(siteDir, 'public', file))) {
@@ -297,3 +297,54 @@ export async function forgetDeployYml(siteDir) {
297
297
  await unlink(path)
298
298
  return names
299
299
  }
300
+
301
+ /**
302
+ * Move a deploy.yml off the keys retired on 2026-09-20 — `lastDeploy:` to `deploys:`, and
303
+ * `autoSave:` to `saveDeploys:` — keeping its comments.
304
+ *
305
+ * ⛔ NOTHING READS THE OLD KEYS, and a renamed key returns empty rather than failing: a
306
+ * target's record under `lastDeploy:` is a deploy nothing can see, and `autoSave: off` is
307
+ * ignored, so deploys are recorded after all. `uniweb doctor` names both, and this is its fix.
308
+ *
309
+ * A target's record moves to `deploys:` when that target has none there; when it has, the
310
+ * one in `deploys:` is newer, and the old is dropped. `autoSave: off` becomes
311
+ * `saveDeploys: false`; any other value is the default, and goes.
312
+ *
313
+ * @param {string} siteDir
314
+ * @returns {Promise<{ moved: string[], dropped: string[], saveDeploys: boolean|null } | null>}
315
+ * null when the file has neither key, or there is no file
316
+ */
317
+ export async function retireDeployYmlKeys(siteDir) {
318
+ const path = join(siteDir, 'deploy.yml')
319
+ if (!existsSync(path)) return null
320
+ const doc = parseDocument(await readFile(path, 'utf8'))
321
+ if (doc.errors.length) throw new Error(`${path}: ${doc.errors[0].message}`)
322
+ const old = doc.get('lastDeploy')
323
+ const autoSave = doc.get('autoSave')
324
+ if (old === undefined && autoSave === undefined) return null
325
+ const moved = []
326
+ const dropped = []
327
+ if (isMap(old)) {
328
+ if (!isMap(doc.get('deploys'))) doc.set('deploys', doc.createNode({}))
329
+ const deploys = doc.get('deploys')
330
+ for (const pair of old.items) {
331
+ const name = String(pair.key?.value ?? pair.key)
332
+ if (deploys.has(name)) dropped.push(name)
333
+ else {
334
+ deploys.set(name, pair.value)
335
+ moved.push(name)
336
+ }
337
+ }
338
+ }
339
+ doc.delete('lastDeploy')
340
+ let saveDeploys = null
341
+ if (autoSave !== undefined) {
342
+ if (String(autoSave) === 'off' && !doc.has('saveDeploys')) {
343
+ doc.set('saveDeploys', false)
344
+ saveDeploys = false
345
+ }
346
+ doc.delete('autoSave')
347
+ }
348
+ await writeFile(path, String(doc))
349
+ return { moved, dropped, saveDeploys }
350
+ }
package/src/site/index.js CHANGED
@@ -57,4 +57,4 @@ export {
57
57
  mergeDataIntoContent
58
58
  } from './data-fetcher.js'
59
59
  export { loadDeployYml, resolveTarget, resolvePublishTarget } from './deploy-config.js'
60
- export { recordLastDeploy, recordTarget, forgetDeploys, forgetDeployYml } from './deploy-config-writer.js'
60
+ export { recordLastDeploy, recordTarget, forgetDeploys, forgetDeployYml, retireDeployYmlKeys } from './deploy-config-writer.js'
@@ -1,171 +1,56 @@
1
1
  /**
2
2
  * `sitemap.xml` and `robots.txt` — the two files a crawler reads about a statically served site,
3
- * written by the prerender from the pages it wrote.
3
+ * written by the prerender once it has written the pages.
4
4
  *
5
- * ⭐ THE SITEMAP LISTS THE PAGES A STATIC HOST WILL ANSWER WITH, BY THE URLS THEY STATE. An entry is
6
- * a page the prerender wrote — the static ones and each record's own — at its canonical URL with its
7
- * alternates, read off `Page#getHeadMeta`, the source its `<head>` was written from, so the sitemap
8
- * and the page cannot disagree. ⛔ Until 2026-10-25 the plugin wrote it at bundle time, before
9
- * parametric pages were expanded: it listed `/books/:slug` and no book, at URLs that left the base
10
- * path out.
5
+ * ⭐ BOTH ARE `@uniweb/projections`', made from the site's content alone (`sitemapEntries`,
6
+ * `renderSitemap`, `renderRobotsTxt`), so a host serving the site from its content serves the same
7
+ * files. A page's entry is the canonical and the alternates its `<head>` states, by the rules
8
+ * `Page#getHeadMeta` states them with (guarded by projections' `tests/sitemap-heads.test.js`).
9
+ * ⛔ Until 2026-10-25 the build read each entry off the rendered page instead, and until earlier
10
+ * that day the plugin wrote the sitemap at bundle time, before parametric pages were expanded: it
11
+ * listed `/books/:slug` and no book, at URLs that left the base path out.
11
12
  *
12
- * ⭐ `robots.txt` IS READ ONLY AT A HOST'S ROOT. A site served under a base path — a GitHub project
13
- * site — cannot have one; it is not written there, and the build says so, rather than shipping a file
14
- * no crawler reads that points at a sitemap where none is.
15
- */
16
-
17
- /** The three defined Content Signals, in the order they are emitted. */
18
- const CONTENT_SIGNAL_KEYS = ['search', 'ai-input', 'ai-train']
19
-
20
- /**
21
- * Format the `Content-Signal:` directive from `seo.robots.contentSignals`.
22
- *
23
- * Content Signals express what a site permits its content to be *used for*,
24
- * which is a different axis from `Disallow:` — that governs fetching, this
25
- * governs use after fetching. The three defined signals:
26
- *
27
- * - `search` — appear in search results
28
- * - `ai-input` — be retrieved at inference time (RAG, grounding)
29
- * - `ai-train` — be used to train a model
30
- *
31
- * **Emitted only when declared.** There is no default: a preference the site
32
- * owner did not state is not ours to assert, in either direction. An absent
33
- * signal means "unstated", which is not the same as `no`.
34
- *
35
- * Unknown keys are ignored rather than passed through — the vocabulary is a
36
- * closed set, and forwarding an invented signal would produce a directive no
37
- * crawler honors while reading as though it were doing something.
38
- *
39
- * @param {Object|null} signals - e.g. `{ search: true, 'ai-input': true, 'ai-train': false }`
40
- * @returns {string} The directive line, or `''` when nothing is declared
41
- */
42
- export function formatContentSignals(signals) {
43
- if (!signals || typeof signals !== 'object') return ''
44
-
45
- const parts = []
46
- for (const key of CONTENT_SIGNAL_KEYS) {
47
- if (!(key in signals)) continue
48
- const value = signals[key]
49
- const yes = value === true || value === 'yes'
50
- const no = value === false || value === 'no'
51
- if (!yes && !no) continue
52
- parts.push(`${key}=${yes ? 'yes' : 'no'}`)
53
- }
54
-
55
- return parts.length ? `Content-Signal: ${parts.join(', ')}` : ''
56
- }
57
-
58
- /**
59
- * `robots.txt` — the site's rules, and where its sitemap is.
13
+ * ⭐ THE SITEMAP LISTS THE PAGES A STATIC HOST WILL ANSWER WITH: the pages this build wrote, each
14
+ * record's own among them. A page it wrote no file for — one whose render failed — is answered with
15
+ * the not-found page, so it is left out, where a host that renders on request lists it.
60
16
  *
61
- * @param {Object} [robots] - `seo.robots`: `allow`, `disallow`, `crawlDelay`, `additionalSitemaps`,
62
- * `contentSignals`
63
- * @param {Object} [options]
64
- * @param {string|null} [options.sitemap] - the sitemap's absolute URL, when there is one
65
- * @returns {string}
17
+ * ⭐ A site served under a base path — a GitHub project site — gets no `robots.txt`, since a crawler
18
+ * reads it only at a host's root, and the build says so.
66
19
  */
67
- export function generateRobotsTxt(robots = {}, { sitemap = null } = {}) {
68
- const { disallow = [], allow = [], crawlDelay = null, additionalSitemaps = [], contentSignals = null } = robots || {}
69
20
 
70
- let content = 'User-agent: *\n'
71
-
72
- const signals = formatContentSignals(contentSignals)
73
- if (signals) {
74
- // Inside the User-agent group, before the rules — the directive applies to
75
- // the group it sits in.
76
- content += `${signals}\n`
77
- }
78
- for (const path of allow) content += `Allow: ${path}\n`
79
- for (const path of disallow) content += `Disallow: ${path}\n`
80
- if (crawlDelay) content += `Crawl-delay: ${crawlDelay}\n`
81
-
82
- const sitemaps = [sitemap, ...additionalSitemaps].filter(Boolean)
83
- if (sitemaps.length) content += `\n${sitemaps.map((url) => `Sitemap: ${url}`).join('\n')}\n`
84
- return content
85
- }
86
-
87
- /** Whether `seo.robots` says anything a crawler would act on. */
88
- function hasRules(robots) {
89
- if (!robots || typeof robots !== 'object') return false
90
- return Boolean(
91
- robots.allow?.length ||
92
- robots.disallow?.length ||
93
- robots.crawlDelay ||
94
- robots.additionalSitemaps?.length ||
95
- formatContentSignals(robots.contentSignals)
96
- )
97
- }
98
-
99
- /**
100
- * A rendered page's sitemap entry — its canonical URL, with its alternates — or null when it is not
101
- * one to list: kept out of search (`noindex`), naming another URL as its canonical, or on a site
102
- * whose address is unknown.
103
- *
104
- * @param {Object} page - a Page of the Website the prerender rendered it from
105
- * @returns {{ loc: string, alternates: Array, lastmod: string|null, changefreq: string|null, priority: (string|number)|null }|null}
106
- */
107
- export function sitemapEntry(page) {
108
- const meta = page?.getHeadMeta?.()
109
- if (!meta?.canonical || meta.robots?.includes('noindex')) return null
110
- const own = page.website?.getPageUrl?.(page)
111
- if (!own || meta.canonical !== own) return null
112
- return {
113
- loc: own,
114
- alternates: meta.alternates || [],
115
- // A record's page has no file of its own, so no date of its own: none is better than its template's.
116
- lastmod: !page.dynamicContext && page.lastModified ? String(page.lastModified).split('T')[0] : null,
117
- changefreq: page.seo?.changefreq || null,
118
- priority: page.seo?.priority ?? null,
119
- }
120
- }
121
-
122
- /** Escape a value for XML text or a double-quoted attribute. */
123
- function escapeXml(str) {
124
- return String(str)
125
- .replace(/&/g, '&amp;')
126
- .replace(/</g, '&lt;')
127
- .replace(/>/g, '&gt;')
128
- .replace(/"/g, '&quot;')
129
- .replace(/'/g, '&apos;')
130
- }
21
+ import { renderRobotsTxt, renderSitemap, sitemapEntries } from '@uniweb/projections'
131
22
 
132
23
  /**
133
- * `sitemap.xml` for entries (`sitemapEntry`), each with its alternates when the site has several
134
- * locales.
24
+ * One language's sitemap entries, for the pages this build wrote: `sitemapEntries` for its content
25
+ * and its record pages, less any page the build wrote no file for.
135
26
  *
136
- * @param {Array} entries
137
- * @returns {string}
27
+ * @param {Object} content - the language's content, as authored: each parametric page its template
28
+ * @param {Object} options
29
+ * @param {string} options.locale - the language
30
+ * @param {Object[]} [options.records] - its record pages, as expanded (`expandDynamicPages`)
31
+ * @param {Set<string>} options.written - the route of each page written, as its head states it
32
+ * (`Page#getNavRoute`)
33
+ * @returns {Array} `sitemapEntries` results
138
34
  */
139
- export function renderSitemap(entries) {
140
- const alternated = entries.some((entry) => entry.alternates?.length)
141
- const urls = entries.map((entry) => {
142
- const lines = [` <loc>${escapeXml(entry.loc)}</loc>`]
143
- if (entry.lastmod) lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`)
144
- if (entry.changefreq) lines.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`)
145
- if (entry.priority !== null && entry.priority !== undefined) lines.push(` <priority>${escapeXml(entry.priority)}</priority>`)
146
- for (const { hreflang, url } of entry.alternates || []) {
147
- lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(hreflang)}" href="${escapeXml(url)}" />`)
148
- }
149
- return ` <url>\n${lines.join('\n')}\n </url>`
150
- })
151
- const xmlns = alternated ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
152
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xmlns}>\n${urls.join('\n')}\n</urlset>\n`
35
+ export function sitemapEntriesOf(content, { locale, records = [], written }) {
36
+ const entries = sitemapEntries(content, { locale, records: records.map((page) => ({ type: 'record', route: page.route })) })
37
+ return entries.filter((entry) => written.has(entry.route))
153
38
  }
154
39
 
155
40
  /**
156
41
  * The site's crawler files, for what this build serves: the sitemap when the site's address is
157
- * known and it has pages to list, `robots.txt` when the site is at its host's root and has rules or a
158
- * sitemap to name.
42
+ * known and it has pages to list, and `robots.txt` (`renderRobotsTxt`), naming that sitemap.
159
43
  *
160
44
  * @param {Object} options
161
- * @param {string|null} options.address - the site's address (`config.url`)
162
- * @param {string} [options.base] - the base the site is built for
163
- * @param {Array} options.entries - `sitemapEntry` results
164
- * @param {Object} [options.robots] - `seo.robots`
45
+ * @param {Object} options.content - the site's content: its address (`config.url`), the base it is
46
+ * built for (`config.base`) and its rules (`config.seo.robots`)
47
+ * @param {Array} options.entries - every language's entries (`sitemapEntriesOf`)
165
48
  * @returns {{ sitemap: string|null, robots: string|null, notes: string[] }} what to write, and why
166
49
  * anything was not
167
50
  */
168
- export function crawlerFiles({ address, base = '/', entries = [], robots = {} }) {
51
+ export function crawlerFiles({ content, entries = [] }) {
52
+ const config = content?.config || {}
53
+ const address = config.url || null
169
54
  const notes = []
170
55
  let sitemap = null
171
56
  if (!address) {
@@ -175,16 +60,14 @@ export function crawlerFiles({ address, base = '/', entries = [], robots = {} })
175
60
  } else {
176
61
  sitemap = renderSitemap(entries)
177
62
  }
178
- const atRoot = !base || base === '/'
179
- const sitemapUrl = sitemap ? new URL('sitemap.xml', address).href : null
180
- let robotsTxt = null
181
- if (atRoot) {
182
- if (sitemapUrl || hasRules(robots)) robotsTxt = generateRobotsTxt(robots, { sitemap: sitemapUrl })
183
- } else {
63
+ const robots = renderRobotsTxt(content, { sitemap: Boolean(sitemap) })
64
+ const base = config.base || '/'
65
+ if (base !== '/') {
66
+ const sitemapUrl = sitemap ? new URL('sitemap.xml', address).href : null
184
67
  notes.push(
185
68
  `No robots.txt: crawlers read it only at a host's root, and this site is served under ${base}.` +
186
69
  (sitemapUrl ? ` Submit ${sitemapUrl} to search engines directly.` : '')
187
70
  )
188
71
  }
189
- return { sitemap, robots: robotsTxt, notes }
72
+ return { sitemap, robots, notes }
190
73
  }
@@ -4,13 +4,19 @@
4
4
  // `$id` with no `$uuid` on first sync; the backend mints `$uuid` and returns it;
5
5
  // this writes it into the record in its source file (docs/reference/entity-content.md).
6
6
  //
7
- // Write-back RE-RENDERS the parsed record with `$uuid` as the leading key, rather
8
- // than surgically patching the text. It does not preserve comments or incidental
9
- // formatting (accepted) — in exchange it is one uniform path across YAML, JSON, and
10
- // markdown frontmatter, and it converges to a fixpoint: a pristine file gains only
11
- // a `$uuid`, and a no-op re-sync is byte-identical (render(parse(x)) == x once
12
- // canonical). It reads from the SOURCE file (not the backend's finalized document),
13
- // so field values round-trip untouched and no inverse decode is needed in v1.
7
+ // ⭐ Write-back ADDS ONE LINE: `$uuid` as the record's leading key, in a YAML file or a
8
+ // markdown file's frontmatter (`yaml-edit.js::setYamlKeyFirst`), and changes nothing else
9
+ // the author wrote — comments, quotes, blank lines, line breaks, `[a, b]` lists. A re-sync
10
+ // with the same `$uuid` is byte-identical. It reads from the SOURCE file (not the backend's
11
+ // finalized document), so field values round-trip untouched. A file that cannot be edited
12
+ // in place (an alias, a flow-style map) is re-rendered whole and the caller is warned; JSON,
13
+ // which holds no comments, is re-rendered as before.
14
+ //
15
+ // ⛔ Until 2026-10-25 every file was re-rendered whole, which the first push of a site's
16
+ // records made into a commit of every record file: on a site's first publish, measured on
17
+ // 16 hand-written records, every comment was deleted, every long line refolded, every
18
+ // `[a, b]` list exploded and every blank line between keys removed — the price of "one
19
+ // uniform path", written down as accepted before `yaml-edit.js` could edit in place.
14
20
  //
15
21
  // ⛔ A PUSH NEVER RENDERS THE BACKEND'S DOCUMENT OVER THE AUTHOR'S FILE. It did, for
16
22
  // a record's first push, from 2026-05-29 to 2026-09-23 ("variant A"), and that
@@ -34,6 +40,7 @@ import yaml from 'js-yaml'
34
40
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
35
41
  import { proseMirrorToMarkdown } from '@uniweb/content-writer'
36
42
  import { parseFrontmatter } from './entity-source.js'
43
+ import { setYamlKeyFirst } from './yaml-edit.js'
37
44
  import { isProseMirrorField, isOpenMapSection, openMapFromRows } from './data-schema.js'
38
45
  import { recordFileLayout, contentBodyTarget } from './record-layout.js'
39
46
  import { unwrapLocalizedContent } from './locale-sync.js'
@@ -63,13 +70,16 @@ function withUuidFirst(obj, uuid) {
63
70
  }
64
71
 
65
72
  /**
66
- * Insert `$uuid` into a single-record source file by re-rendering the parsed
67
- * record with `$uuid` leading. Idempotent — if the render equals the file's
68
- * current bytes, nothing is written.
73
+ * Insert `$uuid` into a single-record source file as its leading key, editing nothing else
74
+ * in a YAML file or a markdown file's frontmatter (`setYamlKeyFirst`): an author's comments,
75
+ * quotes, blank lines and line breaks stay as written. A file that cannot be edited in place
76
+ * (an alias, a flow-style map) is re-rendered whole, and the result says so. Idempotent: the
77
+ * same `$uuid` leaves the file untouched.
69
78
  *
70
79
  * @param {string} filePath
71
80
  * @param {string} uuid
72
- * @returns {{ status: 'updated'|'unchanged'|'deferred'|'error', message?: string }}
81
+ * @returns {{ status: 'updated'|'unchanged'|'deferred'|'error', message?: string, warning?: string }}
82
+ * `warning` when the file was re-rendered whole
73
83
  */
74
84
  export function backfillUuid(filePath, uuid) {
75
85
  const dot = filePath.lastIndexOf('.')
@@ -83,6 +93,7 @@ export function backfillUuid(filePath, uuid) {
83
93
  }
84
94
 
85
95
  let next
96
+ let rewritten = false
86
97
  if (ext === '.json') {
87
98
  let obj
88
99
  try {
@@ -104,17 +115,32 @@ export function backfillUuid(filePath, uuid) {
104
115
  if (Array.isArray(obj)) {
105
116
  return { status: 'deferred', message: 'array-form YAML (many records) not handled in v1' }
106
117
  }
107
- next = yaml.dump(withUuidFirst(obj && typeof obj === 'object' ? obj : {}, uuid))
118
+ next = setYamlKeyFirst(text, '$uuid', uuid)
119
+ if (next === null) {
120
+ next = yaml.dump(withUuidFirst(obj && typeof obj === 'object' ? obj : {}, uuid))
121
+ rewritten = true
122
+ }
108
123
  } else if (ext === '.md') {
109
124
  const { frontmatter, body } = parseFrontmatter(text, filePath)
110
- next = `---\n${yaml.dump(withUuidFirst(frontmatter, uuid))}---\n${body}`
125
+ // Split as `parseFrontmatter` splits, so the frontmatter edited is the one it reads and
126
+ // everything around it stays byte for byte.
127
+ const parts = text.split('---\n')
128
+ const edited = text.trimStart().startsWith('---') && parts.length >= 3 ? setYamlKeyFirst(parts[1], '$uuid', uuid) : null
129
+ if (edited !== null) next = [parts[0], edited, ...parts.slice(2)].join('---\n')
130
+ else {
131
+ next = `---\n${yaml.dump(withUuidFirst(frontmatter, uuid))}---\n${body}`
132
+ // A file with no frontmatter gains one, which loses nothing.
133
+ rewritten = text.trimStart().startsWith('---')
134
+ }
111
135
  } else {
112
136
  return { status: 'deferred', message: `${ext || '(no extension)'} back-fill is not yet implemented` }
113
137
  }
114
138
 
115
139
  if (next === text) return { status: 'unchanged' }
116
140
  writeFileSync(filePath, next)
117
- return { status: 'updated' }
141
+ return rewritten
142
+ ? { status: 'updated', warning: 'rewritten whole to record its $uuid — it could not be edited in place, so its comments and layout are gone' }
143
+ : { status: 'updated' }
118
144
  }
119
145
 
120
146
  /**
@@ -473,6 +499,7 @@ export function backfillEntityUuids({ index, finalized }) {
473
499
 
474
500
  // The uuid, into the author's own file — never the returned document (header ⛔).
475
501
  const res = backfillUuid(entry.sourceFile, uuid)
502
+ if (res.warning) warnings.push(`${entry.sourceFile}: ${res.warning}`)
476
503
  if (res.status === 'updated') updated.push(entry.sourceFile)
477
504
  else if (res.status === 'unchanged') unchanged.push(entry.sourceFile)
478
505
  else if (res.status === 'deferred') deferred.push({ index: i, id: entry.id, file: entry.sourceFile, reason: res.message })
@@ -132,6 +132,7 @@ export function backfillLinkUuids({ links = [], folderDoc }) {
132
132
  warnings.push(`${link.sourceFile}: ${res.message}`)
133
133
  continue
134
134
  }
135
+ if (res.warning) warnings.push(`${link.sourceFile}: ${res.warning}`)
135
136
  if (res.status === 'updated') updated.push(link.sourceFile)
136
137
  mapped[item.$uuid] = item.$uuid
137
138
  }
@@ -289,6 +289,47 @@ export function setYamlKey(text, key, value) {
289
289
  return before ? editYamlText(text, before, { ...before, [key]: value }) : null
290
290
  }
291
291
 
292
+ /**
293
+ * Set top-level `key` to `value` as the map's FIRST key — a line of its own before the first
294
+ * key, so after any comments that head the file — editing nothing else. A key already there is
295
+ * set where it stands (`editYamlText`).
296
+ *
297
+ * For an identity a sync writes into a file an author wrote (`$uuid`, `backfill.js`), which leads
298
+ * a record by convention, as a record a pull writes has it.
299
+ *
300
+ * @param {string} text - the file's contents (a record's frontmatter, for a markdown record)
301
+ * @param {string} key
302
+ * @param {*} value
303
+ * @returns {string|null} the new contents; null when the text cannot be edited in place — not a
304
+ * block map, an alias or merge key, a parse error, or a result that reads differently
305
+ */
306
+ export function setYamlKeyFirst(text, key, value) {
307
+ const before = readMap(text)
308
+ if (!before) return null
309
+ if (Object.hasOwn(before, key)) return editYamlText(text, before, { ...before, [key]: value })
310
+ let doc
311
+ try {
312
+ doc = parseDocument(text)
313
+ } catch {
314
+ return null
315
+ }
316
+ if (doc.errors.length || usesReferences(doc)) return null
317
+ const line = yaml.dump({ [key]: value }, YAML_DUMP_OPTS)
318
+ let out
319
+ if (doc.contents === null || doc.contents === undefined) {
320
+ // Comments and nothing else: the key goes after them.
321
+ out = text + (text && !text.endsWith('\n') ? '\n' : '') + line
322
+ } else {
323
+ if (!isMap(doc.contents) || doc.contents.flow || !doc.contents.items[0]?.key?.range) return null
324
+ const first = doc.contents.items[0].key
325
+ if (columnOf(text, first) !== 0) return null
326
+ const at = lineStart(text, first.range[0])
327
+ out = text.slice(0, at) + line + text.slice(at)
328
+ }
329
+ const read = readMap(out)
330
+ return read && sameValue(read, { [key]: value, ...before }) ? out : null
331
+ }
332
+
292
333
  /**
293
334
  * Replace entries of the top-level list `key` — each `from` entry becomes its `to` — where
294
335
  * they stand, in the quotes they were written in. An entry that only CONTAINS `from` is