@uniweb/build 0.29.0 → 0.30.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/content/index.js +6 -6
- package/src/dev-backend.js +31 -31
- package/src/i18n/freeform.js +44 -24
- package/src/i18n/index.js +22 -22
- package/src/i18n/{collections.js → records.js} +114 -51
- package/src/i18n/sync.js +9 -8
- package/src/site/build-site-data.js +9 -12
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +25 -40
- package/src/site/data-fetcher.js +23 -10
- package/src/site/entity-pool.js +211 -0
- package/src/site/fetch-shapes.js +71 -0
- package/src/site/foundation-ref.js +1 -1
- package/src/site/index.js +4 -4
- package/src/site/plugin.js +58 -63
- package/src/site/queries-config.js +324 -0
- package/src/site/{collection-processor.js → query-processor.js} +180 -95
- package/src/site/records-config.js +299 -0
- package/src/site/schemaless-data.js +2 -2
- package/src/utils/numeric-prefix.js +63 -0
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/data-schema.js +2 -2
- package/src/uwx/entity-source.js +122 -0
- package/src/uwx/folder.js +85 -77
- package/src/uwx/index.js +33 -12
- package/src/uwx/locale-sync.js +2 -2
- package/src/uwx/project-writer.js +36 -10
- package/src/uwx/queries-config.js +11 -0
- package/src/uwx/records-project.js +535 -0
- package/src/uwx/{collections.js → records.js} +152 -69
- package/src/uwx/site-diff.js +24 -1
- package/src/uwx/site-project.js +9 -6
- package/src/uwx/site.js +179 -23
- package/src/uwx/sync-package.js +37 -16
- package/src/validate-data.js +17 -19
- package/src/site/collections-config.js +0 -260
- package/src/uwx/collection-source.js +0 -180
- package/src/uwx/collections-config.js +0 -9
- package/src/uwx/collections-project.js +0 -335
- /package/src/search/{collections.js → records-index.js} +0 -0
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
// A site's collection declarations — the ONE resolution, for every lane.
|
|
2
|
-
//
|
|
3
|
-
// ⛔ THIS LIVED IN `uwx/` AND THE SITE BUILD COULD NOT SEE IT, so the site build
|
|
4
|
-
// used `site.yml::collections` directly and the two disagreed. Measured before the
|
|
5
|
-
// move: a collection declared only in `collections/collections.yml` resolved here
|
|
6
|
-
// and was INVISIBLE to the build — no `dist/data/<name>.json`, so `data: <name>`
|
|
7
|
-
// delivered nothing while sync pushed it fine. Declared in both files, the build
|
|
8
|
-
// took `site.yml`'s values and sync took `collections.yml`'s, so an author writing
|
|
9
|
-
// `sort: date desc` here got `date asc` baked into the static file.
|
|
10
|
-
//
|
|
11
|
-
// The broken case was the one the public docs recommend. See
|
|
12
|
-
// `kb/framework/plans/one-collections-config.md`.
|
|
13
|
-
//
|
|
14
|
-
// Resolve a site's collection configuration from the (optional, local-first)
|
|
15
|
-
// `collections/collections.yml`, layered over the legacy `site.yml::collections`
|
|
16
|
-
// and the zero-config subfolder-name convention.
|
|
17
|
-
//
|
|
18
|
-
// `collections.yml` is the co-located home for FILE-BASED collection declarations
|
|
19
|
-
// (it sits with the data it describes). It is useful with NO backend at all — it
|
|
20
|
-
// maps each subfolder to a data schema, declares query/display config, and can lay
|
|
21
|
-
// out a VIRTUAL folder organization decoupled from the on-disk layout. (Sync holds no
|
|
22
|
-
// folder uuid here — the backend owns the site's `@uniweb/folder`, keyed by the
|
|
23
|
-
// site-content uuid.)
|
|
24
|
-
//
|
|
25
|
-
// Precedence (per-collection, per-key): collections.yml > site.yml::collections.
|
|
26
|
-
// `site.yml::collections` stays valid for remote `url:` sources and back-compat.
|
|
27
|
-
// When neither declares a schema, the subfolder-name convention fills it
|
|
28
|
-
// (`articles` → `@/article`). Absent the file entirely, behavior is unchanged.
|
|
29
|
-
|
|
30
|
-
import { join } from 'node:path'
|
|
31
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
-
import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
|
|
33
|
-
import { detectFoundationType } from './foundation-ref.js'
|
|
34
|
-
import { readFile } from 'node:fs/promises'
|
|
35
|
-
import yaml from 'js-yaml'
|
|
36
|
-
|
|
37
|
-
// Read its own YAML rather than importing the site build's helper. That import
|
|
38
|
-
// pointed the wrong way — a config resolver reaching into the collector that
|
|
39
|
-
// consumes it — and became a cycle the moment the site build started calling
|
|
40
|
-
// this. Four lines beats a dependency between two modules that should not know
|
|
41
|
-
// about each other.
|
|
42
|
-
async function readYamlFile(filePath) {
|
|
43
|
-
if (!existsSync(filePath)) return {}
|
|
44
|
-
try {
|
|
45
|
-
return yaml.load(await readFile(filePath, 'utf8')) || {}
|
|
46
|
-
} catch {
|
|
47
|
-
return {}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const COLLECTIONS_YML_RELPATH = 'collections/collections.yml'
|
|
52
|
-
|
|
53
|
-
// The default data-schema ref for a collection that declares none: the collection's
|
|
54
|
-
// OWN NAME, unchanged. `@/` is the self scope — the local foundation's `schemas/` —
|
|
55
|
-
// so this stays backend-independent.
|
|
56
|
-
//
|
|
57
|
-
// ⛔ IT USED TO APPLY AN ENGLISH SINGULAR RULE to every language. Measured:
|
|
58
|
-
// `news` → `@/new`, `series` → `@/sery`, `analyses` → `@/analys`. Right for regular
|
|
59
|
-
// English plurals, right-by-accident elsewhere (`noticias` → `noticia`), and inert
|
|
60
|
-
// for languages with no plural marker.
|
|
61
|
-
//
|
|
62
|
-
// ⚠️ The failure was SILENT, which is why the rule was removed rather than improved.
|
|
63
|
-
// A default that does not resolve is not an error — the collection soft-skips to
|
|
64
|
-
// delivery-only — so a `news` collection simply never synced as entities, and
|
|
65
|
-
// nothing said the cause was a guess about English morphology.
|
|
66
|
-
//
|
|
67
|
-
// A more complete rule would move that boundary, not remove it: every irregular
|
|
68
|
-
// list belongs to one language, and a site names its collections in its own.
|
|
69
|
-
// Identity has no boundary to get wrong, and an author wanting a different schema
|
|
70
|
-
// name writes `schema:`, which is one line and says what it means.
|
|
71
|
-
//
|
|
72
|
-
// Exported so the inverse (projection) can drop a `schema:` that merely restates
|
|
73
|
-
// this default, keeping a projected collections.yml as terse as the author left it.
|
|
74
|
-
export function defaultSchema(name) {
|
|
75
|
-
return `@/${name}`
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Normalize one site.yml::collections entry (string shorthand or object) to the
|
|
79
|
-
// internal decl shape. Paths here are already site-root-relative (legacy contract).
|
|
80
|
-
function normalizeSiteDecl(name, decl) {
|
|
81
|
-
if (typeof decl === 'string') return { name, path: decl }
|
|
82
|
-
const d = decl && typeof decl === 'object' ? decl : {}
|
|
83
|
-
return { name, ...d }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Normalize one collections.yml::collections entry. Its `path:` is relative to the
|
|
87
|
-
// collections/ directory (default = the collection name); we lift it to a
|
|
88
|
-
// site-root-relative path so downstream readers resolve it uniformly.
|
|
89
|
-
function normalizeYmlDecl(name, decl) {
|
|
90
|
-
const d = decl && typeof decl === 'object' ? decl : {}
|
|
91
|
-
const rel = typeof d.path === 'string' ? d.path : name
|
|
92
|
-
return { name, ...d, path: `collections/${rel}` }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Resolve the merged collection configuration for a site.
|
|
97
|
-
*
|
|
98
|
-
* @param {string} siteRoot - directory containing site.yml + collections/
|
|
99
|
-
* @param {object} [opts]
|
|
100
|
-
* @param {object} [opts.siteYml] - an already-read site.yml (avoids a re-read)
|
|
101
|
-
* @returns {Promise<{
|
|
102
|
-
* folderSync: boolean, // collections.yml `sync` (whole-folder opt-out)
|
|
103
|
-
* hasCollectionsYml: boolean,
|
|
104
|
-
* declarations: object, // { name: decl } — merged, schema-defaulted
|
|
105
|
-
* folders: Array|null, // collections.yml `folders` (virtual org) or null
|
|
106
|
-
* }>}
|
|
107
|
-
*/
|
|
108
|
-
export async function resolveCollectionsConfig(siteRoot, opts = {}) {
|
|
109
|
-
const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
110
|
-
const ymlPath = join(siteRoot, COLLECTIONS_YML_RELPATH)
|
|
111
|
-
const hasCollectionsYml = existsSync(ymlPath)
|
|
112
|
-
const colYml = hasCollectionsYml ? await readYamlFile(ymlPath) : {}
|
|
113
|
-
|
|
114
|
-
const declarations = {}
|
|
115
|
-
|
|
116
|
-
// Legacy site.yml::collections first (lower precedence).
|
|
117
|
-
const siteCols = siteYml?.collections
|
|
118
|
-
if (siteCols && typeof siteCols === 'object' && !Array.isArray(siteCols)) {
|
|
119
|
-
for (const [name, decl] of Object.entries(siteCols)) {
|
|
120
|
-
declarations[name] = normalizeSiteDecl(name, decl)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// collections.yml::collections overlay (higher precedence; per-key merge).
|
|
125
|
-
const ymlCols = colYml?.collections
|
|
126
|
-
if (ymlCols && typeof ymlCols === 'object' && !Array.isArray(ymlCols)) {
|
|
127
|
-
for (const [name, decl] of Object.entries(ymlCols)) {
|
|
128
|
-
const incoming = normalizeYmlDecl(name, decl)
|
|
129
|
-
declarations[name] = { ...(declarations[name] || {}), ...incoming, name }
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Schema default (subfolder-name convention) + `model:`→`schema:` synonym.
|
|
134
|
-
// `schemaExplicit` records whether the author asked for this schema: an explicit
|
|
135
|
-
// schema that fails to resolve is a hard error; a convention-defaulted one that
|
|
136
|
-
// fails to resolve soft-skips (so a delivery-only collection never breaks sync).
|
|
137
|
-
for (const decl of Object.values(declarations)) {
|
|
138
|
-
if (decl.schema) {
|
|
139
|
-
decl.schemaExplicit = true
|
|
140
|
-
} else if (decl.model) {
|
|
141
|
-
decl.schema = decl.model // migration synonym
|
|
142
|
-
decl.schemaExplicit = true
|
|
143
|
-
} else if (decl.path || !decl.url) {
|
|
144
|
-
decl.schema = defaultSchema(decl.name) // subfolder-name convention
|
|
145
|
-
decl.schemaExplicit = false
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
await deriveDeferredFromSchemas(siteRoot, siteYml, declarations)
|
|
150
|
-
|
|
151
|
-
const folderSync = colYml?.sync !== false
|
|
152
|
-
return {
|
|
153
|
-
folderSync,
|
|
154
|
-
hasCollectionsYml,
|
|
155
|
-
declarations,
|
|
156
|
-
folders: Array.isArray(colYml?.folders) ? colYml.folders : null,
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The declarations as `config.collections` should carry them.
|
|
162
|
-
*
|
|
163
|
-
* `schemaExplicit` records whether the AUTHOR asked for a schema or the
|
|
164
|
-
* subfolder-name convention supplied one. That decides how a failed resolution
|
|
165
|
-
* behaves during sync — hard error vs soft skip — and is nobody's business
|
|
166
|
-
* downstream. It is stripped here rather than at each consumer, so the payload
|
|
167
|
-
* has one shape and no consumer has to know the field existed.
|
|
168
|
-
*
|
|
169
|
-
* Returns undefined for a site with no collections, so `config.collections`
|
|
170
|
-
* stays absent rather than becoming an empty object — an empty object reads as
|
|
171
|
-
* "declared, and empty" to anything checking for presence.
|
|
172
|
-
*/
|
|
173
|
-
export function toConfigCollections(declarations) {
|
|
174
|
-
const names = Object.keys(declarations || {})
|
|
175
|
-
if (names.length === 0) return undefined
|
|
176
|
-
const out = {}
|
|
177
|
-
for (const name of names) {
|
|
178
|
-
const { schemaExplicit, ...rest } = declarations[name]
|
|
179
|
-
out[name] = rest
|
|
180
|
-
}
|
|
181
|
-
return out
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Fill in `deferred:` from each collection's own data schema.
|
|
187
|
-
*
|
|
188
|
-
* ⭐ A schema's **brief** section already states what a record's summary is — the
|
|
189
|
-
* card, the row, the thing a list shows. Everything else is wanted only when one
|
|
190
|
-
* record is the focus. That is exactly what `deferred:` says, so an author with a
|
|
191
|
-
* schema should not have to say it twice, in a second vocabulary, with nothing
|
|
192
|
-
* checking the two against each other.
|
|
193
|
-
*
|
|
194
|
-
* ⇒ `deferred` = the schema's flat-record fields MINUS its brief fields.
|
|
195
|
-
*
|
|
196
|
-
* Derived from the SCHEMA, never from a record. That is what keeps the
|
|
197
|
-
* build-derived keys safe without a reserved list: `slug`, `route`, `path`,
|
|
198
|
-
* `excerpt`, `image` and `lastModified` are not schema fields, so they are never
|
|
199
|
-
* in the difference and never stripped. `content` is not exempt — it is
|
|
200
|
-
* schema-governed, and usually the heavy field the split exists for.
|
|
201
|
-
*
|
|
202
|
-
* ⛔ Silent on every path that cannot answer, because none of them is an error:
|
|
203
|
-
*
|
|
204
|
-
* - an author-declared `deferred:` wins outright — this never overrides one;
|
|
205
|
-
* - no local foundation (a linked or cataloged one), or it is unbuilt → nothing
|
|
206
|
-
* to read, and a site must still build;
|
|
207
|
-
* - the schema is not in the foundation's built map → the same soft-skip the
|
|
208
|
-
* sync lane already applies. `dist/meta/schema.json` carries the schemas
|
|
209
|
-
* COMPONENTS reference, so a collection whose schema no component binds is
|
|
210
|
-
* simply not there;
|
|
211
|
-
* - the schema states no brief (`briefFields` → null, e.g. a root list) → there
|
|
212
|
-
* is no lean shape to honour, so records stay whole.
|
|
213
|
-
*
|
|
214
|
-
* The last two are why this reads the built artifact rather than resolving
|
|
215
|
-
* schemas itself: it is the same input the sync lane uses, so both lanes agree
|
|
216
|
-
* about which schemas exist.
|
|
217
|
-
*/
|
|
218
|
-
async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
219
|
-
const pending = Object.values(declarations).filter(
|
|
220
|
-
(d) => d.schema && !Array.isArray(d.deferred)
|
|
221
|
-
)
|
|
222
|
-
if (pending.length === 0) return
|
|
223
|
-
|
|
224
|
-
const dataSchemas = loadFoundationDataSchemas(siteRoot, siteYml)
|
|
225
|
-
if (!dataSchemas) return
|
|
226
|
-
|
|
227
|
-
for (const decl of pending) {
|
|
228
|
-
const schema = dataSchemas[decl.schema]
|
|
229
|
-
if (!schema) continue
|
|
230
|
-
const brief = briefFields(schema)
|
|
231
|
-
if (!brief) continue
|
|
232
|
-
const all = Object.keys(flatRecordFields(schema) || {})
|
|
233
|
-
const heavy = all.filter((f) => !brief.has(f))
|
|
234
|
-
if (heavy.length) decl.deferred = heavy
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** The foundation's built data-schema map, or null when there is nothing to read. */
|
|
239
|
-
function loadFoundationDataSchemas(siteRoot, siteYml) {
|
|
240
|
-
if (!siteYml?.foundation) return null
|
|
241
|
-
let info
|
|
242
|
-
try {
|
|
243
|
-
info = detectFoundationType(siteYml.foundation, siteRoot)
|
|
244
|
-
} catch {
|
|
245
|
-
return null // a declaration this resolver refuses is not this function's error
|
|
246
|
-
}
|
|
247
|
-
if (info?.type !== 'local' || !info.path) return null
|
|
248
|
-
const schemaPath = join(info.path, 'dist', 'meta', 'schema.json')
|
|
249
|
-
if (!existsSync(schemaPath)) return null
|
|
250
|
-
try {
|
|
251
|
-
return JSON.parse(readFileSync(schemaPath, 'utf8'))?.dataSchemas || null
|
|
252
|
-
} catch {
|
|
253
|
-
return null
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Path to the collections.yml file (whether or not it exists yet). */
|
|
258
|
-
export function collectionsYmlPath(siteRoot) {
|
|
259
|
-
return join(siteRoot, COLLECTIONS_YML_RELPATH)
|
|
260
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
// Read a file-based collection's ORIGINAL source records for sync — the author's
|
|
2
|
-
// files, untouched. This is deliberately NOT `processCollections`
|
|
3
|
-
// (`build/src/site/collection-processor.js`): that is the DELIVERY pipeline that
|
|
4
|
-
// builds `public/data/<name>.json` — it converts markdown bodies to ProseMirror,
|
|
5
|
-
// derives excerpt/image, rewrites asset paths, and copies files into
|
|
6
|
-
// `public/collections/`. Sync carries the source, so it must read the source:
|
|
7
|
-
// raw frontmatter + raw markdown body, raw YAML/JSON mappings, raw BibTeX entries.
|
|
8
|
-
// No conversion, no derivation, no filter/sort/limit, no asset side effects.
|
|
9
|
-
//
|
|
10
|
-
// A collection `.md` is NOT a page-section `.md`: its frontmatter is structured
|
|
11
|
-
// DATA whose schema is the collection type's data schema (the `model:` Model), and
|
|
12
|
-
// its body is the value of the Model's content body field (a markup `text` field,
|
|
13
|
-
// or a `format: prosemirror` json field) — not foundation/runtime config. See
|
|
14
|
-
// docs/reference/entity-content.md §"Markdown (frontmatter + body)".
|
|
15
|
-
|
|
16
|
-
import { readFile, readdir } from 'node:fs/promises'
|
|
17
|
-
import { existsSync } from 'node:fs'
|
|
18
|
-
import { join, basename, extname, resolve } from 'node:path'
|
|
19
|
-
import yaml from 'js-yaml'
|
|
20
|
-
import { parseBibtex } from '@citestyle/bibtex'
|
|
21
|
-
|
|
22
|
-
const SOURCE_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Split YAML frontmatter from a markdown body. Mirrors the collection
|
|
26
|
-
* processor's split (`---\n` delimited) so a record read here re-renders to the
|
|
27
|
-
* same shape the back-fill writer produces. A file with no frontmatter yields an
|
|
28
|
-
* empty mapping and the whole text as body.
|
|
29
|
-
*
|
|
30
|
-
* @param {string} raw
|
|
31
|
-
* @returns {{ frontmatter: object, body: string }}
|
|
32
|
-
*/
|
|
33
|
-
export function parseFrontmatter(raw) {
|
|
34
|
-
if (!raw.trimStart().startsWith('---')) {
|
|
35
|
-
return { frontmatter: {}, body: raw }
|
|
36
|
-
}
|
|
37
|
-
const parts = raw.split('---\n')
|
|
38
|
-
if (parts.length < 3) {
|
|
39
|
-
return { frontmatter: {}, body: raw }
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
const frontmatter = yaml.load(parts[1]) || {}
|
|
43
|
-
const body = parts.slice(2).join('---\n')
|
|
44
|
-
return { frontmatter, body }
|
|
45
|
-
} catch {
|
|
46
|
-
return { frontmatter: {}, body: raw }
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Format key from a file extension. Single source of the format vocabulary the
|
|
51
|
-
// reader emits and the writer dispatches on.
|
|
52
|
-
function formatFor(ext) {
|
|
53
|
-
if (ext === '.md') return 'md'
|
|
54
|
-
if (ext === '.json') return 'json'
|
|
55
|
-
if (ext === '.yml' || ext === '.yaml') return 'yaml'
|
|
56
|
-
if (ext === '.bib') return 'bib'
|
|
57
|
-
return null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function readOneFile(filepath) {
|
|
61
|
-
const ext = extname(filepath).toLowerCase()
|
|
62
|
-
const format = formatFor(ext)
|
|
63
|
-
const slugFromName = basename(filepath, ext)
|
|
64
|
-
const raw = await readFile(filepath, 'utf-8')
|
|
65
|
-
|
|
66
|
-
if (format === 'md') {
|
|
67
|
-
const { frontmatter, body } = parseFrontmatter(raw)
|
|
68
|
-
const slug = frontmatter.slug || slugFromName
|
|
69
|
-
return [{ slug, format, data: frontmatter, body, sourceFile: filepath, multiRecord: false }]
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (format === 'bib') {
|
|
73
|
-
// BibTeX always yields an array — the cite key is the slug/$id. Multi-record
|
|
74
|
-
// file → write-back deferred (no natural in-file `$uuid` slot in v1).
|
|
75
|
-
const entries = parseBibtex(raw)
|
|
76
|
-
return entries
|
|
77
|
-
.filter((e) => e && e.id)
|
|
78
|
-
.map((e) => ({ slug: e.id, format, data: e, body: undefined, sourceFile: filepath, multiRecord: true }))
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// yaml / json
|
|
82
|
-
const data = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
|
|
83
|
-
if (Array.isArray(data)) {
|
|
84
|
-
// Many records in one file — each carries its own slug. Write-back deferred.
|
|
85
|
-
return data
|
|
86
|
-
.filter((item) => item && typeof item === 'object')
|
|
87
|
-
.map((item) => ({
|
|
88
|
-
slug: item.slug,
|
|
89
|
-
format,
|
|
90
|
-
data: item,
|
|
91
|
-
body: undefined,
|
|
92
|
-
sourceFile: filepath,
|
|
93
|
-
multiRecord: true,
|
|
94
|
-
}))
|
|
95
|
-
}
|
|
96
|
-
const mapping = data && typeof data === 'object' ? data : {}
|
|
97
|
-
const slug = mapping.slug || slugFromName
|
|
98
|
-
return [{ slug, format, data: mapping, body: undefined, sourceFile: filepath, multiRecord: false }]
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Read every source record in a collection directory, untouched.
|
|
103
|
-
*
|
|
104
|
-
* @param {string} collectionDir - absolute path to the collection folder
|
|
105
|
-
* @returns {Promise<Array<{ slug, format, data, body, sourceFile, multiRecord }>>}
|
|
106
|
-
* `data` is the raw frontmatter / mapping / entry; `body` is the raw markdown
|
|
107
|
-
* body (md only, else undefined); `multiRecord` is true for array-form / bib
|
|
108
|
-
* files (write-back deferred). No delivery processing of any kind.
|
|
109
|
-
*/
|
|
110
|
-
export async function readCollectionRecords(collectionDir) {
|
|
111
|
-
if (!existsSync(collectionDir)) {
|
|
112
|
-
throw new Error(`uwx/collection-source: collection folder not found: ${collectionDir}`)
|
|
113
|
-
}
|
|
114
|
-
const entries = await readdir(collectionDir, { withFileTypes: true })
|
|
115
|
-
const files = entries
|
|
116
|
-
.filter((e) => e.isFile())
|
|
117
|
-
.map((e) => e.name)
|
|
118
|
-
.filter((f) => !f.startsWith('_') && SOURCE_EXTENSIONS.has(extname(f).toLowerCase()))
|
|
119
|
-
.sort() // stable order — the wire's package digest depends on it
|
|
120
|
-
|
|
121
|
-
await reportNestedRecords(collectionDir, entries)
|
|
122
|
-
|
|
123
|
-
const records = []
|
|
124
|
-
for (const file of files) {
|
|
125
|
-
const recs = await readOneFile(resolve(collectionDir, file))
|
|
126
|
-
records.push(...recs)
|
|
127
|
-
}
|
|
128
|
-
return records
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Report source records nested below a collection's top level.
|
|
133
|
-
*
|
|
134
|
-
* ⛔ THIS LANE IS FLAT AND THAT IS A CONTRACT, NOT AN OVERSIGHT. The delivery
|
|
135
|
-
* build reads a collection recursively — an author may organise records into
|
|
136
|
-
* branches, and `path` + the `under` predicate address them
|
|
137
|
-
* (`build/src/site/collection-processor.js`). Sync cannot follow yet: the
|
|
138
|
-
* folder shape agreed with the entity store is one level, records as direct
|
|
139
|
-
* leaves of a collection's branch, and going deeper needs that renegotiated
|
|
140
|
-
* with an explicit order axis rather than assumed.
|
|
141
|
-
*
|
|
142
|
-
* ⚠️ The two lanes therefore disagree, and the failure mode is the dangerous
|
|
143
|
-
* shape: a nested record BUILDS and RENDERS locally, then is simply absent from
|
|
144
|
-
* everything the sync produced — no error, no empty result, just a smaller set
|
|
145
|
-
* than the author is looking at. Losing records silently is the one outcome
|
|
146
|
-
* that must not happen, so this says so. It does not throw: a static site with
|
|
147
|
-
* nested collections is completely valid and must keep working, and an author
|
|
148
|
-
* who never syncs should not be blocked by a lane they do not use.
|
|
149
|
-
*/
|
|
150
|
-
async function reportNestedRecords(collectionDir, entries) {
|
|
151
|
-
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
|
|
152
|
-
if (dirs.length === 0) return
|
|
153
|
-
|
|
154
|
-
const nested = []
|
|
155
|
-
for (const dir of dirs) {
|
|
156
|
-
nested.push(...(await findSourceFiles(resolve(collectionDir, dir.name), dir.name)))
|
|
157
|
-
}
|
|
158
|
-
if (nested.length === 0) return
|
|
159
|
-
|
|
160
|
-
const shown = nested.slice(0, 5).join(', ')
|
|
161
|
-
const more = nested.length > 5 ? `, and ${nested.length - 5} more` : ''
|
|
162
|
-
console.warn(
|
|
163
|
-
`[uwx/collection-source] ${nested.length} record(s) below the top level of ` +
|
|
164
|
-
`"${basename(collectionDir)}" are NOT synced: ${shown}${more}. ` +
|
|
165
|
-
`Collection sync is one level deep; these build and render locally but are ` +
|
|
166
|
-
`absent from the synced set. Move them to the collection's top level to sync them.`
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Every source file at or below `dir`, as paths relative to the collection root. */
|
|
171
|
-
async function findSourceFiles(dir, rel) {
|
|
172
|
-
const out = []
|
|
173
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
174
|
-
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
|
|
175
|
-
const relPath = `${rel}/${entry.name}`
|
|
176
|
-
if (entry.isDirectory()) out.push(...(await findSourceFiles(resolve(dir, entry.name), relPath)))
|
|
177
|
-
else if (SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) out.push(relPath)
|
|
178
|
-
}
|
|
179
|
-
return out
|
|
180
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Moved to `../site/collections-config.js` — a site's collection declarations are
|
|
2
|
-
// a site-build question first, and keeping the resolver here meant the build could
|
|
3
|
-
// not reach it and answered differently. Re-exported so no caller moved.
|
|
4
|
-
export {
|
|
5
|
-
resolveCollectionsConfig,
|
|
6
|
-
collectionsYmlPath,
|
|
7
|
-
defaultSchema,
|
|
8
|
-
COLLECTIONS_YML_RELPATH,
|
|
9
|
-
} from '../site/collections-config.js'
|