@uniweb/build 0.79.0 → 0.80.1
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 +7 -7
- package/src/foundation/default-layout.js +52 -0
- package/src/generate-entry.js +11 -0
- package/src/schema.js +8 -3
- package/src/site/content-collector.js +12 -8
- package/src/site/queries-config.js +59 -4
- package/src/site/query-processor.js +40 -32
- package/src/uwx/data-key-types.js +4 -2
- package/src/uwx/index.js +3 -0
- package/src/uwx/queries-config.js +0 -1
- package/src/uwx/records-project.js +25 -18
- package/src/uwx/site-diff.js +6 -2
- package/src/uwx/site-project.js +11 -28
- package/src/uwx/yaml-edit.js +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.1",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/
|
|
61
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
60
|
+
"@uniweb/content-reader": "^1.2.8",
|
|
62
61
|
"@uniweb/semantic-parser": "^1.5.1",
|
|
62
|
+
"@uniweb/projections": "^0.7.15",
|
|
63
63
|
"@uniweb/schemas": "^0.13.0",
|
|
64
|
-
"@uniweb/content-
|
|
64
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
65
65
|
"@uniweb/theming": "^0.2.0"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.29.
|
|
68
|
+
"@uniweb/runtime": "^0.29.4"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.38.0",
|
|
78
|
+
"@uniweb/runtime": "^0.29.4"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layout a page renders with when it names none — decided once, by the build, and written
|
|
3
|
+
* into both artifacts: the entry's `capabilities.defaultLayout`, which the runtime reads
|
|
4
|
+
* (`Website.getDefaultLayoutName`), and `_self.defaultLayout` in the schema, which an editor
|
|
5
|
+
* reads to offer a page the areas it will draw. One rule, run once, so the two cannot disagree.
|
|
6
|
+
*
|
|
7
|
+
* ⭐ THE RULE [2026-10-07; Diego: "I think the default layout maybe called `default`"]:
|
|
8
|
+
*
|
|
9
|
+
* 1. `main.js`'s `defaultLayout`, written as that layout's own name — `DocsLayout` for
|
|
10
|
+
* `docs` — so a reader finds it in `_layouts` by key. ⛔ One that names none of the
|
|
11
|
+
* foundation's layouts stops the build: at render it fell to the built-in layout with
|
|
12
|
+
* nothing saying why.
|
|
13
|
+
* 2. Else the layout whose name is `default`, by the one naming rule (`layoutNameKey`):
|
|
14
|
+
* `Default`, `DefaultLayout`, `default-layout`.
|
|
15
|
+
* 3. Else none — the runtime's built-in layout, which draws `header`, the body and `footer`.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NOT "the only layout". Under that rule, adding a second layout would change the layout of
|
|
18
|
+
* every page that names none — a non-local effect nobody asked for. A foundation that means its
|
|
19
|
+
* one layout as the default says so, as every template does.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ *Until 2026-10-07 the build copied the declared value and derived nothing, so a layout named
|
|
22
|
+
* `Default` was the default only when `main.js` said so — and a reader of the schema finding none
|
|
23
|
+
* had to guess what the runtime would draw.*
|
|
24
|
+
*
|
|
25
|
+
* @module @uniweb/build/foundation/default-layout
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { findLayoutEntry } from '@uniweb/core/layout-name'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {*} declared - `main.js`'s `defaultLayout`, as `loadFoundationConfig` reads it
|
|
32
|
+
* @param {string[]} layoutNames - the foundation's layouts (`discoverLayoutsInPath`)
|
|
33
|
+
* @returns {string|null} the layout's own name, or null for the built-in layout
|
|
34
|
+
* @throws {Error} when `declared` names none of the layouts
|
|
35
|
+
*/
|
|
36
|
+
export function resolveDefaultLayout(declared, layoutNames = []) {
|
|
37
|
+
const byName = Object.fromEntries((layoutNames || []).map((name) => [name, name]))
|
|
38
|
+
|
|
39
|
+
if (declared !== undefined && declared !== null && declared !== '') {
|
|
40
|
+
const name = typeof declared === 'string' ? findLayoutEntry(byName, declared) : undefined
|
|
41
|
+
if (name) return name
|
|
42
|
+
const known = Object.keys(byName)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`main.js: \`defaultLayout\` names ${JSON.stringify(declared)}, which is not one of this foundation's layouts` +
|
|
45
|
+
(known.length > 0 ? ` (${known.join(', ')})` : ' — it has none in src/layouts/') +
|
|
46
|
+
'. Name one of them, or remove the key: a page that names no layout then renders the built-in ' +
|
|
47
|
+
'layout, or one named `Default`.'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return findLayoutEntry(byName, 'default') ?? null
|
|
52
|
+
}
|
package/src/generate-entry.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
LAYOUTS_PATH
|
|
33
33
|
} from './schema.js'
|
|
34
34
|
import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas, extractFoundationRuntime } from './runtime-schema.js'
|
|
35
|
+
import { resolveDefaultLayout } from './foundation/default-layout.js'
|
|
35
36
|
import { SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -124,6 +125,7 @@ function generateEntrySource(components, options = {}) {
|
|
|
124
125
|
cssPath = null,
|
|
125
126
|
foundationExports = null,
|
|
126
127
|
foundationRuntime = { code: [], data: {} },
|
|
128
|
+
defaultLayout = null,
|
|
127
129
|
meta = {},
|
|
128
130
|
layouts = {},
|
|
129
131
|
layoutMeta = {},
|
|
@@ -188,6 +190,9 @@ function generateEntrySource(components, options = {}) {
|
|
|
188
190
|
for (const key of foundationRuntime.code) capParts.push(`${key}: _foundationModule.default?.${key}`)
|
|
189
191
|
for (const [key, value] of Object.entries(foundationRuntime.data)) capParts.push(`${key}: ${JSON.stringify(value)}`)
|
|
190
192
|
}
|
|
193
|
+
// The resolved default, with or without a main.js — a layout named `Default` is one
|
|
194
|
+
// (`resolveDefaultLayout`), and `_self.defaultLayout` in the schema carries the same name.
|
|
195
|
+
if (defaultLayout) capParts.push(`defaultLayout: ${JSON.stringify(defaultLayout)}`)
|
|
191
196
|
if (layoutNames.length > 0) {
|
|
192
197
|
capParts.push(`layouts: { ${layoutNames.join(', ')} }`)
|
|
193
198
|
}
|
|
@@ -310,6 +315,11 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
|
|
|
310
315
|
const foundationExports = detectFoundationExports(srcDir)
|
|
311
316
|
const foundationConfig = await loadFoundationConfig(srcDir)
|
|
312
317
|
const foundationRuntime = foundationExports ? extractFoundationRuntime(foundationConfig) : { code: [], data: {} }
|
|
318
|
+
// Which layout a page that names none renders with — the build's answer, written here and in
|
|
319
|
+
// the schema's `_self.defaultLayout` (`buildSchema`) from one rule, so the runtime and an editor
|
|
320
|
+
// agree. It replaces the declared value `extractFoundationRuntime` copied.
|
|
321
|
+
const defaultLayout = resolveDefaultLayout(foundationConfig.defaultLayout, layoutNames)
|
|
322
|
+
delete foundationRuntime.data.defaultLayout
|
|
313
323
|
|
|
314
324
|
// Per-component runtime metadata. ⭐ No data schema is resolved for it: the runtime reads a
|
|
315
325
|
// declared key's ref and nothing of its shape (2026-10-05), so the entry no longer depends on
|
|
@@ -329,6 +339,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
|
|
|
329
339
|
cssPath,
|
|
330
340
|
foundationExports,
|
|
331
341
|
foundationRuntime,
|
|
342
|
+
defaultLayout,
|
|
332
343
|
meta,
|
|
333
344
|
layouts,
|
|
334
345
|
layoutMeta,
|
package/src/schema.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
unnameableIn,
|
|
29
29
|
} from './foundation/derive-supports.js'
|
|
30
30
|
import { RENAMED_SERVICES } from './uwx/services-request.js'
|
|
31
|
+
import { resolveDefaultLayout } from './foundation/default-layout.js'
|
|
31
32
|
import { foundationNameOf, splitFoundationName } from './foundation-name.js'
|
|
32
33
|
import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
|
|
33
34
|
|
|
@@ -1236,6 +1237,9 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
1236
1237
|
|
|
1237
1238
|
// Discover layouts from src/layouts/
|
|
1238
1239
|
const layouts = await discoverLayoutsInPath(srcDir)
|
|
1240
|
+
// The layout a page that names none renders with — the entry gets the same answer
|
|
1241
|
+
// (`generate-entry.js`), so an editor reading `_self.defaultLayout` offers what the runtime draws.
|
|
1242
|
+
const defaultLayout = resolveDefaultLayout(foundationConfig.defaultLayout, Object.keys(layouts))
|
|
1239
1243
|
await refuseFlagsOnListSchemas(
|
|
1240
1244
|
[
|
|
1241
1245
|
...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
|
|
@@ -1260,8 +1264,9 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
1260
1264
|
}
|
|
1261
1265
|
}
|
|
1262
1266
|
|
|
1263
|
-
// Build _self, stripping the raw extension boolean in favor of normalized role
|
|
1264
|
-
|
|
1267
|
+
// Build _self, stripping the raw extension boolean in favor of normalized role, and the
|
|
1268
|
+
// declared `defaultLayout` in favor of the resolved one (`resolveDefaultLayout`)
|
|
1269
|
+
const { extension: _ext, defaultLayout: _declaredLayout, ...configWithoutExtension } = foundationConfig
|
|
1265
1270
|
|
|
1266
1271
|
// `supports` is the one identity field the build knows better than the file.
|
|
1267
1272
|
// `identity.supports` is already normalized (absent stays absent, `[]` stays
|
|
@@ -1303,7 +1308,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
1303
1308
|
...supports,
|
|
1304
1309
|
...(name && { name }),
|
|
1305
1310
|
...(foundationConfig.description && { description: foundationConfig.description }),
|
|
1306
|
-
...(
|
|
1311
|
+
...(defaultLayout && { defaultLayout }),
|
|
1307
1312
|
...(isExtension && { role: 'extension' }),
|
|
1308
1313
|
// The build's fact, last: which form the entries below hold.
|
|
1309
1314
|
schemaFormat: SCHEMA_FORMAT,
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
|
-
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
25
|
+
import { resolveQueriesConfig, toConfigQueries, dataKeyTyping } from './queries-config.js'
|
|
26
26
|
import { isSectionKey } from '@uniweb/schemas/section'
|
|
27
27
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
28
28
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
@@ -2511,13 +2511,17 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2511
2511
|
// merged both, so a declaration in the second file was invisible here: never
|
|
2512
2512
|
// compiled, `data: <name>` delivering nothing, while sync pushed it fine.
|
|
2513
2513
|
//
|
|
2514
|
-
// ⭐ `config.queries` —
|
|
2515
|
-
//
|
|
2516
|
-
//
|
|
2517
|
-
//
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2514
|
+
// ⭐ `config.queries` — what a page's queries are, which a section's keys are filled by. A
|
|
2515
|
+
// backend-served payload carries its own projection of each (`schema`, `where`, `sort`,
|
|
2516
|
+
// `limit`, `scope`) since 2026-09-05. ⛔ This said the backend's projector never emitted it,
|
|
2517
|
+
// which was true only before then.
|
|
2518
|
+
//
|
|
2519
|
+
// ⭐ A query a data key types carries the type, as a push names it (`dataKeyTyping`), so a key
|
|
2520
|
+
// typed `@/post` fills from `articles:` on a static site as it does on a hosted one.
|
|
2521
|
+
const { declarations } = await resolveQueriesConfig(sitePath, { siteYml: siteConfig })
|
|
2522
|
+
const nameDefaulted = Object.values(declarations).some((d) => d?.schemaExplicit === false)
|
|
2523
|
+
const typing = nameDefaulted ? await dataKeyTyping(sitePath, declarations, { siteYml: siteConfig }) : null
|
|
2524
|
+
const byQuery = toConfigQueries(declarations, typing)
|
|
2521
2525
|
if (byQuery) siteConfig.queries = byQuery
|
|
2522
2526
|
|
|
2523
2527
|
// Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
|
|
@@ -41,6 +41,7 @@ import { refuseQueryRoute, refuseLimit, refuseFilter } from './data-fetcher.js'
|
|
|
41
41
|
import { readFile } from 'node:fs/promises'
|
|
42
42
|
import yaml from 'js-yaml'
|
|
43
43
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
44
|
+
import { dataKeyTypes } from '../uwx/data-key-types.js'
|
|
44
45
|
|
|
45
46
|
// Read its own YAML rather than importing the site build's helper. That import
|
|
46
47
|
// pointed the wrong way — a config resolver reaching into the collector that
|
|
@@ -77,8 +78,10 @@ export const QUERIES_YML_RELPATH = 'queries.yml'
|
|
|
77
78
|
// Identity has no boundary to get wrong, and an author wanting a different schema
|
|
78
79
|
// name writes `schema:`, which is one line and says what it means.
|
|
79
80
|
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
81
|
+
// ⛔ A pull does not drop a `schema:` equal to this default: it writes the schema the site holds,
|
|
82
|
+
// in its one spelling (`uwx/records-project.js::declToFileShape`). Until 2026-10-08 it did, keeping
|
|
83
|
+
// a projected queries.yml as terse as the author left it — and a clone, with no author's file to
|
|
84
|
+
// look at, wrote `series: { schema: '@/series' }` back as `series: {}`.
|
|
82
85
|
export function defaultSchema(name) {
|
|
83
86
|
return `@/${name}`
|
|
84
87
|
}
|
|
@@ -320,21 +323,73 @@ function refuseMethod(method, where) {
|
|
|
320
323
|
* held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
|
|
321
324
|
* query on the payload read `{ name: 'articles', … }` under `articles`.
|
|
322
325
|
*
|
|
326
|
+
* ⭐ A query that declares no `schema:` and whose records a data key types carries the TYPE —
|
|
327
|
+
* `articles` → `@/post` (`dataKeyTyping`) — which is what a push names it on the wire
|
|
328
|
+
* (`uwx/site.js::queriesNested`). The schema a page's query carries is what a section's key is
|
|
329
|
+
* filled by at render (automatic `as`), so the two lanes fill the same keys. ⛔ Until 2026-10-08 the
|
|
330
|
+
* static payload carried the name (`@/articles`), and a key typed `@/post` filled on a hosted site
|
|
331
|
+
* and stayed empty on a static one.
|
|
332
|
+
*
|
|
323
333
|
* Returns undefined for a site with no queries, so `config.queries`
|
|
324
334
|
* stays absent rather than becoming an empty object — an empty object reads as
|
|
325
335
|
* "declared, and empty" to anything checking for presence.
|
|
336
|
+
*
|
|
337
|
+
* @param {object} declarations - `resolveQueriesConfig(…).declarations`
|
|
338
|
+
* @param {Map<string, string>|null} [typing] - `dataKeyTyping`: name-defaulted ref → its type
|
|
326
339
|
*/
|
|
327
|
-
export function toConfigQueries(declarations) {
|
|
340
|
+
export function toConfigQueries(declarations, typing = null) {
|
|
328
341
|
const names = Object.keys(declarations || {})
|
|
329
342
|
if (names.length === 0) return undefined
|
|
330
343
|
const out = {}
|
|
331
344
|
for (const key of names) {
|
|
332
345
|
const { schemaExplicit, name, ...rest } = declarations[key]
|
|
333
|
-
|
|
346
|
+
const typed = schemaExplicit === false ? typing?.get(rest.schema) : null
|
|
347
|
+
out[key] = typed ? { ...rest, schema: typed } : rest
|
|
334
348
|
}
|
|
335
349
|
return out
|
|
336
350
|
}
|
|
337
351
|
|
|
352
|
+
/**
|
|
353
|
+
* ⭐ THE TYPE A DATA KEY GIVES A NAME — `@/articles` → `@/post` (ruled 2026-09-25 [Diego];
|
|
354
|
+
* `uwx/data-key-types.js`), decided for the static build as a push decides it
|
|
355
|
+
* (`uwx/records.js::typedFor`), so both lanes give a query one schema.
|
|
356
|
+
*
|
|
357
|
+
* A name-defaulted ref, `@/<name>`, stands for the type of the data key of that name when:
|
|
358
|
+
*
|
|
359
|
+
* - no data schema has the name — `@/<name>` resolves to nothing;
|
|
360
|
+
* - the foundation's section types declare the key `<name>` with one type, and it resolves;
|
|
361
|
+
* - no query asks for `@/<name>` explicitly — that one is the author's, and fails loudly.
|
|
362
|
+
*
|
|
363
|
+
* Then `records/<name>/` holds records of the type, and a query named `<name>` that declares no
|
|
364
|
+
* schema is a query of it. ⚠️ Where the records live on disk and how their translations are keyed
|
|
365
|
+
* stay keyed by the name.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} siteRoot
|
|
368
|
+
* @param {object} declarations - `resolveQueriesConfig(…).declarations`, for which refs are explicit
|
|
369
|
+
* @param {object} [opts]
|
|
370
|
+
* @param {object} [opts.siteYml] - an already-read site.yml
|
|
371
|
+
* @returns {Promise<Map<string, string>>} name-defaulted ref → the type; empty when none
|
|
372
|
+
*/
|
|
373
|
+
export async function dataKeyTyping(siteRoot, declarations, opts = {}) {
|
|
374
|
+
const typing = new Map()
|
|
375
|
+
const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
376
|
+
const keyTypes = dataKeyTypes(await foundationSections(siteRoot, siteYml))
|
|
377
|
+
if (keyTypes.size === 0) return typing
|
|
378
|
+
const explicit = new Set(
|
|
379
|
+
Object.values(declarations || {})
|
|
380
|
+
.filter((d) => d && d.schemaExplicit === true)
|
|
381
|
+
.map((d) => d.schema)
|
|
382
|
+
)
|
|
383
|
+
const named = [...keyTypes.keys()].map(defaultSchema)
|
|
384
|
+
const { schemas } = await resolveRecordSchemas(siteRoot, [...named, ...keyTypes.values()], { siteYml })
|
|
385
|
+
for (const [key, type] of keyTypes) {
|
|
386
|
+
const ref = defaultSchema(key)
|
|
387
|
+
if (schemas[ref] || explicit.has(ref) || !schemas[type]) continue
|
|
388
|
+
typing.set(ref, type)
|
|
389
|
+
}
|
|
390
|
+
return typing
|
|
391
|
+
}
|
|
392
|
+
|
|
338
393
|
|
|
339
394
|
/**
|
|
340
395
|
* The data schemas a site's records are shaped by, keyed by ref — each resolved from
|
|
@@ -65,8 +65,8 @@ import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-p
|
|
|
65
65
|
import { readRecordsConfig, resolveFolder, folderTreeOrder } from './records-config.js'
|
|
66
66
|
import { isDraftRecord } from './record-draft.js'
|
|
67
67
|
import { refuseNotOneRecord } from './record-file.js'
|
|
68
|
-
import { resolveRecordSchemas, resolveQueriesConfig,
|
|
69
|
-
import {
|
|
68
|
+
import { resolveRecordSchemas, resolveQueriesConfig, dataKeyTyping } from './queries-config.js'
|
|
69
|
+
import { keyOfDefaultRef } from '../uwx/data-key-types.js'
|
|
70
70
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
71
71
|
import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
|
|
72
72
|
import { collectNestedRefs } from '@uniweb/schemas/format'
|
|
@@ -1197,10 +1197,24 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1197
1197
|
const poolBySchema = groupPoolBySchema(pool.entities)
|
|
1198
1198
|
const recordsRoot = resolve(siteDir, pool.dir)
|
|
1199
1199
|
|
|
1200
|
+
// ⭐ A FOLDER NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego];
|
|
1201
|
+
// `dataKeyTyping`): `records/articles/`, when no data schema has the name and the foundation types
|
|
1202
|
+
// `articles` as `@/post`, holds `@/post` records — so they join that type's records, as a push
|
|
1203
|
+
// sends them, and any query of `@/post` reads them, as a host answers one. ⛔ Until 2026-10-08 they
|
|
1204
|
+
// stayed under `@/articles`: only a query named `articles` read them, and it carried `@/articles`
|
|
1205
|
+
// on the payload where a push named `@/post`.
|
|
1206
|
+
const typing = await recordTyping(siteDir, queriesConfig, poolBySchema)
|
|
1207
|
+
for (const [ref, type] of typing) {
|
|
1208
|
+
const held = poolBySchema.get(ref)
|
|
1209
|
+
if (!held) continue
|
|
1210
|
+
poolBySchema.delete(ref)
|
|
1211
|
+
poolBySchema.set(type, [...(poolBySchema.get(type) || []), ...held])
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1200
1214
|
// ⭐ THE DATA SCHEMA EACH QUERY'S RECORDS ARE DELIVERED IN (`deliverRecord`) — resolved
|
|
1201
1215
|
// from the foundation's source once for every query. A query with none compiles its
|
|
1202
1216
|
// records as their files hold them.
|
|
1203
|
-
const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig))
|
|
1217
|
+
const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig, typing))
|
|
1204
1218
|
const references = referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, basePath, includeDrafts })
|
|
1205
1219
|
|
|
1206
1220
|
const results = {}
|
|
@@ -1211,6 +1225,9 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1211
1225
|
// be an empty `/data/<name>.json` standing in for a live source.
|
|
1212
1226
|
if (config && typeof config === 'object' && config.url !== undefined) continue
|
|
1213
1227
|
const parsed = parseQueryConfig(name, config)
|
|
1228
|
+
// A query that still names its folder (`@/articles`) is a query of the folder's type.
|
|
1229
|
+
const folderRef = parsed.schema
|
|
1230
|
+
if (parsed.schema && typing.has(parsed.schema)) parsed.schema = typing.get(parsed.schema)
|
|
1214
1231
|
parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
|
|
1215
1232
|
parsed.placements = folder.placements
|
|
1216
1233
|
parsed.folderRank = folderRank
|
|
@@ -1218,7 +1235,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1218
1235
|
parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
|
|
1219
1236
|
parsed.references = references
|
|
1220
1237
|
if (parsed.poolEntities.length === 0) {
|
|
1221
|
-
const dirs =
|
|
1238
|
+
const dirs = folderRef ? poolDirsForSchema(folderRef) : null
|
|
1222
1239
|
console.warn(
|
|
1223
1240
|
`[query-processor] Query "${name}" matches no records — ` +
|
|
1224
1241
|
(dirs
|
|
@@ -1240,12 +1257,12 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1240
1257
|
* its records are compiled as their files hold them, which is not the shape a
|
|
1241
1258
|
* component receives from a host.
|
|
1242
1259
|
*/
|
|
1243
|
-
async function querySchemas(siteDir, queriesConfig) {
|
|
1244
|
-
const refs = []
|
|
1260
|
+
async function querySchemas(siteDir, queriesConfig, typing = new Map()) {
|
|
1261
|
+
const refs = [...typing.values()]
|
|
1245
1262
|
for (const config of Object.values(queriesConfig)) {
|
|
1246
1263
|
if (config && typeof config === 'object' && config.url !== undefined) continue
|
|
1247
1264
|
const ref = typeof config === 'string' ? config : config?.schema
|
|
1248
|
-
if (typeof ref === 'string') refs.push(ref)
|
|
1265
|
+
if (typeof ref === 'string' && !typing.has(ref)) refs.push(ref)
|
|
1249
1266
|
}
|
|
1250
1267
|
const { schemas, failures } = await resolveRecordSchemas(siteDir, refs)
|
|
1251
1268
|
for (const { ref, message } of failures) {
|
|
@@ -1254,28 +1271,25 @@ async function querySchemas(siteDir, queriesConfig) {
|
|
|
1254
1271
|
`their files hold them rather than as a component receives them: ${message}`
|
|
1255
1272
|
)
|
|
1256
1273
|
}
|
|
1257
|
-
await typeByDataKey(siteDir, queriesConfig, schemas)
|
|
1258
1274
|
return schemas
|
|
1259
1275
|
}
|
|
1260
1276
|
|
|
1261
1277
|
/**
|
|
1262
|
-
* ⭐
|
|
1263
|
-
* `
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
* query asked for explicitly: that one is the author's, and does not resolve. ⛔ Until 2026-09-27
|
|
1268
|
-
* this lane compiled them as if they had no data schema, while a push typed them.
|
|
1278
|
+
* ⭐ WHICH FOLDERS HOLD RECORDS OF A DATA KEY'S TYPE (ruled 2026-09-25 [Diego]): `@/articles` →
|
|
1279
|
+
* `@/post` — `dataKeyTyping`, the rule a push applies, asked only when the site has a folder or a
|
|
1280
|
+
* query it could apply to: a name-defaulted ref, `@/<name>`. ⛔ Until 2026-10-08 this lane typed a
|
|
1281
|
+
* query's RECORDS by aliasing its `@/<name>` to the type's schema, while its query and its folder
|
|
1282
|
+
* kept the name.
|
|
1269
1283
|
*
|
|
1270
1284
|
* @param {string} siteDir
|
|
1271
|
-
* @param {Object} queriesConfig
|
|
1272
|
-
* @param {
|
|
1285
|
+
* @param {Object} queriesConfig - what the build compiles (`config.queries`)
|
|
1286
|
+
* @param {Map<string, Array>} poolBySchema - the records, by the ref their folder names
|
|
1287
|
+
* @returns {Promise<Map<string, string>>} name-defaulted ref → the type
|
|
1273
1288
|
*/
|
|
1274
|
-
async function
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
)
|
|
1278
|
-
if (untyped.length === 0) return
|
|
1289
|
+
async function recordTyping(siteDir, queriesConfig, poolBySchema) {
|
|
1290
|
+
const candidate = (ref) => typeof ref === 'string' && keyOfDefaultRef(ref) !== null
|
|
1291
|
+
const queried = Object.values(queriesConfig).map((c) => (typeof c === 'string' ? c : c?.schema))
|
|
1292
|
+
if (![...poolBySchema.keys(), ...queried].some(candidate)) return new Map()
|
|
1279
1293
|
// `config.queries` carries no trace of whether its author wrote the schema; the declarations do.
|
|
1280
1294
|
let declarations = {}
|
|
1281
1295
|
try {
|
|
@@ -1283,17 +1297,11 @@ async function typeByDataKey(siteDir, queriesConfig, schemas) {
|
|
|
1283
1297
|
} catch {
|
|
1284
1298
|
// a declaration the build refuses is reported where the build reads it
|
|
1285
1299
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
for (const [name, c] of pending) {
|
|
1291
|
-
const type = keyTypes.get(name)
|
|
1292
|
-
if (type) wanted.set(c.schema, type)
|
|
1300
|
+
try {
|
|
1301
|
+
return await dataKeyTyping(siteDir, declarations)
|
|
1302
|
+
} catch {
|
|
1303
|
+
return new Map() // a foundation that does not read is reported by the build that reads it
|
|
1293
1304
|
}
|
|
1294
|
-
if (wanted.size === 0) return
|
|
1295
|
-
const { schemas: types } = await resolveRecordSchemas(siteDir, wanted.values())
|
|
1296
|
-
for (const [ref, type] of wanted) if (types[type]) schemas[ref] = types[type]
|
|
1297
1305
|
}
|
|
1298
1306
|
|
|
1299
1307
|
/**
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// records folder (`records/team/`). When no data schema has that name, but the foundation's
|
|
8
8
|
// section types declare the data key of that name with a type — `data: { team: '@/member' }` in a
|
|
9
9
|
// `meta.js` — the records ARE of that type: a push sends them as its entities, and the query names
|
|
10
|
-
// it on the wire
|
|
11
|
-
//
|
|
10
|
+
// it on the wire; the static build names it on the payload and reads them as the type's records
|
|
11
|
+
// (`site/queries-config.js::dataKeyTyping`, since 2026-10-08 — until then the payload named the query
|
|
12
|
+
// `@/team`). ⛔ Until 2026-09-25 they were read as having no data schema at all and went out as
|
|
13
|
+
// static files, although the one section that reads them declared what they are.
|
|
12
14
|
//
|
|
13
15
|
// ⚠️ THIS DECIDES ONLY WHICH DATA SCHEMA THE RECORDS BELONG TO. Where they live on disk, how the
|
|
14
16
|
// site builds them and how their translations are keyed stay keyed by the name (`team`), as the
|
package/src/uwx/index.js
CHANGED
|
@@ -88,6 +88,9 @@ export {
|
|
|
88
88
|
sectionRecordToFile,
|
|
89
89
|
pageSectionsToFiles,
|
|
90
90
|
siteContentDocumentToProject,
|
|
91
|
+
// The file the author keeps a section in (`1-hero.md` holds `hero`) — what a section
|
|
92
|
+
// unit's path names on disk, for a push recording what it delivered.
|
|
93
|
+
existingSectionFile,
|
|
91
94
|
} from './site-project.js'
|
|
92
95
|
export {
|
|
93
96
|
createTranslationCollector,
|
|
@@ -41,7 +41,7 @@ import yaml from 'js-yaml'
|
|
|
41
41
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
42
42
|
import { parseFrontmatter } from './entity-source.js'
|
|
43
43
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
44
|
-
import {
|
|
44
|
+
import { foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
45
45
|
import { dataKeyTypes } from './data-key-types.js'
|
|
46
46
|
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
47
47
|
import { folderYmlPath } from '../site/records-config.js'
|
|
@@ -277,8 +277,8 @@ function setIf(obj, key, value) {
|
|
|
277
277
|
// - `path:` is written VERBATIM, and omitted entirely when it equals the default
|
|
278
278
|
// (the query's own name under the pool).
|
|
279
279
|
// - `url:` (remote source) and a bare `source:` object are carried as-is.
|
|
280
|
-
// - `schema:` is
|
|
281
|
-
//
|
|
280
|
+
// - `schema:` is written as the site holds it — the query-name default included —
|
|
281
|
+
// except a query typed by a data key, kept short where its type can be read again.
|
|
282
282
|
//
|
|
283
283
|
// ⛔ THE `collections/`-PREFIX STRIP AND THE site.yml ROUTING ARE BOTH GONE, and
|
|
284
284
|
// they went together. They existed because `collections.yml` sat INSIDE
|
|
@@ -342,24 +342,31 @@ function declToFileShape(wire, scope = null, own = null, keyTypes = null, author
|
|
|
342
342
|
decl.source = source
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// `
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
345
|
+
// ⭐ THE SCHEMA THE SITE HOLDS IS WRITTEN — one spelling per meaning [Diego, 2026-10-08]. A
|
|
346
|
+
// query left to its name's default (`series` → `@/series`) comes back with that schema stated:
|
|
347
|
+
// `series: {}` and `series: { schema: '@/series' }` read the same records, and a clone, with no
|
|
348
|
+
// file of the author's to look at, could not tell which was written. ⛔ Until 2026-10-08 a schema
|
|
349
|
+
// equal to the default stayed unwritten unless the local file stated it, so a clone turned an
|
|
350
|
+
// author's `series: { schema: '@/series' }` into `series: {}`.
|
|
351
|
+
//
|
|
352
|
+
// ⛔ EXCEPT A QUERY TYPED BY A DATA KEY, which is not a second spelling of anything: `team:` reads
|
|
353
|
+
// its own folder, `records/team/`, as the type the foundation's data key declares
|
|
354
|
+
// (`data-key-types.js`), where `team: { schema: '@/member' }` would read `records/member/`. It
|
|
355
|
+
// stays as written — short — but only where this project can read it as that type again: the
|
|
356
|
+
// foundation's types (its own, or the registered version a clone keeps) give its name the schema
|
|
357
|
+
// the site holds. Then the push's mark says so (`typed_by_data_key`), or the author's file
|
|
358
|
+
// declares it short, or — the foundation in this project — the file does not declare it. An
|
|
359
|
+
// author's stated schema is never taken for one. ⛔ With no types to read, short would mean
|
|
360
|
+
// `@/team`: the schema is written, and the records go to its folder, so the copy still pushes it.
|
|
355
361
|
const written = name ? authored?.get(name) : undefined
|
|
356
|
-
const
|
|
357
|
-
const keyType = name && !own?.has(name) && typedHere ? keyTypes?.get(name) : null
|
|
362
|
+
const keyType = name && !own?.has(name) ? keyTypes?.get(name) : null
|
|
358
363
|
// In the author's form: the registered version names the type qualified (`@acme/member`).
|
|
359
364
|
const keyDefault = keyType ? unresolveSelfScope(keyType, scope, own) : null
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
const typedByKey =
|
|
366
|
+
keyDefault !== null &&
|
|
367
|
+
d.schema === keyDefault &&
|
|
368
|
+
(wire.typed_by_data_key === true || written === null || (written === undefined && !markedOnly))
|
|
369
|
+
if (d.schema && !typedByKey) decl.schema = d.schema
|
|
363
370
|
setIf(decl, 'sort', d.sort)
|
|
364
371
|
setIf(decl, 'where', d.where)
|
|
365
372
|
setIf(decl, 'limit', d.limit)
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -96,8 +96,12 @@ export function collectSiteUnits(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.
|
|
|
96
96
|
export function walkSiteUnits(doc, cb, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
|
|
97
97
|
const walkSections = (sections, dir) => {
|
|
98
98
|
for (const record of sections || []) {
|
|
99
|
-
// The projector's own file rule
|
|
100
|
-
//
|
|
99
|
+
// The projector's own file rule — including an app-created section named from its
|
|
100
|
+
// `$uuid`. ⚠️ The path NAMES the unit by its id; it is not always the author's file:
|
|
101
|
+
// a pull writes into an existing `1-about.md` or `@about.md` in place
|
|
102
|
+
// (`existingSectionFile`), so a reader mapping a unit to disk looks it up there.
|
|
103
|
+
// (This said "a unit's path here is the file it is written to" until 2026-10-07,
|
|
104
|
+
// and a push recorded no file for a numbered section.)
|
|
101
105
|
const base = sectionFileBase(record)
|
|
102
106
|
if (base) cb(`${dir}/${base}.md`, record, 'section')
|
|
103
107
|
walkSections(record.$children, dir)
|
package/src/uwx/site-project.js
CHANGED
|
@@ -47,8 +47,7 @@ import { declarationsToQueriesYml } from './records-project.js'
|
|
|
47
47
|
import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
|
|
48
48
|
import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
|
|
49
49
|
import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
50
|
-
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent
|
|
51
|
-
import { resolveLocaleList } from '../i18n/locales.js'
|
|
50
|
+
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
|
|
52
51
|
import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
|
|
53
52
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
54
53
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
@@ -214,7 +213,7 @@ function readAuthoredYaml(filePath) {
|
|
|
214
213
|
* @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
|
|
215
214
|
* write status ('updated' | 'unchanged')
|
|
216
215
|
*/
|
|
217
|
-
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false
|
|
216
|
+
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
|
|
218
217
|
const info = document?.info || {}
|
|
219
218
|
const settingsSection = document?.settings || {}
|
|
220
219
|
|
|
@@ -325,18 +324,14 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
325
324
|
}
|
|
326
325
|
}
|
|
327
326
|
|
|
328
|
-
// ⭐ `languages`
|
|
329
|
-
//
|
|
330
|
-
// files make (`uwx/site.js::settingsNested`), and
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
delete siteChanges.languages
|
|
336
|
-
}
|
|
337
|
-
|
|
327
|
+
// ⭐ `languages` IS WRITTEN AS THE SITE HOLDS IT — one spelling per meaning [Diego, 2026-10-08]:
|
|
328
|
+
// "It's definitely good for the case of languages." A site whose `site.yml` declared none sent the
|
|
329
|
+
// list its translation files make (`uwx/site.js::settingsNested`), and that list comes back stated,
|
|
330
|
+
// so a clone and the copy that pushed it say the same thing. ⛔ Until 2026-10-08 the list was held
|
|
331
|
+
// back where the translation files the pull wrote made the same one, so a clone of a site that
|
|
332
|
+
// declared `languages: [en, fr]` had no `languages:` at all — and followed its translation files
|
|
333
|
+
// where the site it came from had a fixed list.
|
|
338
334
|
const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
|
|
339
|
-
if (pendingLanguages !== undefined) result.pendingLanguages = pendingLanguages
|
|
340
335
|
|
|
341
336
|
// theme (whole object) → theme.yml.
|
|
342
337
|
if (settingsSection.theme && typeof settingsSection.theme === 'object') {
|
|
@@ -587,7 +582,7 @@ function listedNames(list) {
|
|
|
587
582
|
//
|
|
588
583
|
// ⛔ A top-level section never takes an `@` file: that is a child's name, and the build renders an
|
|
589
584
|
// `@` file no `nest:` claims only as an orphan appended at the end — listed by name, it is not rendered.
|
|
590
|
-
function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
|
|
585
|
+
export function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
|
|
591
586
|
if (!existsSync(pageDir)) return null
|
|
592
587
|
const entries = readdirSync(pageDir).filter((e) => isMarkdownFile(e) && (child || !e.startsWith('@')))
|
|
593
588
|
const found =
|
|
@@ -1121,7 +1116,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1121
1116
|
backend ? readBackendState(siteRoot, backend).assets || {} : {}
|
|
1122
1117
|
)
|
|
1123
1118
|
|
|
1124
|
-
report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation
|
|
1119
|
+
report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
|
|
1125
1120
|
report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
|
|
1126
1121
|
|
|
1127
1122
|
// The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
|
|
@@ -1162,18 +1157,6 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1162
1157
|
report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
|
|
1163
1158
|
// Target-locale FREE-FORM bodies → locales/freeform/{locale}/<relpath> + manifest.
|
|
1164
1159
|
report.freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
|
|
1165
|
-
// The site's languages, for a site.yml that declares none (`siteInfoToConfig`) — written only where
|
|
1166
|
-
// the translation files, now written, would not make the same list, or where `publishLanguages`
|
|
1167
|
-
// needs a declared list to draw from: the build refuses one without it.
|
|
1168
|
-
if (report.config?.pendingLanguages !== undefined) {
|
|
1169
|
-
const pulled = report.config.pendingLanguages
|
|
1170
|
-
delete report.config.pendingLanguages
|
|
1171
|
-
const site = readAuthoredYaml(join(siteRoot, 'site.yml')) || {}
|
|
1172
|
-
const source = site.defaultLanguage || sourceLocale
|
|
1173
|
-
const made = new Set([source, ...resolveLocaleList(undefined, localesDir(siteRoot))])
|
|
1174
|
-
const same = Array.isArray(pulled) && pulled.length === made.size && pulled.every((l) => made.has(l))
|
|
1175
|
-
if (!same || site.publishLanguages !== undefined) writeSiteConfig(siteRoot, { languages: pulled })
|
|
1176
|
-
}
|
|
1177
1160
|
writePullIndex(siteRoot, ctx.newIndex)
|
|
1178
1161
|
return report
|
|
1179
1162
|
}
|
package/src/uwx/yaml-edit.js
CHANGED
|
@@ -45,9 +45,18 @@ function columnOf(text, key) {
|
|
|
45
45
|
return /^ *$/.test(lead) ? lead.length : null
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Where a pair's text ends: after its value, its trailing comment and its newline.
|
|
50
|
+
*
|
|
51
|
+
* ⛔ A SCALAR ENDS WHERE ITS OWN TEXT DOES — `range[1]`, then the rest of that line. Its
|
|
52
|
+
* `range[2]` also spans the comment lines that follow it, and for an empty value (`team:`)
|
|
53
|
+
* every comment and blank line up to the next key, at any indentation: those belong to what
|
|
54
|
+
* comes next. ⛔ Until 2026-10-08 this read `range[2]`, and a pull that gave a bare `team:` its
|
|
55
|
+
* schema deleted the blank line and the `# Build options` heading under it in `site.yml`.
|
|
56
|
+
*/
|
|
49
57
|
function pairEnd(text, pair) {
|
|
50
|
-
|
|
58
|
+
const node = pair.value ?? pair.key
|
|
59
|
+
let end = isScalar(node) ? node.range[1] : node.range[2]
|
|
51
60
|
if (text[end - 1] !== '\n') {
|
|
52
61
|
const nl = text.indexOf('\n', end)
|
|
53
62
|
const rest = nl === -1 ? text.slice(end) : text.slice(end, nl)
|
|
@@ -169,6 +178,17 @@ function editMap(text, map, before, after, edits, dump) {
|
|
|
169
178
|
continue
|
|
170
179
|
}
|
|
171
180
|
}
|
|
181
|
+
// ⭐ A BARE KEY GIVEN A VALUE KEEPS ITS LINE — and any comment the author wrote on it — and
|
|
182
|
+
// the value goes under it: `team: # the people` gains ` schema: '@/team'` beneath.
|
|
183
|
+
const bare = isScalar(value) && value.value === null && value.range[0] === value.range[1]
|
|
184
|
+
if (bare && (isPlainObject(next) || Array.isArray(next))) {
|
|
185
|
+
const whole = indented(dump({ [key]: next }), col, false)
|
|
186
|
+
const nl = whole.indexOf('\n')
|
|
187
|
+
if (nl !== -1 && nl < whole.length - 1) {
|
|
188
|
+
edits.push({ start: stop, end: stop, text: (text[stop - 1] === '\n' ? '' : '\n') + whole.slice(nl + 1) })
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
}
|
|
172
192
|
// Written whole — in flow style when the author wrote it that way (`tags: [a, b]`).
|
|
173
193
|
const flow = (isMap(value) || isSeq(value)) && value.flow
|
|
174
194
|
edits.push({
|