@uniweb/build 0.47.1 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,9 +32,10 @@
32
32
  // we unwrap to the source locale for the file surface (other locales stay in
33
33
  // the i18n pipeline). Absent `info` keys are left untouched on disk.
34
34
 
35
- import { join, relative, extname, basename } from 'node:path'
35
+ import { join, relative, extname, basename, dirname } from 'node:path'
36
36
  import { readAssetMap, restoreAssetRefs } from './asset-map.js'
37
- import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync } from 'node:fs'
37
+ import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
38
+ import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
38
39
  import { createHash } from 'node:crypto'
39
40
  import yaml from 'js-yaml'
40
41
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
@@ -695,59 +696,90 @@ function prunePagesTree(pages, pagesDir, sourceLocale, report) {
695
696
  if (incomingDirs.size > 0) pruneOrphanPageDirs(pagesDir, incomingDirs, report)
696
697
  }
697
698
 
698
- // The file a layout section maps to: `layout/<area>.md` (the 'default' layout) or
699
- // `layout/<layout_name>/<area>.md`. Inverse of site.js collectLayoutNested.
700
- function layoutFilePath(layoutBaseDir, record) {
701
- const area = record.area || recordStableId(record)
702
- if (!area) return null
703
- const named = record.layout_name && record.layout_name !== 'default' ? record.layout_name : null
704
- return named ? join(layoutBaseDir, named, `${area}.md`) : join(layoutBaseDir, `${area}.md`)
705
- }
706
-
707
- // A layout subdir the producer treats as a named layout (not an `_`-prefixed
708
- // organizational folder, the only thing collectLayoutNested skips).
709
- function isNamedLayoutDir(name) {
710
- return !name.startsWith('_')
699
+ /**
700
+ * Where each layout section lives, relative to the layout folder — the ONE placement,
701
+ * shared by the pull (`projectLayout`) and the site diff's unit keys (`site-diff.js`).
702
+ *
703
+ * The inverse of `readLayoutFolder` (`../site/layout-folder.js`): an area with one
704
+ * section is a file — `<area>.md` in the default layout, `<layout>/<area>.md` in a
705
+ * named one — and an area with several is a folder of prefixed files,
706
+ * `<layout>/<area>/<n>-<id>.md`, with `default/<area>/` for the default layout.
707
+ * A layout name is compared regardless of case, as the runtime matches it.
708
+ *
709
+ * @param {Array<object>} layoutSections - `layout_sections` records, in order
710
+ * @returns {Array<{ record: object, relPath: string }>}
711
+ */
712
+ export function layoutSectionPaths(layoutSections) {
713
+ const groups = new Map()
714
+ for (const record of layoutSections || []) {
715
+ const area = record?.area || recordStableId(record)
716
+ if (!area) continue
717
+ const named = typeof record.layout_name === 'string' && record.layout_name && record.layout_name.toLowerCase() !== 'default'
718
+ const layout = named ? record.layout_name : 'default'
719
+ const key = `${layout.toLowerCase()}/${area}`
720
+ if (!groups.has(key)) groups.set(key, { layout, area, records: [] })
721
+ groups.get(key).records.push(record)
722
+ }
723
+ const out = []
724
+ for (const { layout, area, records } of groups.values()) {
725
+ if (records.length === 1) {
726
+ out.push({ record: records[0], relPath: layout === 'default' ? `${area}.md` : `${layout}/${area}.md` })
727
+ continue
728
+ }
729
+ records.forEach((record, i) => {
730
+ const id = recordStableId(record) || `s${i + 1}`
731
+ out.push({ record, relPath: `${layout}/${area}/${i + 1}-${safeStableIdFilename(id)}.md` })
732
+ })
733
+ }
734
+ return out
711
735
  }
712
736
 
713
- // Delete orphan layout `.md` files (default-layout files in layoutBaseDir and
714
- // named-layout files in its subdirs) not in `keep`, and remove a named-layout dir
715
- // left empty. `_`-prefixed organizational folders are never touched.
737
+ // Delete orphan layout section files not in `keep` — at every depth the layout
738
+ // folder reader reads (`<area>.md`, `<layout>/<area>.md`, `<layout>/<area>/*.md`) —
739
+ // and remove an area or layout folder left empty. Only files the reader treats as
740
+ // sections are touched: `_`-prefixed drafts, READMEs and `_`/`.` folders are left alone.
716
741
  function pruneOrphanLayout(layoutBaseDir, keep, report) {
717
742
  if (!existsSync(layoutBaseDir)) return
718
- for (const entry of readdirSync(layoutBaseDir)) {
719
- const p = join(layoutBaseDir, entry)
720
- const st = statSync(p)
721
- if (st.isFile()) {
722
- if (extname(entry).toLowerCase() === '.md' && !keep.has(p)) {
723
- unlinkSync(p)
724
- report.deleted.push(p)
725
- }
726
- } else if (st.isDirectory() && isNamedLayoutDir(entry)) {
727
- for (const f of readdirSync(p)) {
728
- const fp = join(p, f)
729
- if (statSync(fp).isFile() && extname(f).toLowerCase() === '.md' && !keep.has(fp)) {
730
- unlinkSync(fp)
731
- report.deleted.push(fp)
732
- }
733
- }
734
- if (readdirSync(p).length === 0) {
735
- rmSync(p, { recursive: true, force: true })
736
- report.deleted.push(p)
743
+ const pruneSectionFiles = (dir) => {
744
+ for (const f of readdirSync(dir)) {
745
+ const fp = join(dir, f)
746
+ if (isMarkdownFile(f) && statSync(fp).isFile() && !keep.has(fp)) {
747
+ unlinkSync(fp)
748
+ report.deleted.push(fp)
737
749
  }
738
750
  }
739
751
  }
752
+ const removeIfEmpty = (dir) => {
753
+ if (readdirSync(dir).length === 0) {
754
+ rmSync(dir, { recursive: true, force: true })
755
+ report.deleted.push(dir)
756
+ }
757
+ }
758
+ pruneSectionFiles(layoutBaseDir)
759
+ for (const entry of readdirSync(layoutBaseDir)) {
760
+ const layoutDir = join(layoutBaseDir, entry)
761
+ if (isIgnoredFolder(entry) || !statSync(layoutDir).isDirectory()) continue
762
+ pruneSectionFiles(layoutDir)
763
+ for (const sub of readdirSync(layoutDir)) {
764
+ const areaDir = join(layoutDir, sub)
765
+ if (isIgnoredFolder(sub) || !statSync(areaDir).isDirectory()) continue
766
+ pruneSectionFiles(areaDir)
767
+ removeIfEmpty(areaDir)
768
+ }
769
+ removeIfEmpty(layoutDir)
770
+ }
740
771
  }
741
772
 
742
773
  // Project layout_sections → layout/**. TWO PASSES (like projectPages): pass 1
743
774
  // writes + relocates each file (uuid-anchored, so an app-side (layout_name, area)
744
775
  // change is a move, not delete + create); pass 2 prunes orphan files + emptied
745
- // named-layout dirs. Pruning is guarded against an empty incoming set.
776
+ // layout and area folders. Pruning is guarded against an empty incoming set.
746
777
  function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
747
778
  const written = []
748
- for (const record of layoutSections || []) {
749
- const filePath = layoutFilePath(layoutBaseDir, record)
750
- if (!filePath) continue
779
+ for (const { record, relPath } of layoutSectionPaths(layoutSections)) {
780
+ const filePath = join(layoutBaseDir, relPath)
781
+ // A relocation into a new area folder needs the folder first.
782
+ mkdirSync(dirname(filePath), { recursive: true })
751
783
  placeByUuid(ctx, record.$uuid, filePath)
752
784
  sectionRecordToFile({ filePath, record, sourceLocale: ctx?.sourceLocale, collector: ctx?.collector })
753
785
  report.layout.push(filePath)
package/src/uwx/site.js CHANGED
@@ -57,7 +57,8 @@ import {
57
57
  fetchFromQueryShorthand,
58
58
  assertRouteFolder,
59
59
  } from '../site/content-collector.js'
60
- import { refuseUnder } from '../site/data-fetcher.js'
60
+ import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
61
+ import { readLayoutFolder } from '../site/layout-folder.js'
61
62
  import { normalizeHideIn } from '../site/nav-visibility.js'
62
63
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
63
64
  import { emitEntitySyncPackage } from './entity-document.js'
@@ -242,10 +243,11 @@ function buildPageData(config, ctx) {
242
243
  // `fetch:`, or the `query:` shorthand, read and refused exactly as the build
243
244
  // reads them (`declaredFetch`) — a `folder.yml` as much as a `page.yml`.
244
245
  let fetch = declaredFetch(config, where ?? 'page.yml')
245
- // `where: { path: { under } }` is refused here as the build refuses it
246
- // (`parseFetchConfig`): a site that cannot build must not sync either. A
247
- // section's fetch is refused where the collector parses it.
248
- for (const one of [fetch].flat()) refuseUnder(one?.where, 'fetch')
246
+ // Refused here as the build refuses it (`refuseBinding`, the one list): a site
247
+ // that cannot build must not sync either. A section's fetch is refused where the
248
+ // collector parses it.
249
+ for (const one of [fetch].flat()) refuseBinding(one, where ?? 'page.yml', { level: 'page' })
250
+ warnDuplicateBindings([fetch].flat(), where ?? 'page.yml')
249
251
  // Resolve the authored `query:` shorthand to the runtime-fetchable
250
252
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
251
253
  // A shell/backend-hosted site renders client-side with NO prerender, so the
@@ -259,6 +261,8 @@ function buildPageData(config, ctx) {
259
261
  // `path`, no `as` and no `schema`. That is the silent-empty class: a payload
260
262
  // that arrives, parses, and resolves to nothing.
261
263
  const resolveWireFetch = (one) => {
264
+ // ⭐ A string is a query name — the wire carries the object form.
265
+ if (typeof one === 'string') one = { query: one }
262
266
  if (!one || typeof one.query !== 'string') return one
263
267
  const { query, ...rest } = one
264
268
  // ⭐ BOTH, deliberately, and they are not redundant.
@@ -599,34 +603,28 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
599
603
  return out
600
604
  }
601
605
 
602
- // layout_sections: top-level self-nesting, keyed by (layout_name, area) in data.
603
- // Same per-area walk as collectLayoutSections, emitted as `$`-records.
606
+ // layout_sections: top-level self-nesting, keyed by (layout_name, area) in data —
607
+ // one `$`-record per section, in area order and then section order.
608
+ //
609
+ // ⭐ Read through `readLayoutFolder` (`../site/layout-folder.js`), the ONE reader the
610
+ // build uses too, so the two cannot disagree about what `layout/` holds. ⛔ They did
611
+ // until 2026-09-13: this walk called every folder a named layout and read only the
612
+ // files directly inside it, while the build called a folder an area of the default
613
+ // layout unless the foundation declared a layout by that name.
604
614
  async function collectLayoutNested(layoutDir, siteRoot) {
605
- if (!existsSync(layoutDir)) return []
615
+ const areas = await readLayoutFolder(layoutDir, { siteRoot })
606
616
  const items = []
607
617
  let order = 0
608
- async function addArea(filePath, layoutName, area) {
609
- const { section } = await processMarkdownFile(filePath, String(order + 1), siteRoot, area)
610
- const stable = section.stableId || String(order)
611
- items.push(
612
- withIdentity(stable, { layout_name: layoutName, area, ...mapSectionData(section) })
613
- )
614
- order++
615
- }
616
- const entries = await readdir(layoutDir, { withFileTypes: true })
617
- const rootMd = entries
618
- .filter((e) => e.isFile() && isMarkdownFile(e.name))
619
- .map((e) => e.name)
620
- .sort(compareFilenames)
621
- for (const file of rootMd) {
622
- await addArea(join(layoutDir, file), 'default', parseNumericPrefix(parse(file).name).name)
623
- }
624
- for (const e of entries) {
625
- if (!e.isDirectory() || isIgnoredFolder(e.name)) continue
626
- const sub = join(layoutDir, e.name)
627
- const md = (await readdir(sub)).filter(isMarkdownFile).sort(compareFilenames)
628
- for (const file of md) {
629
- await addArea(join(sub, file), e.name, parseNumericPrefix(parse(file).name).name)
618
+ for (const area of areas) {
619
+ for (const file of area.files) {
620
+ // A one-section area's stable id is the area's name, as it always was; a
621
+ // section of a multi-section area is named the way a page's section is.
622
+ const { name } = parse(file)
623
+ const fallback = area.form === 'file' ? area.area : (parseNumericPrefix(name).name || name)
624
+ const { section } = await processMarkdownFile(join(area.dir, file), String(order + 1), siteRoot, fallback)
625
+ const stable = section.stableId || String(order)
626
+ items.push(withIdentity(stable, { layout_name: area.layout, area: area.area, ...mapSectionData(section) }))
627
+ order++
630
628
  }
631
629
  }
632
630
  return items
@@ -774,11 +772,10 @@ export function isSiteRelativeExtensionUrl(decl) {
774
772
  // that is neither emitted nor listed here.
775
773
  // Authored keys the explicit block in `queriesNested` already consumes. Kept
776
774
  // separate from the framework-local set below because these DO reach the wire —
777
- // just under a wire spelling. ⚠️ `detailUrl` is the one that matters: it is emitted
778
- // as `detail_url`, so a pass-through keyed on "is it already in `data`?" does not
779
- // see it and the field rides TWICE. Measured 2026-08-29, in the first draft of this
780
- // very change — and the push test missed it because both its controls (`limit`,
781
- // `schema`) keep their names.
775
+ // some under a wire spelling. ⚠️ A key emitted under another name is the one that
776
+ // matters: a pass-through keyed on "is it already in `data`?" does not see it and
777
+ // the field rides TWICE (measured 2026-08-29 with `detailUrl` → `detail_url`, which
778
+ // was retired on 2026-09-13 — an external query's `record:` rides in `source`).
782
779
  const DECL_EMITTED_ABOVE = new Set([
783
780
  'source',
784
781
  'schema',
@@ -787,16 +784,19 @@ const DECL_EMITTED_ABOVE = new Set([
787
784
  'limit',
788
785
  'excerpt',
789
786
  'deferred',
790
- 'detailUrl',
791
787
  'queryable'
792
788
  ])
793
789
 
794
790
  const DECL_NOT_ON_WIRE = new Set([
795
791
  // Identity — rides as the record's own `name`, not inside `data`.
796
792
  'name',
797
- // Folded into `source` above.
793
+ // Folded into `source` above — an external query's whole source.
798
794
  'path',
799
795
  'url',
796
+ 'method',
797
+ 'body',
798
+ 'transform',
799
+ 'record',
800
800
  // Folded into `schema` above (the migration synonym).
801
801
  'model',
802
802
  // Build state: whether the AUTHOR asked for the schema or the subfolder-name
@@ -816,12 +816,26 @@ const DECL_NOT_ON_WIRE = new Set([
816
816
  'filter'
817
817
  ])
818
818
 
819
+ /** An external query's source, as it rides in the `queries` Section's `source`. */
820
+ function externalSource(d) {
821
+ const source = { url: d.url }
822
+ setIf(source, 'method', d.method)
823
+ setIf(source, 'body', d.body)
824
+ setIf(source, 'transform', d.transform)
825
+ setIf(source, 'record', d.record)
826
+ return source
827
+ }
828
+
819
829
  function queriesNested(declarations, uuids = null, org = null) {
820
830
  const out = []
821
831
  for (const [name, d] of Object.entries(declarations)) {
822
832
  refuseUnder(d.where, `queries.${name}`)
833
+ refuseOutsideLanguage(d.where, `queries.${name}`)
823
834
  const data = {}
824
- const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
835
+ // ⭐ AN EXTERNAL QUERY'S SOURCE, WHOLE — `url`, `method`, `body`, `transform` and
836
+ // `record` — so a host can put it on the payload's `config.queries[<name>]`, where
837
+ // the runtime fetches it from (never the records service).
838
+ const source = d.path ? { path: d.path } : d.url !== undefined ? externalSource(d) : d.source
825
839
  setIf(data, 'source', source)
826
840
  // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME ORG AS THE RECORDS' `$model`
827
841
  // (`records.js::buildRecordEntities`). A consumer answers a query by matching
@@ -837,7 +851,6 @@ function queriesNested(declarations, uuids = null, org = null) {
837
851
  setIf(data, 'limit', d.limit)
838
852
  setIf(data, 'excerpt', d.excerpt)
839
853
  setIf(data, 'deferred', d.deferred)
840
- setIf(data, 'detail_url', d.detailUrl)
841
854
  setIf(data, 'queryable', d.queryable)
842
855
  // ⛔ EMIT WHAT WE DO NOT MODEL. The decl's field set is the BACKEND's Model
843
856
  // (this document mirrors `@uniweb/site-content` — see the lane header), and
@@ -1092,7 +1105,8 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1092
1105
  // the `query:` shorthand carries no `where`. ⚠️ The desugaring stays inline in
1093
1106
  // `setIf`: `gen-emit-surface.mjs` reads the published key's sources off it.
1094
1107
  checkDeclaration(siteYml, 'site.yml')
1095
- for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
1108
+ for (const one of [siteYml.fetch].flat()) refuseBinding(one, 'site.yml fetch', { level: 'site' })
1109
+ warnDuplicateBindings([siteYml.fetch ?? fetchFromQueryShorthand(siteYml.query)].flat(), 'site.yml')
1096
1110
  setIf(settings, 'fetch', siteYml.fetch ?? fetchFromQueryShorthand(siteYml.query))
1097
1111
 
1098
1112
  // ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises'
27
27
  import { existsSync } from 'node:fs'
28
28
  import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
- import { queryNameFromUrl } from '@uniweb/core'
30
+ import { queryNameFromUrl, siteReaches } from '@uniweb/core'
31
31
 
32
32
  import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
33
33
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
@@ -108,7 +108,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
108
108
  const type = section.type
109
109
  if (!type) return
110
110
  const bindings = foundation[type]?.data
111
- const inputs = collectInputs(section, page.fetch, config.fetch)
111
+ // the site's binding reaches a top-level page's sections only (`siteReaches`)
112
+ const inputs = collectInputs(section, page.fetch, siteReaches(page.parent) ? config.fetch : null)
112
113
 
113
114
  // ⭐ **THE JOIN, RUN THE OTHER WAY: data arrived, but under no name this
114
115
  // section reads.** Everything below asks "for each input, is there a
@@ -150,8 +151,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
150
151
  for (const input of inputs) {
151
152
  const key = input.as // the content.data KEY
152
153
 
153
- if (input.url) {
154
- deferred.push({ route: page.route, section: type, key, reason: 'remote url: source', url: input.url })
154
+ // An external query's records are its address's, fetched where the page renders.
155
+ const external = typeof input.query === 'string' ? config.queries?.[input.query]?.url : undefined
156
+ if (input.url || external) {
157
+ deferred.push({ route: page.route, section: type, key, reason: 'external query', url: input.url ?? external })
155
158
  continue
156
159
  }
157
160
  if (!input.path) continue