@uniweb/build 0.29.1 → 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 +13 -12
- 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 -13
- 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 +6 -6
- package/src/uwx/site-project.js +4 -4
- package/src/uwx/site.js +143 -22
- package/src/uwx/sync-package.js +32 -18
- 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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// `records.yml` — the site's FOLDER: what is in it, and where.
|
|
2
|
+
//
|
|
3
|
+
// ⭐ LISTING AN ENTITY HERE IS WHAT MAKES IT A RECORD. An entity in `entities/`
|
|
4
|
+
// exists; a record is a leaf entry in the site's folder. The backend's own gauge:
|
|
5
|
+
// "An entity in no folder is not a record either: it exists, but cannot be
|
|
6
|
+
// publicly fetched; placing a ref to it in a published folder is what makes one."
|
|
7
|
+
// ⇒ An unreferenced entity is a draft, for free, with no flag to set.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT IS A DATABASE, NOT A PAGE SYSTEM. Records are data. They usually have no
|
|
10
|
+
// structure at all — a flat pool, and THE COMMON CASE IS THREE LINES:
|
|
11
|
+
//
|
|
12
|
+
// - person/*.md
|
|
13
|
+
// - publication/*.md
|
|
14
|
+
// - project/*.md
|
|
15
|
+
//
|
|
16
|
+
// ⛔ AND STRUCTURE IS QUERY SCOPE, NOT NAVIGATION. A `folder:` is an addressable
|
|
17
|
+
// dimension a query slices on — `where: { path: { under: 'archive' } }` — never a
|
|
18
|
+
// menu, a listing or a URL tree. The question is not "does this site want
|
|
19
|
+
// sub-pages?" but "will a query ever ask for a SLICE rather than the whole pool?"
|
|
20
|
+
// Most will not, which is why flat is the norm rather than a simplification.
|
|
21
|
+
//
|
|
22
|
+
// ⭐ THE SHAPE IS A LIST, and an entity gets no ceremony: a bare string IS an
|
|
23
|
+
// entity path, one file or a pattern matching many. Only the exceptions announce
|
|
24
|
+
// themselves — `url:`, `asset:`, `folder:`. (An earlier design keyed a map by
|
|
25
|
+
// entry name; it charged every entry a key, a kind and often a label to buy
|
|
26
|
+
// navigation ergonomics most sites never use. Measured on a realistic file: 7 of
|
|
27
|
+
// 11 entries are just a path.)
|
|
28
|
+
//
|
|
29
|
+
// ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
|
|
30
|
+
// Curation enumerates; queries compute. Two constructs, two jobs.
|
|
31
|
+
//
|
|
32
|
+
// ⭐ AND IT IS THE SYNC CONTROL. What syncs is exactly what this file references —
|
|
33
|
+
// no flag, no inference. `missing` and `empty` differ deliberately:
|
|
34
|
+
//
|
|
35
|
+
// missing → inert. Do not sync; leave the server's folder untouched.
|
|
36
|
+
// empty → destructive. Sync an empty folder, removing what is there.
|
|
37
|
+
//
|
|
38
|
+
// The safe state is the ABSENCE of a file and the destructive act requires
|
|
39
|
+
// affirmatively creating one, so a live folder cannot be wiped by deleting
|
|
40
|
+
// something. ⛔ Do not "simplify" these into one behaviour — it would delete a
|
|
41
|
+
// capability. The placeholder hazard (someone creates an empty file meaning to
|
|
42
|
+
// fill it in) is guarded at the CLI with a count and a confirmation; the format
|
|
43
|
+
// stays honest and the CLI does the asking.
|
|
44
|
+
//
|
|
45
|
+
// Model: `kb/framework/plans/records-model.md`.
|
|
46
|
+
|
|
47
|
+
import { existsSync } from 'node:fs'
|
|
48
|
+
import { readFile } from 'node:fs/promises'
|
|
49
|
+
import { join } from 'node:path'
|
|
50
|
+
import yaml from 'js-yaml'
|
|
51
|
+
import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
52
|
+
|
|
53
|
+
export const RECORDS_YML_RELPATH = 'records.yml'
|
|
54
|
+
|
|
55
|
+
/** Path to the records.yml file (whether or not it exists yet). */
|
|
56
|
+
export function recordsYmlPath(siteRoot) {
|
|
57
|
+
return join(siteRoot, RECORDS_YML_RELPATH)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What `records.yml` says about syncing, before any entry is resolved. */
|
|
61
|
+
export const FOLDER_MISSING = 'missing'
|
|
62
|
+
export const FOLDER_EMPTY = 'empty'
|
|
63
|
+
export const FOLDER_DECLARED = 'declared'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Match one entity path against a `records.yml` pattern.
|
|
67
|
+
*
|
|
68
|
+
* ⛔ `*` DOES NOT CROSS A `/`, which is what a reader expects of a file pattern
|
|
69
|
+
* and is NOT what `@uniweb/core`'s `globMatch` does. That one backs the `like`
|
|
70
|
+
* PREDICATE, where a value is one opaque string and a cross-segment `*` is
|
|
71
|
+
* correct. Same syntax, different question — so this is a deliberate second
|
|
72
|
+
* implementation, not a copy that drifted. Do not "converge" them.
|
|
73
|
+
*/
|
|
74
|
+
export function matchEntityPattern(pattern, relPath) {
|
|
75
|
+
const re =
|
|
76
|
+
'^' +
|
|
77
|
+
pattern
|
|
78
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
79
|
+
.replace(/\*/g, '[^/]*')
|
|
80
|
+
.replace(/\?/g, '[^/]')
|
|
81
|
+
+ '$'
|
|
82
|
+
return new RegExp(re).test(relPath)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Does this string name one exact file rather than a set? */
|
|
86
|
+
const isPattern = (s) => /[*?]/.test(s)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The slug a record is addressed by — its filename stem, whole.
|
|
90
|
+
*
|
|
91
|
+
* ⛔ NOTHING IS STRIPPED. A leading number is a DATE (`2026-03-…`) at least as
|
|
92
|
+
* often as it is an order (`01-`), and nothing in the filename distinguishes
|
|
93
|
+
* them, so consuming one into the name mangles the other. A number is read to
|
|
94
|
+
* SORT by and never to rename. *(An earlier draft of the model stripped a leading
|
|
95
|
+
* `01-`; implementing it against the model's own example pool is what surfaced
|
|
96
|
+
* the collision, and the idea was withdrawn.)*
|
|
97
|
+
*/
|
|
98
|
+
export function slugForEntity(entity) {
|
|
99
|
+
return entity.slug
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read `records.yml`.
|
|
104
|
+
*
|
|
105
|
+
* ⛔ THE THREE STATES ARE NOT TWO. `missing` and `empty` mean different things
|
|
106
|
+
* and the difference is load-bearing (see the header), so this reports which it
|
|
107
|
+
* saw rather than collapsing them into "no entries".
|
|
108
|
+
*
|
|
109
|
+
* @returns {Promise<{ state: string, entries: Array, error: string|null }>}
|
|
110
|
+
*/
|
|
111
|
+
export async function readRecordsConfig(siteRoot) {
|
|
112
|
+
const file = join(siteRoot, RECORDS_YML_RELPATH)
|
|
113
|
+
if (!existsSync(file)) return { state: FOLDER_MISSING, entries: [], error: null }
|
|
114
|
+
|
|
115
|
+
let doc
|
|
116
|
+
try {
|
|
117
|
+
doc = yaml.load(await readFile(file, 'utf8'))
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { state: FOLDER_MISSING, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (doc === null || doc === undefined || (Array.isArray(doc) && doc.length === 0)) {
|
|
123
|
+
return { state: FOLDER_EMPTY, entries: [], error: null }
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(doc)) {
|
|
126
|
+
return {
|
|
127
|
+
state: FOLDER_MISSING,
|
|
128
|
+
entries: [],
|
|
129
|
+
error:
|
|
130
|
+
`${RECORDS_YML_RELPATH} must be a LIST of what is in the folder, not a mapping. ` +
|
|
131
|
+
`The common case is three lines:\n - person/*.md\n - publication/*.md`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { state: FOLDER_DECLARED, entries: doc, error: null }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the folder's entries against the entity pool.
|
|
139
|
+
*
|
|
140
|
+
* Every rule here is a guard, and each one exists because its failure was
|
|
141
|
+
* SILENT. `entries: [artcles]` used to produce a real, reachable, empty path
|
|
142
|
+
* with no warning at all.
|
|
143
|
+
*
|
|
144
|
+
* ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
|
|
145
|
+
* a second placement. The wire could carry many-to-many — `folder.js` nests, and
|
|
146
|
+
* placements are keyed by their `path_segment` chain — but `core/src/where.js`'s
|
|
147
|
+
* `matchUnder` is STRING-ONLY, so a record with two paths would match nothing
|
|
148
|
+
* under `where: { path: { under: … } }`, silently. Widening `under` is a
|
|
149
|
+
* predicate the backend also evaluates natively, so it is a cross-lane change to
|
|
150
|
+
* agree first, not to infer. Until then the file lane is the floor: one folder.
|
|
151
|
+
*
|
|
152
|
+
* @param {Array} entries - the parsed `records.yml` list
|
|
153
|
+
* @param {Array} pool - entities from `readEntityPool`
|
|
154
|
+
* @returns {{ nodes: Array, placements: Map, errors: string[], warnings: string[] }}
|
|
155
|
+
* `nodes` is the folder tree (branches + entity placements, in file order);
|
|
156
|
+
* `placements` maps an entity id to its `{ entity, path, slug }`.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveFolder(entries, pool) {
|
|
159
|
+
const errors = []
|
|
160
|
+
const warnings = []
|
|
161
|
+
const placements = new Map()
|
|
162
|
+
// Which entry claimed a file, so the second one can name the first.
|
|
163
|
+
const claimedBy = new Map()
|
|
164
|
+
|
|
165
|
+
const byRelPath = new Map()
|
|
166
|
+
for (const e of pool || []) {
|
|
167
|
+
// ⚠️ KEYED BY THE FILE AS IT EXISTS, prefix and all — an author writing
|
|
168
|
+
// `post/01-lab-opens.md` is naming a file they can see, not the slug it
|
|
169
|
+
// produces. `poolPath` is that path, relative to `entities/`.
|
|
170
|
+
byRelPath.set(e.poolPath, e)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const place = (entity, pathSegs, where) => {
|
|
174
|
+
const key = entity.id
|
|
175
|
+
const prior = claimedBy.get(key)
|
|
176
|
+
if (prior) {
|
|
177
|
+
errors.push(
|
|
178
|
+
`${RECORDS_YML_RELPATH}: "${entity.relPath}" is placed twice — by ${prior} and by ${where}. ` +
|
|
179
|
+
`An entity occupies one folder; a computed subset is a named query, not a second placement.`
|
|
180
|
+
)
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
claimedBy.set(key, where)
|
|
184
|
+
const slug = slugForEntity(entity)
|
|
185
|
+
const path = pathSegs.join('/')
|
|
186
|
+
placements.set(key, { entity, path, slug })
|
|
187
|
+
return { kind: 'ref', path_segment: slug, $entityId: key }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const resolveEntry = (entry, pathSegs, index, trail) => {
|
|
191
|
+
const where = `entry ${trail}[${index}]`
|
|
192
|
+
|
|
193
|
+
if (typeof entry === 'string') {
|
|
194
|
+
const pattern = entry.trim()
|
|
195
|
+
if (!pattern) {
|
|
196
|
+
errors.push(`${RECORDS_YML_RELPATH}: ${where} is an empty string.`)
|
|
197
|
+
return []
|
|
198
|
+
}
|
|
199
|
+
if (!isPattern(pattern)) {
|
|
200
|
+
const hit = byRelPath.get(pattern)
|
|
201
|
+
if (!hit) {
|
|
202
|
+
errors.push(
|
|
203
|
+
`${RECORDS_YML_RELPATH}: ${where} names "${pattern}", which is not in entities/. ` +
|
|
204
|
+
`A bare string is a path under entities/, extension included.`
|
|
205
|
+
)
|
|
206
|
+
return []
|
|
207
|
+
}
|
|
208
|
+
const leaf = place(hit, pathSegs, where)
|
|
209
|
+
return leaf ? [leaf] : []
|
|
210
|
+
}
|
|
211
|
+
// ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `entities/artcle/*.md` is the
|
|
212
|
+
// old empty-branch defect respelled, and it produced a real, reachable,
|
|
213
|
+
// empty path with no warning.
|
|
214
|
+
const matches = [...byRelPath.entries()]
|
|
215
|
+
.filter(([rel]) => matchEntityPattern(pattern, rel))
|
|
216
|
+
.map(([, e]) => e)
|
|
217
|
+
if (matches.length === 0) {
|
|
218
|
+
errors.push(
|
|
219
|
+
`${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no entity. ` +
|
|
220
|
+
`Check the schema folder name and the extension.`
|
|
221
|
+
)
|
|
222
|
+
return []
|
|
223
|
+
}
|
|
224
|
+
// Matches sort alphanumerically by filename, numeric-aware — so `1-`, `2-`,
|
|
225
|
+
// `10-` order as written rather than as strings, and `2025-…` precedes
|
|
226
|
+
// `2026-…`. Ordering only: the number never leaves the name.
|
|
227
|
+
matches.sort((a, b) => compareByNumericPrefix(a.slug, b.slug))
|
|
228
|
+
return matches.map((e) => place(e, pathSegs, where)).filter(Boolean)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
232
|
+
errors.push(`${RECORDS_YML_RELPATH}: ${where} is neither a path nor a typed entry.`)
|
|
233
|
+
return []
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (entry.folder !== undefined) {
|
|
237
|
+
// ⚠️ COERCED, because YAML types a bare `2024` as a NUMBER — and a folder
|
|
238
|
+
// named for a year is the single most likely one anybody writes. Both the
|
|
239
|
+
// segment and the label reach the wire as strings; passing a number through
|
|
240
|
+
// would surface as a type error on the far side, far from the file that
|
|
241
|
+
// caused it. `0` is a legal folder name, so this tests for absence rather
|
|
242
|
+
// than falsiness.
|
|
243
|
+
const raw = entry.folder
|
|
244
|
+
const segment = raw === null || raw === undefined ? '' : String(raw).trim()
|
|
245
|
+
if (!segment) {
|
|
246
|
+
errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
|
|
247
|
+
return []
|
|
248
|
+
}
|
|
249
|
+
const branch = { kind: 'branch', path_segment: segment }
|
|
250
|
+
if (entry.label !== undefined && entry.label !== null) branch.name = String(entry.label)
|
|
251
|
+
const kids = Array.isArray(entry.records) ? entry.records : []
|
|
252
|
+
if (kids.length === 0) {
|
|
253
|
+
warnings.push(
|
|
254
|
+
`${RECORDS_YML_RELPATH}: folder "${segment}" (${where}) holds no records. ` +
|
|
255
|
+
`A folder exists to be QUERIED — if no query needs the slice, do not make it.`
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
branch.$children = kids.flatMap((child, i) =>
|
|
259
|
+
resolveEntry(child, [...pathSegs, segment], i, `${trail}[${index}].records`)
|
|
260
|
+
)
|
|
261
|
+
return [branch]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ⛔ REJECT LOUDLY RATHER THAN IGNORE. The union's shape is settled and these
|
|
265
|
+
// are part of it, but framework emits only `ref` and `branch` today. Silently
|
|
266
|
+
// dropping an entry an author wrote is the failure mode this whole file
|
|
267
|
+
// exists to prevent.
|
|
268
|
+
if (entry.url !== undefined || entry.asset !== undefined) {
|
|
269
|
+
const kind = entry.url !== undefined ? 'url' : 'asset'
|
|
270
|
+
errors.push(
|
|
271
|
+
`${RECORDS_YML_RELPATH}: ${where} declares \`${kind}:\`, which the folder producer ` +
|
|
272
|
+
`does not emit yet. A folder holds urls and assets by design, but nothing would ` +
|
|
273
|
+
`be sent for this entry — so it is refused rather than dropped.`
|
|
274
|
+
)
|
|
275
|
+
return []
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
errors.push(
|
|
279
|
+
`${RECORDS_YML_RELPATH}: ${where} has no recognized kind. ` +
|
|
280
|
+
`A bare string is an entity path; anything else says \`folder:\`, \`url:\` or \`asset:\`.`
|
|
281
|
+
)
|
|
282
|
+
return []
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const nodes = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
|
|
286
|
+
|
|
287
|
+
// ⚠️ REPORT WHAT NOTHING REFERENCES. It is a legitimate draft state, so it is
|
|
288
|
+
// not an error — but silence is how every defect in this area survived.
|
|
289
|
+
for (const e of pool || []) {
|
|
290
|
+
if (!placements.has(e.id)) {
|
|
291
|
+
warnings.push(
|
|
292
|
+
`${e.relPath} is in entities/ but referenced by nothing in ${RECORDS_YML_RELPATH} — ` +
|
|
293
|
+
`it exists, but it is not a record and no query can reach it.`
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { nodes, placements, errors, warnings }
|
|
299
|
+
}
|
|
@@ -61,7 +61,7 @@ async function readJsonTree(dir) {
|
|
|
61
61
|
|
|
62
62
|
// The collection a `dist/data` relpath belongs to: the first path segment, minus a
|
|
63
63
|
// trailing `.json`. `articles.json` → `articles`; `articles/hello.json` → `articles`.
|
|
64
|
-
function
|
|
64
|
+
function queryOf(relPath) {
|
|
65
65
|
const first = relPath.split('/')[0]
|
|
66
66
|
return first.endsWith('.json') ? first.slice(0, -5) : first
|
|
67
67
|
}
|
|
@@ -79,7 +79,7 @@ export async function collectSchemalessData(distDir, schemalessNames = []) {
|
|
|
79
79
|
const allData = await readJsonTree(join(distDir, DATA_DIR))
|
|
80
80
|
const data = {}
|
|
81
81
|
for (const [relPath, value] of Object.entries(allData)) {
|
|
82
|
-
if (schemaless.has(
|
|
82
|
+
if (schemaless.has(queryOf(relPath))) data[relPath] = value
|
|
83
83
|
}
|
|
84
84
|
// Agent projections deliberately do NOT ride the ball.
|
|
85
85
|
//
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The `01-` filename convention — ONE rule, for pages and for records.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ IT WAS PRIVATE TO THE PAGES LANE, and `records.yml` needs exactly the same
|
|
4
|
+
// rule: a leading `01-` orders a file and is stripped from the name it produces.
|
|
5
|
+
// Two copies would drift, and the drift would be invisible — a section ordering
|
|
6
|
+
// one way and a record another, both plausible, with nothing comparing them.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Lifted here rather than imported from `site/content-collector.js` because
|
|
9
|
+
// that module reads a site's whole page tree and imports the query resolver; a
|
|
10
|
+
// records reader importing it would close a cycle. A naming convention is a leaf.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Split a leading numeric prefix off a filename stem.
|
|
14
|
+
*
|
|
15
|
+
* Dots are sub-levels, so `1`, `1.5`, `2` order as you would read them.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ THE RECORDS LANE USES THIS FOR ORDERING ONLY — it does NOT strip the prefix
|
|
18
|
+
* from a record's slug. A record's slug is its filename stem, whole. An earlier
|
|
19
|
+
* draft of the model stripped it, and implementing that surfaced why it cannot
|
|
20
|
+
* work here: a leading number is a DATE at least as often as it is an order, and
|
|
21
|
+
* the two are indistinguishable by shape. Measured on the model's own example
|
|
22
|
+
* pool, `2026-03-nature-folding.md` became `03-nature-folding`.
|
|
23
|
+
*
|
|
24
|
+
* ⚖️ Sorting is unaffected and stays useful: `2025-11-…` before `2026-03-…` is
|
|
25
|
+
* exactly what a date prefix should do, and `1-`, `2-`, `10-` order as written
|
|
26
|
+
* rather than as strings. Reading a number to ORDER by it is safe; consuming it
|
|
27
|
+
* into a name is not.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} filename - a stem, without its extension
|
|
30
|
+
* @returns {{ prefix: string|null, name: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function parseNumericPrefix(filename) {
|
|
33
|
+
const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
|
|
34
|
+
if (match) {
|
|
35
|
+
return { prefix: match[1], name: match[2] || match[1] }
|
|
36
|
+
}
|
|
37
|
+
return { prefix: null, name: filename }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compare two stems by their numeric prefix, falling back to locale order.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ Prefixed names sort BEFORE unprefixed ones. A file the author numbered is
|
|
44
|
+
* one they placed deliberately; an unnumbered one has no stated position, so it
|
|
45
|
+
* follows rather than interleaving at an arbitrary point.
|
|
46
|
+
*/
|
|
47
|
+
export function compareByNumericPrefix(a, b) {
|
|
48
|
+
const { prefix: pa } = parseNumericPrefix(a)
|
|
49
|
+
const { prefix: pb } = parseNumericPrefix(b)
|
|
50
|
+
|
|
51
|
+
if (!pa && !pb) return a.localeCompare(b)
|
|
52
|
+
if (!pa) return 1
|
|
53
|
+
if (!pb) return -1
|
|
54
|
+
|
|
55
|
+
const partsA = pa.split('.').map(Number)
|
|
56
|
+
const partsB = pb.split('.').map(Number)
|
|
57
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
58
|
+
const na = partsA[i] ?? 0
|
|
59
|
+
const nb = partsB[i] ?? 0
|
|
60
|
+
if (na !== nb) return na - nb
|
|
61
|
+
}
|
|
62
|
+
return 0
|
|
63
|
+
}
|
package/src/uwx/backfill.js
CHANGED
|
@@ -22,7 +22,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
|
22
22
|
import { join } from 'node:path'
|
|
23
23
|
import yaml from 'js-yaml'
|
|
24
24
|
import { proseMirrorToMarkdown } from '@uniweb/content-writer'
|
|
25
|
-
import { parseFrontmatter } from './
|
|
25
|
+
import { parseFrontmatter } from './entity-source.js'
|
|
26
26
|
import { isProseMirrorField, isContentBodyField } from './data-schema.js'
|
|
27
27
|
import { unwrapLocalizedContent } from './locale-sync.js'
|
|
28
28
|
import { parseBibtex, exportBibtex } from '@citestyle/bibtex'
|
|
@@ -35,9 +35,9 @@ const SOURCE_EXTENSIONS = ['.yml', '.yaml', '.json', '.md', '.bib']
|
|
|
35
35
|
* probing the supported extensions. Returns the absolute path or null (e.g. an
|
|
36
36
|
* array-form file holding many records, whose name is not `<slug>.<ext>`).
|
|
37
37
|
*/
|
|
38
|
-
export function findRecordFile(
|
|
38
|
+
export function findRecordFile(poolDir, slug) {
|
|
39
39
|
for (const ext of SOURCE_EXTENSIONS) {
|
|
40
|
-
const p = join(
|
|
40
|
+
const p = join(poolDir, slug + ext)
|
|
41
41
|
if (existsSync(p)) return p
|
|
42
42
|
}
|
|
43
43
|
return null
|
|
@@ -235,11 +235,11 @@ function briefHasContentBody(declaration) {
|
|
|
235
235
|
* @param {string} params.format - 'yaml' | 'json' | 'md'
|
|
236
236
|
* @param {string} [params.sourceLocale]
|
|
237
237
|
* @param {object} [params.collector] - translation collector; target locales of
|
|
238
|
-
* localized SCALAR fields are captured into it (→ locales/
|
|
238
|
+
* localized SCALAR fields are captured into it (→ locales/records/{locale}.json),
|
|
239
239
|
* and a localized prosemirror BODY's target locales are captured as either a
|
|
240
240
|
* structural map or, when `freeformRelPath` is given, a free-form body override.
|
|
241
241
|
* @param {string} [params.freeformRelPath] - the free-form path for this record's
|
|
242
|
-
* content body (
|
|
242
|
+
* content body (buildFreeformRecordPath); lets a target-locale full-doc
|
|
243
243
|
* body be written under locales/freeform/{locale}/ instead of being dropped.
|
|
244
244
|
* @returns {string} the source-file text
|
|
245
245
|
*/
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -313,9 +313,9 @@ function lowerField(rawField, resolve, optResolve, path = '') {
|
|
|
313
313
|
// Note the local checker is unaffected and still stricter: `validateItem`
|
|
314
314
|
// works on the IR, where the field is `type: array`, so it verifies the value
|
|
315
315
|
// IS a list. Only what the registry is told changes.
|
|
316
|
-
const { items: _items, ...
|
|
316
|
+
const { items: _items, ...rest } = field
|
|
317
317
|
return lowerLeaf(
|
|
318
|
-
{ ...
|
|
318
|
+
{ ...rest, ...items, type: items ? items.type : 'json' },
|
|
319
319
|
resolve,
|
|
320
320
|
optResolve,
|
|
321
321
|
{ multiple: true }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Read a file-based collection's ORIGINAL source records for sync — the author's
|
|
2
|
+
// files, untouched. This is deliberately NOT `processQueries`
|
|
3
|
+
// (`build/src/site/query-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
|
+
// ⭐ THE FLAT-VS-RECURSIVE DIVERGENCE THIS FILE USED TO WARN ABOUT IS CLOSED.
|
|
11
|
+
// It reported records below a collection's top level as building and rendering
|
|
12
|
+
// locally while being absent from the synced set — the delivery lane recursed,
|
|
13
|
+
// this one was one level deep. `entities/{schema}/` has no nesting to disagree
|
|
14
|
+
// about (`site/entity-pool.js` refuses it), and placement moved to `records.yml`,
|
|
15
|
+
// so both lanes now read the same flat pool. The warning went with the ambiguity.
|
|
16
|
+
//
|
|
17
|
+
// A collection `.md` is NOT a page-section `.md`: its frontmatter is structured
|
|
18
|
+
// DATA whose schema is the collection type's data schema (the `model:` Model), and
|
|
19
|
+
// its body is the value of the Model's content body field (a markup `text` field,
|
|
20
|
+
// or a `format: prosemirror` json field) — not foundation/runtime config. See
|
|
21
|
+
// docs/reference/entity-content.md §"Markdown (frontmatter + body)".
|
|
22
|
+
|
|
23
|
+
import { readFile } from 'node:fs/promises'
|
|
24
|
+
import { basename, extname } from 'node:path'
|
|
25
|
+
import yaml from 'js-yaml'
|
|
26
|
+
import { parseBibtex } from '@citestyle/bibtex'
|
|
27
|
+
|
|
28
|
+
const SOURCE_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split YAML frontmatter from a markdown body. Mirrors the collection
|
|
32
|
+
* processor's split (`---\n` delimited) so a record read here re-renders to the
|
|
33
|
+
* same shape the back-fill writer produces. A file with no frontmatter yields an
|
|
34
|
+
* empty mapping and the whole text as body.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} raw
|
|
37
|
+
* @returns {{ frontmatter: object, body: string }}
|
|
38
|
+
*/
|
|
39
|
+
export function parseFrontmatter(raw) {
|
|
40
|
+
if (!raw.trimStart().startsWith('---')) {
|
|
41
|
+
return { frontmatter: {}, body: raw }
|
|
42
|
+
}
|
|
43
|
+
const parts = raw.split('---\n')
|
|
44
|
+
if (parts.length < 3) {
|
|
45
|
+
return { frontmatter: {}, body: raw }
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const frontmatter = yaml.load(parts[1]) || {}
|
|
49
|
+
const body = parts.slice(2).join('---\n')
|
|
50
|
+
return { frontmatter, body }
|
|
51
|
+
} catch {
|
|
52
|
+
return { frontmatter: {}, body: raw }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Format key from a file extension. Single source of the format vocabulary the
|
|
57
|
+
// reader emits and the writer dispatches on.
|
|
58
|
+
function formatFor(ext) {
|
|
59
|
+
if (ext === '.md') return 'md'
|
|
60
|
+
if (ext === '.json') return 'json'
|
|
61
|
+
if (ext === '.yml' || ext === '.yaml') return 'yaml'
|
|
62
|
+
if (ext === '.bib') return 'bib'
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read ONE entity file into its raw source record(s).
|
|
68
|
+
*
|
|
69
|
+
* ⭐ THE FILE IS THE UNIT NOW. `entities/{schema}/` supplies the model, so
|
|
70
|
+
* nothing above this needs a directory scan to know what a file is — the pool
|
|
71
|
+
* reader (`site/entity-pool.js`) already walked the tree and paired each file
|
|
72
|
+
* with its schema. A single-record file yields one record; array-form YAML/JSON
|
|
73
|
+
* and BibTeX yield several, each carrying its own slug.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} filepath - absolute path to one entity source file
|
|
76
|
+
* @returns {Promise<Array<{ slug, format, data, body, sourceFile, multiRecord }>>}
|
|
77
|
+
*/
|
|
78
|
+
export async function readEntityFile(filepath) {
|
|
79
|
+
return readOneFile(filepath)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readOneFile(filepath) {
|
|
83
|
+
const ext = extname(filepath).toLowerCase()
|
|
84
|
+
const format = formatFor(ext)
|
|
85
|
+
const slugFromName = basename(filepath, ext)
|
|
86
|
+
const raw = await readFile(filepath, 'utf-8')
|
|
87
|
+
|
|
88
|
+
if (format === 'md') {
|
|
89
|
+
const { frontmatter, body } = parseFrontmatter(raw)
|
|
90
|
+
const slug = frontmatter.slug || slugFromName
|
|
91
|
+
return [{ slug, format, data: frontmatter, body, sourceFile: filepath, multiRecord: false }]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (format === 'bib') {
|
|
95
|
+
// BibTeX always yields an array — the cite key is the slug/$id. Multi-record
|
|
96
|
+
// file → write-back deferred (no natural in-file `$uuid` slot in v1).
|
|
97
|
+
const entries = parseBibtex(raw)
|
|
98
|
+
return entries
|
|
99
|
+
.filter((e) => e && e.id)
|
|
100
|
+
.map((e) => ({ slug: e.id, format, data: e, body: undefined, sourceFile: filepath, multiRecord: true }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// yaml / json
|
|
104
|
+
const data = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
|
|
105
|
+
if (Array.isArray(data)) {
|
|
106
|
+
// Many records in one file — each carries its own slug. Write-back deferred.
|
|
107
|
+
return data
|
|
108
|
+
.filter((item) => item && typeof item === 'object')
|
|
109
|
+
.map((item) => ({
|
|
110
|
+
slug: item.slug,
|
|
111
|
+
format,
|
|
112
|
+
data: item,
|
|
113
|
+
body: undefined,
|
|
114
|
+
sourceFile: filepath,
|
|
115
|
+
multiRecord: true,
|
|
116
|
+
}))
|
|
117
|
+
}
|
|
118
|
+
const mapping = data && typeof data === 'object' ? data : {}
|
|
119
|
+
const slug = mapping.slug || slugFromName
|
|
120
|
+
return [{ slug, format, data: mapping, body: undefined, sourceFile: filepath, multiRecord: false }]
|
|
121
|
+
}
|
|
122
|
+
|