@uniweb/build 0.63.4 → 0.64.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/i18n/extract.js +43 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +36 -17
- package/src/i18n/records.js +393 -161
- package/src/runtime-schema.js +3 -1
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/queries-config.js +8 -1
- package/src/site/query-processor.js +13 -2
- package/src/site/registered-foundation.js +47 -0
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +20 -9
- package/src/uwx/data-schema.js +34 -0
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +9 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/open-graph.js +69 -0
- package/src/uwx/project-writer.js +109 -31
- package/src/uwx/records-project.js +156 -28
- package/src/uwx/records.js +67 -18
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +334 -39
- package/src/uwx/site.js +178 -44
- package/src/uwx/sync-package.js +24 -1
- package/src/validate-data.js +15 -5
package/src/runtime-schema.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { isRichSchema } from '@uniweb/core'
|
|
28
28
|
import { deliveredFields } from '@uniweb/schemas/conform'
|
|
29
|
+
import { enumValues } from '@uniweb/schemas/format'
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
|
|
@@ -70,7 +71,8 @@ function extractSchemaField(field) {
|
|
|
70
71
|
// inert at runtime but carried through.
|
|
71
72
|
if (field.type) lean.type = field.type
|
|
72
73
|
if (field.default !== undefined) lean.default = field.default
|
|
73
|
-
|
|
74
|
+
// The values only: an entry's label (`{ value, label }`) is an editor's, and the runtime compares.
|
|
75
|
+
if (field.enum !== undefined) lean.enum = enumValues(field.enum)
|
|
74
76
|
if (field.options) lean.options = field.options
|
|
75
77
|
|
|
76
78
|
// Nested object → recurse into its field map.
|
|
@@ -26,7 +26,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'
|
|
|
26
26
|
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
27
27
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
28
28
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
29
|
-
import { readLayoutFolder, DEFAULT_LAYOUT } from './layout-folder.js'
|
|
29
|
+
import { readLayoutFolder, DEFAULT_LAYOUT, layoutAreaRoute } from './layout-folder.js'
|
|
30
30
|
import { join, parse, relative, resolve, sep } from 'node:path'
|
|
31
31
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
32
32
|
import yaml from 'js-yaml'
|
|
@@ -2419,7 +2419,7 @@ async function collectLayouts(layoutDir, siteRoot) {
|
|
|
2419
2419
|
assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
|
|
2420
2420
|
iconCollection = mergeIconCollections(iconCollection, result.iconCollection)
|
|
2421
2421
|
}
|
|
2422
|
-
const route = area.layout
|
|
2422
|
+
const route = layoutAreaRoute(area.layout, area.area)
|
|
2423
2423
|
layouts[area.layout] ??= {}
|
|
2424
2424
|
layouts[area.layout][area.area] = {
|
|
2425
2425
|
route,
|
|
@@ -2693,6 +2693,11 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2693
2693
|
for (const { message } of langValidation.warnings) {
|
|
2694
2694
|
console.warn(`[content-collector] ${message}`)
|
|
2695
2695
|
}
|
|
2696
|
+
// Errors stop a publish build (below); in dev they are said, so the author meets them here
|
|
2697
|
+
// rather than first at a push.
|
|
2698
|
+
if (!dropUnpublished) {
|
|
2699
|
+
for (const { message } of langValidation.errors) console.warn(`[content-collector] ${message}`)
|
|
2700
|
+
}
|
|
2696
2701
|
// Publish filter (the sync format's "Per-locale publish
|
|
2697
2702
|
// readiness"): on published build paths, only the publishable intersection
|
|
2698
2703
|
// (publishLanguages ∩ languages; absent field = all declared) ships — in
|
|
@@ -2956,9 +2961,35 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
|
|
|
2956
2961
|
return Object.keys(result).length > 0 ? result : null
|
|
2957
2962
|
}
|
|
2958
2963
|
|
|
2964
|
+
/**
|
|
2965
|
+
* One level's pages in the order a push walks them, and a pull reproduces: explicit `order:`, then
|
|
2966
|
+
* numeric-prefix filename order, then the parent config's `pages:` list. Shared by the push's walk
|
|
2967
|
+
* (`uwx/site.js::orderedSubfolders`) and the pull (`uwx/site-project.js`), so the two cannot
|
|
2968
|
+
* disagree on what a level's order is.
|
|
2969
|
+
*
|
|
2970
|
+
* ⚠️ The build's own page-folder sort (`collectPagesRecursive`) is not this function: it compares
|
|
2971
|
+
* folder names whole, as strings. The two differ only for numbered page folders — see the note at
|
|
2972
|
+
* `orderedSubfolders`.
|
|
2973
|
+
*
|
|
2974
|
+
* @param {Array<{ dirName: string, name: string, order?: number }>} folders
|
|
2975
|
+
* @param {Array} [parentPages] - the parent config's `pages:`
|
|
2976
|
+
* @returns {Array} the folders, ordered
|
|
2977
|
+
*/
|
|
2978
|
+
function orderFolders(folders, parentPages) {
|
|
2979
|
+
const sorted = [...folders].sort(
|
|
2980
|
+
(a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || compareFilenames(a.dirName, b.dirName)
|
|
2981
|
+
)
|
|
2982
|
+
const parsed = Array.isArray(parentPages) ? parseWildcardArray(parentPages) : null
|
|
2983
|
+
return parsed && parsed.mode !== 'all' ? applyWildcardOrder(sorted, parsed) : sorted
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2959
2986
|
export {
|
|
2960
2987
|
assertRouteFolder,
|
|
2961
2988
|
buildRouteTranslations,
|
|
2989
|
+
composeLocalizedRoute,
|
|
2990
|
+
isValidSlugSegment,
|
|
2991
|
+
declaredHomepage,
|
|
2992
|
+
orderFolders,
|
|
2962
2993
|
extractItemName,
|
|
2963
2994
|
parseWildcardArray,
|
|
2964
2995
|
applyWildcardOrder,
|
|
@@ -40,6 +40,21 @@ import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
|
40
40
|
/** The key the default layout's areas are held under, on every lane. */
|
|
41
41
|
export const DEFAULT_LAYOUT = 'default'
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The route a layout area renders at — `/layout/<area>` in the default layout,
|
|
45
|
+
* `/layout/<layout>/<area>` in a named one. A layout area's free-form translations are
|
|
46
|
+
* addressed by it (`i18n/freeform.js`), so the build, the push and the pull must all use
|
|
47
|
+
* this one rule: the push said "layout sections have no free-form home" until 2026-09-26,
|
|
48
|
+
* and a translation the build rendered never left the author's machine.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} [layoutName]
|
|
51
|
+
* @param {string} area
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function layoutAreaRoute(layoutName, area) {
|
|
55
|
+
return !layoutName || layoutName === DEFAULT_LAYOUT ? `/layout/${area}` : `/layout/${layoutName}/${area}`
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
/**
|
|
44
59
|
* Area names a layout folder is most likely to have been meant as. A folder under
|
|
45
60
|
* `layout/` named like one of them was, before this convention, an area of the
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
import { existsSync, readFileSync } from 'node:fs'
|
|
33
33
|
import { recordLayout } from '@uniweb/schemas/conform'
|
|
34
|
-
import { detectFoundationType } from './foundation-ref.js'
|
|
34
|
+
import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
|
|
35
|
+
import { readRegisteredFoundation } from './registered-foundation.js'
|
|
35
36
|
import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
|
|
36
37
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
37
38
|
import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
|
|
@@ -457,6 +458,12 @@ export function foundationDataSchemas(siteRoot, siteYml) {
|
|
|
457
458
|
*/
|
|
458
459
|
export function foundationSchemaJson(siteRoot, siteYml) {
|
|
459
460
|
if (!siteYml?.foundation) return null
|
|
461
|
+
// ⭐ A foundation named by catalog ref — every clone's — is read from the registered version the
|
|
462
|
+
// project keeps (`registered-foundation.js`): its section types, with their `data:`, and no data
|
|
463
|
+
// schemas, which travel as Models. Null until a push or a pull has read it.
|
|
464
|
+
if (parseCatalogRef(siteYml.foundation)) {
|
|
465
|
+
return readRegisteredFoundation(siteRoot, siteYml.foundation)?.schema || null
|
|
466
|
+
}
|
|
460
467
|
let info
|
|
461
468
|
try {
|
|
462
469
|
info = detectFoundationType(siteYml.foundation, siteRoot)
|
|
@@ -186,23 +186,34 @@ function extractPlainText(node) {
|
|
|
186
186
|
if (node.type === 'text') {
|
|
187
187
|
return node.text || ''
|
|
188
188
|
}
|
|
189
|
+
if (node.type === 'hardBreak' || node.type === 'hard_break') return ' '
|
|
189
190
|
|
|
190
191
|
if (Array.isArray(node.content)) {
|
|
191
|
-
|
|
192
|
+
// A textblock's inline children join as written; blocks are separated by a space. ⛔ Until
|
|
193
|
+
// 2026-09-26 blocks joined with nothing too, so an excerpt ran one paragraph into the next
|
|
194
|
+
// ("…in 1816.The river…").
|
|
195
|
+
const inline = node.content.every((child) => child?.type === 'text' || INLINE_NODES.has(child?.type))
|
|
196
|
+
return node.content.map(extractPlainText).join(inline ? '' : ' ')
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
return ''
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
// The inline nodes a textblock holds beside text.
|
|
203
|
+
const INLINE_NODES = new Set(['hardBreak', 'hard_break', 'image', 'inset_ref', 'inset_placeholder', 'icon'])
|
|
204
|
+
|
|
197
205
|
/**
|
|
198
206
|
* Extract excerpt from content
|
|
199
207
|
*
|
|
208
|
+
* Exported for the localized build (`i18n/records.js`), which derives each locale's
|
|
209
|
+
* excerpt from that locale's body by this same rule.
|
|
210
|
+
*
|
|
200
211
|
* @param {Object} frontmatter - Parsed frontmatter
|
|
201
212
|
* @param {Object} content - ProseMirror content
|
|
202
213
|
* @param {Object} excerptConfig - Excerpt configuration
|
|
203
214
|
* @returns {string} Excerpt text
|
|
204
215
|
*/
|
|
205
|
-
function extractExcerpt(frontmatter, content, excerptConfig) {
|
|
216
|
+
export function extractExcerpt(frontmatter, content, excerptConfig) {
|
|
206
217
|
const { maxLength = 160, field = null } = excerptConfig || {}
|
|
207
218
|
|
|
208
219
|
// Check for explicit excerpt in frontmatter
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A REGISTERED FOUNDATION, AS A PROJECT THAT NAMES IT BY CATALOG REF KEEPS IT.
|
|
3
|
+
*
|
|
4
|
+
* A site whose foundation is a catalog ref (`@acme/fnd@1.2.0`) — every clone — has no build of it
|
|
5
|
+
* to read from. What the project's own tools need from it is its section types' `data:`, which is
|
|
6
|
+
* what types a query named for a data key (`uwx/data-key-types.js`). The CLI reads the registered
|
|
7
|
+
* version from the backend the site is on and keeps the reply here, under `.uniweb/`; the build,
|
|
8
|
+
* the push and the pull read it back through `foundationSchemaJson`.
|
|
9
|
+
*
|
|
10
|
+
* ⭐ A registered version never changes, so the copy never goes stale, and losing it costs a round
|
|
11
|
+
* trip, never a wrong answer: the next push or pull reads it again. That is what makes `.uniweb/`
|
|
12
|
+
* (gitignored, per copy) the right home for it.
|
|
13
|
+
*
|
|
14
|
+
* The reply is kept whole, as the backend answered it — its `schema` is the foundation schema a
|
|
15
|
+
* build would emit, less its data schemas, which travel as Models.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { parseCatalogRef } from './foundation-ref.js'
|
|
20
|
+
|
|
21
|
+
/** Where a project keeps a registered foundation's reply — null for anything but a catalog ref. */
|
|
22
|
+
export function registeredFoundationPath(siteRoot, ref) {
|
|
23
|
+
const parsed = parseCatalogRef(ref)
|
|
24
|
+
if (!parsed) return null
|
|
25
|
+
return join(siteRoot, '.uniweb', 'foundations', parsed.scope, `${parsed.name}@${parsed.version}.json`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The kept reply for a catalog ref, or null when the project keeps none. */
|
|
29
|
+
export function readRegisteredFoundation(siteRoot, ref) {
|
|
30
|
+
const path = registeredFoundationPath(siteRoot, ref)
|
|
31
|
+
if (!path || !existsSync(path)) return null
|
|
32
|
+
try {
|
|
33
|
+
const reply = JSON.parse(readFileSync(path, 'utf8'))
|
|
34
|
+
return reply && typeof reply === 'object' ? reply : null
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Keep a registered foundation's reply. Returns whether it was written. */
|
|
41
|
+
export function writeRegisteredFoundation(siteRoot, ref, reply) {
|
|
42
|
+
const path = registeredFoundationPath(siteRoot, ref)
|
|
43
|
+
if (!path || !reply || typeof reply !== 'object') return false
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
45
|
+
writeFileSync(path, JSON.stringify(reply, null, 2) + '\n')
|
|
46
|
+
return true
|
|
47
|
+
}
|
package/src/uwx/asset-map.js
CHANGED
|
@@ -70,9 +70,14 @@ export function servedFingerprint(url) {
|
|
|
70
70
|
* that works, not a local path to a file that is not there. Filling those in is
|
|
71
71
|
* the download's job, not this one.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
73
|
+
* ⭐ **And the identity goes with the URL it stood beside.** A restored reference is
|
|
74
|
+
* the author's path again, and that path's id is kept in `sync.json`, which the next
|
|
75
|
+
* push stamps from (`rewriteEntityAssets`). ⛔ Until 2026-09-26 `assetId` was left in
|
|
76
|
+
* place, on the ground that the markdown serializer drops it — true of a body, and of
|
|
77
|
+
* nothing else: a pull into the copy that pushed wrote `assetId`/`assetExt` into the
|
|
78
|
+
* frontmatter of every section whose background it restored, and a body holding a
|
|
79
|
+
* pushed image never compared as unchanged, since the author's markdown parses with
|
|
80
|
+
* no id. An id the map does not know keeps its identity, with its URL.
|
|
76
81
|
*
|
|
77
82
|
* @param {object} document - the site-content document (mutated in place)
|
|
78
83
|
* @param {Record<string, { id: string, ext: string }>} map - readAssetMap()
|
|
@@ -98,6 +103,8 @@ export function restoreAssetRefs(document, map) {
|
|
|
98
103
|
if (!ref) { stats.unknown++; continue }
|
|
99
104
|
const urlAttr = slot.urls.find((k) => typeof node[k] === 'string') || slot.urls[0]
|
|
100
105
|
node[urlAttr] = ref
|
|
106
|
+
delete node[slot.id]
|
|
107
|
+
delete node[slot.ext]
|
|
101
108
|
stats.restored++
|
|
102
109
|
}
|
|
103
110
|
for (const v of Object.values(node)) visit(v)
|
package/src/uwx/backfill.js
CHANGED
|
@@ -32,7 +32,7 @@ import yaml from 'js-yaml'
|
|
|
32
32
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
33
33
|
import { proseMirrorToMarkdown } from '@uniweb/content-writer'
|
|
34
34
|
import { parseFrontmatter } from './entity-source.js'
|
|
35
|
-
import { isProseMirrorField } from './data-schema.js'
|
|
35
|
+
import { isProseMirrorField, isOpenMapSection, openMapFromRows } from './data-schema.js'
|
|
36
36
|
import { recordFileLayout, contentBodyTarget } from './record-layout.js'
|
|
37
37
|
import { unwrapLocalizedContent } from './locale-sync.js'
|
|
38
38
|
import { parseBibtex, exportBibtex } from '@citestyle/bibtex'
|
|
@@ -254,7 +254,7 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
|
|
|
254
254
|
* it has no name for is written as the uuid.
|
|
255
255
|
* @returns {string} the source-file text
|
|
256
256
|
*/
|
|
257
|
-
export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
|
|
257
|
+
export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
|
|
258
258
|
const layout = recordFileLayout(declaration)
|
|
259
259
|
if (layout.sections.length === 0) {
|
|
260
260
|
throw new Error('uwx/render: the data schema declares no sections')
|
|
@@ -267,7 +267,7 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
|
|
|
267
267
|
}
|
|
268
268
|
// Markdown: the content body field, wherever its single section is, becomes the body.
|
|
269
269
|
const { target } = contentBodyTarget(declaration)
|
|
270
|
-
const dec = { sourceLocale, collector, freeformRelPath, refName, bodyAt: format === 'md' ? target : null, body: '' }
|
|
270
|
+
const dec = { sourceLocale, collector, freeformRelPath, refName, context, bodyAt: format === 'md' ? target : null, body: '' }
|
|
271
271
|
|
|
272
272
|
const record = {}
|
|
273
273
|
if (document?.$uuid) record.$uuid = document.$uuid
|
|
@@ -307,7 +307,7 @@ function decodeRecord(fields, value, dec, sectionName) {
|
|
|
307
307
|
const raw = value[key]
|
|
308
308
|
if (raw === undefined) continue
|
|
309
309
|
if (field?.type === 'section') {
|
|
310
|
-
out[key] = field.multiple === true ?
|
|
310
|
+
out[key] = field.multiple === true ? decodeSectionList(field, raw, dec) : decodeRecord(field.fields, raw, dec, null)
|
|
311
311
|
continue
|
|
312
312
|
}
|
|
313
313
|
const isBody = Boolean(dec.bodyAt) && sectionName === dec.bodyAt.section && key === dec.bodyAt.key
|
|
@@ -318,6 +318,14 @@ function decodeRecord(fields, value, dec, sectionName) {
|
|
|
318
318
|
return out
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
// A `many` field's records — an OPEN MAP's written back as the author's map, keyed by each row's
|
|
322
|
+
// `name` (`isOpenMapSection`), which is what the push reads a map from. Rows that cannot be a map
|
|
323
|
+
// stay a list, which the push reads too.
|
|
324
|
+
function decodeSectionList(def, value, dec) {
|
|
325
|
+
const list = decodeList(def, value, dec)
|
|
326
|
+
return (isOpenMapSection(def) && openMapFromRows(list)) || list
|
|
327
|
+
}
|
|
328
|
+
|
|
321
329
|
// The records of a `many` section; a self-nesting one's `$children` nest back under
|
|
322
330
|
// `children:`, the reserved key the push reads them from.
|
|
323
331
|
function decodeList(def, value, dec) {
|
|
@@ -332,22 +340,25 @@ function decodeList(def, value, dec) {
|
|
|
332
340
|
}
|
|
333
341
|
|
|
334
342
|
// A leaf's authored value. A localized SCALAR's target locales are captured into the
|
|
335
|
-
// collector
|
|
336
|
-
//
|
|
343
|
+
// collector — a markdown body's too, keyed by its whole text as the records extractor keys it
|
|
344
|
+
// (a ProseMirror body's are captured by `unwrapLocalizedContent`, with its free-form path); a
|
|
345
|
+
// list of localized values is unwrapped element by element. ⛔ Until 2026-09-26 a body was
|
|
346
|
+
// taken to be ProseMirror, so a markdown one's translations were captured nowhere and a
|
|
347
|
+
// clone rendered it in the source language.
|
|
337
348
|
function decodeLeaf(raw, field, dec, isBody) {
|
|
338
349
|
if (field?.type === 'entity_ref') return decodeReference(raw, field, dec)
|
|
339
350
|
if (isProseMirrorField(field)) {
|
|
340
351
|
const sourceDoc = field.localized
|
|
341
|
-
? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined)
|
|
352
|
+
? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined, null, dec.context)
|
|
342
353
|
: raw
|
|
343
354
|
return sourceDoc ? proseMirrorToMarkdown(sourceDoc) : ''
|
|
344
355
|
}
|
|
345
356
|
if (!field?.localized) return raw
|
|
346
357
|
if (Array.isArray(raw)) {
|
|
347
|
-
|
|
358
|
+
for (const item of raw) dec.collector?.add(item, dec.context)
|
|
348
359
|
return unwrapLocalizedList(raw, dec.sourceLocale)
|
|
349
360
|
}
|
|
350
|
-
|
|
361
|
+
dec.collector?.add(raw, dec.context)
|
|
351
362
|
return unwrapLocalized(raw, dec.sourceLocale)
|
|
352
363
|
}
|
|
353
364
|
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -37,6 +37,40 @@ const CONTENT_TEXT_FORMATS = new Set(['markdown', 'html'])
|
|
|
37
37
|
// declares its own `name` is an error instead, which names the problem precisely.
|
|
38
38
|
const OPEN_MAP_KEY = 'name'
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* ⭐ AN OPEN MAP'S LOWERED SECTION. `values:` lowers to rows (`lowerField`): a `multiple` section
|
|
42
|
+
* whose required string `name` — the map's key — is made each row's identity by a `unique_field`
|
|
43
|
+
* rule. A record FILE holds it as the author wrote it, a map from key to value; the WIRE holds the
|
|
44
|
+
* rows. This is the one test, read off the declaration alone, because a pull into a clone has
|
|
45
|
+
* nothing else: the push turns a file's map into rows by it, and the pull the rows back into a map.
|
|
46
|
+
*/
|
|
47
|
+
export function isOpenMapSection(def) {
|
|
48
|
+
if (def?.multiple !== true || def.self_nesting === true) return false
|
|
49
|
+
const key = def.fields?.[OPEN_MAP_KEY]
|
|
50
|
+
if (key?.type !== 'string' || key.required !== true || key.localized === true) return false
|
|
51
|
+
return (def.constraints || []).some((c) => c?.kind === 'unique_field' && c.field === OPEN_MAP_KEY && c.scope === 'section')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A file's open map as the rows it lowers to, in the map's key order: each key is its row's `name`. */
|
|
55
|
+
export function openMapEntries(map) {
|
|
56
|
+
return Object.entries(map).map(([key, value]) => ({ key, value, row: { ...value, [OPEN_MAP_KEY]: key } }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rows back as the map a file holds, or null when they cannot be one — a row with no key, or a key
|
|
61
|
+
* two rows share — so a caller keeps them a list, which a push reads too, and nothing is lost.
|
|
62
|
+
*/
|
|
63
|
+
export function openMapFromRows(rows) {
|
|
64
|
+
const map = {}
|
|
65
|
+
for (const row of rows || []) {
|
|
66
|
+
const key = row?.[OPEN_MAP_KEY]
|
|
67
|
+
if (typeof key !== 'string' || key === '' || Object.hasOwn(map, key)) return null
|
|
68
|
+
const { [OPEN_MAP_KEY]: _key, ...value } = row
|
|
69
|
+
map[key] = value
|
|
70
|
+
}
|
|
71
|
+
return map
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
/**
|
|
41
75
|
* A `text` field marked as rich content (`format: markdown` or `html`): the
|
|
42
76
|
* file-based body target. Round-trips as the raw source string — what the retired
|
package/src/uwx/index.js
CHANGED
|
@@ -179,3 +179,12 @@ export {
|
|
|
179
179
|
// is the foundation-less variant (only data-schema entities) for a schemas-only package.
|
|
180
180
|
export { buildRegistryPackage, buildSchemaOnlyPackage } from './registry-package.js'
|
|
181
181
|
export { toDataSchemaDeclaration } from './data-schema.js'
|
|
182
|
+
|
|
183
|
+
// A registered foundation a project names by catalog ref — a clone's — kept under `.uniweb/` as the
|
|
184
|
+
// backend answered for it, so the build, the push and the pull can read its section types.
|
|
185
|
+
export {
|
|
186
|
+
registeredFoundationPath,
|
|
187
|
+
readRegisteredFoundation,
|
|
188
|
+
writeRegisteredFoundation,
|
|
189
|
+
} from '../site/registered-foundation.js'
|
|
190
|
+
export { parseCatalogRef } from '../site/foundation-ref.js'
|