@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.
- package/package.json +7 -7
- package/src/prerender.js +124 -47
- package/src/site/content-collector.js +121 -190
- package/src/site/data-fetcher.js +294 -188
- package/src/site/fetch-shapes.js +9 -8
- package/src/site/layout-folder.js +221 -0
- package/src/site/plugin.js +23 -19
- package/src/site/queries-config.js +80 -5
- package/src/site/query-processor.js +23 -25
- package/src/utils/content-files.js +38 -0
- package/src/uwx/records-project.js +7 -2
- package/src/uwx/site-diff.js +7 -2
- package/src/uwx/site-project.js +73 -41
- package/src/uwx/site.js +54 -40
- package/src/validate-data.js +7 -4
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
|
714
|
-
//
|
|
715
|
-
// left empty.
|
|
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
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
//
|
|
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 =
|
|
750
|
-
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
for (const one of [fetch].flat())
|
|
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
|
-
//
|
|
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
|
-
|
|
615
|
+
const areas = await readLayoutFolder(layoutDir, { siteRoot })
|
|
606
616
|
const items = []
|
|
607
617
|
let order = 0
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
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
|
-
|
|
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())
|
|
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
|
package/src/validate-data.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|