@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.
@@ -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
- if (field.enum !== undefined) lean.enum = field.enum
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 === DEFAULT_LAYOUT ? `/layout/${area.area}` : `/layout/${area.layout}/${area.area}`
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
- return node.content.map(extractPlainText).join('')
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
+ }
@@ -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
- * `assetId` itself is not removed: it is not a markdown attribute, so the
74
- * serializer drops it on the way to disk, and leaving it lets a caller project
75
- * the same document twice without the second pass losing identity.
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)
@@ -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 ? decodeList(field, raw, dec) : decodeRecord(field.fields, raw, dec, null)
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 (the content body's are captured by `unwrapLocalizedContent`, with its
336
- // free-form path); a list of localized values is unwrapped element by element.
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
- if (!isBody) for (const item of raw) dec.collector?.add(item)
358
+ for (const item of raw) dec.collector?.add(item, dec.context)
348
359
  return unwrapLocalizedList(raw, dec.sourceLocale)
349
360
  }
350
- if (!isBody) dec.collector?.add(raw)
361
+ dec.collector?.add(raw, dec.context)
351
362
  return unwrapLocalized(raw, dec.sourceLocale)
352
363
  }
353
364
 
@@ -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
@@ -74,6 +74,12 @@
74
74
  "layout": [
75
75
  "site.yml::layout"
76
76
  ],
77
+ "og_description": [
78
+ "site.yml::seo"
79
+ ],
80
+ "og_title": [
81
+ "site.yml::seo"
82
+ ],
77
83
  "paths": [
78
84
  "site.yml::paths"
79
85
  ],
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'