@uniweb/build 0.29.1 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- 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 +35 -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 +92 -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 +30 -5
- package/src/uwx/site.js +295 -27
- 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
package/src/site/data-fetcher.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Supports:
|
|
8
8
|
* - Simple string paths: "/data/team.json"
|
|
9
9
|
* - Full config objects with schema, prerender, merge, transform options
|
|
10
|
-
* -
|
|
10
|
+
* - Named-query references: { query: 'articles', limit: 3 }
|
|
11
11
|
* - Local JSON/YAML files
|
|
12
12
|
* - Remote URLs
|
|
13
13
|
* - Transform paths to extract nested data
|
|
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import yaml from 'js-yaml'
|
|
23
|
-
import { matchWhere,
|
|
23
|
+
import { matchWhere, queryDataUrl } from '@uniweb/core'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Infer schema name from path or URL
|
|
@@ -236,7 +236,7 @@ export function applyPostProcessing(data, config) {
|
|
|
236
236
|
* // Returns: { path: '/team', schema: 'person', prerender: false, merge: false }
|
|
237
237
|
*
|
|
238
238
|
* // Collection reference
|
|
239
|
-
* parseFetchConfig({
|
|
239
|
+
* parseFetchConfig({ query: 'articles', limit: 3, sort: 'date desc' })
|
|
240
240
|
* // Returns: { path: '/data/articles.json', schema: 'articles', limit: 3, sort: 'date desc', ... }
|
|
241
241
|
*/
|
|
242
242
|
// ─── Unrecognized-key reporting ───────────────────────────────────────
|
|
@@ -252,8 +252,8 @@ export function applyPostProcessing(data, config) {
|
|
|
252
252
|
// once per key name per process so a 200-record build does not print 200 lines.
|
|
253
253
|
const RECOGNIZED_FETCH_KEYS = {
|
|
254
254
|
refine: new Set(['refine', 'inherit', 'detail', 'limit', 'sort', 'where', 'filter']),
|
|
255
|
-
|
|
256
|
-
'
|
|
255
|
+
query: new Set([
|
|
256
|
+
'query', 'schema', 'prerender', 'merge', 'transform',
|
|
257
257
|
'where', 'limit', 'sort', 'detailPage', 'filter',
|
|
258
258
|
]),
|
|
259
259
|
source: new Set([
|
|
@@ -331,14 +331,27 @@ export function parseFetchConfig(fetch) {
|
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
334
|
+
// ⛔ THE RETIRED SPELLING IS AN ERROR, NOT A WARNING. An unrecognized key is
|
|
335
|
+
// warned about and IGNORED, so `fetch: { collection: X }` would fall through to
|
|
336
|
+
// the source shape, find neither `path` nor `url`, and resolve to null — a
|
|
337
|
+
// SILENTLY EMPTY result, which is worse than the old name simply working. The
|
|
338
|
+
// author sees a page render with no data and nothing saying why.
|
|
339
|
+
if (fetch.collection !== undefined) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`[uniweb] fetch: \`collection: ${JSON.stringify(fetch.collection)}\` is retired. ` +
|
|
342
|
+
`Write \`query: ${JSON.stringify(fetch.collection)}\` and declare it in queries.yml. ` +
|
|
343
|
+
`A query names a schema and the folder supplies its records.`
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Named-query reference: { query: 'articles', limit: 3 }
|
|
348
|
+
if (fetch.query) {
|
|
349
|
+
warnUnknownFetchKeys(fetch, 'query')
|
|
337
350
|
if (fetch.filter !== undefined) warnFilterDeprecated()
|
|
338
351
|
return {
|
|
339
|
-
path:
|
|
352
|
+
path: queryDataUrl(fetch.query),
|
|
340
353
|
url: undefined,
|
|
341
|
-
schema: fetch.schema || fetch.
|
|
354
|
+
schema: fetch.schema || fetch.query,
|
|
342
355
|
prerender: fetch.prerender ?? true,
|
|
343
356
|
merge: fetch.merge ?? false,
|
|
344
357
|
transform: fetch.transform,
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// A site's ENTITY POOL — every stored thing on disk, and the model each one has.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. That is the whole point of
|
|
4
|
+
// this directory, and it is the de-conflation the records model is built on:
|
|
5
|
+
// `collections/<name>/` used to mean three things at once — these files are
|
|
6
|
+
// entities, their schema is `@/<name>`, and they are grouped as `<name>` for
|
|
7
|
+
// placement. `entities/{schema}/` declares only the first two. Grouping moved to
|
|
8
|
+
// `records.yml`, which is what makes an entity a RECORD.
|
|
9
|
+
//
|
|
10
|
+
// ⇒ So nothing here reads a query, a folder, or any config. A pool is a fact
|
|
11
|
+
// about the filesystem.
|
|
12
|
+
//
|
|
13
|
+
// ⭐ DEPTH NAMES THE SCOPE — the schema-ref grammar, spelled as directories:
|
|
14
|
+
//
|
|
15
|
+
// entities/person/ada.md → @/person (the foundation's own)
|
|
16
|
+
// entities/std/person/ada.md → @std/person (the shared standard set)
|
|
17
|
+
// entities/acme/project/x.md → @acme/project (an org's)
|
|
18
|
+
//
|
|
19
|
+
// matching `build/src/resolve-data-schema.js`, which is the only grammar there
|
|
20
|
+
// is: a bare directory name can mean `@/<name>` and nothing else.
|
|
21
|
+
//
|
|
22
|
+
// ⛔ BARE, NOT `entities/@std/`. Measured: `@` is a reserved indicator in YAML
|
|
23
|
+
// 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
|
|
24
|
+
// `bad indentation of a sequence entry`, which names neither the cause nor the
|
|
25
|
+
// fix. The `@` form would force quotes on the most common line in `records.yml`
|
|
26
|
+
// and put two spellings in one list.
|
|
27
|
+
//
|
|
28
|
+
// ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
|
|
29
|
+
// total: it is the FILE's depth that decides, so one path answers the question
|
|
30
|
+
// with nothing to classify and no ambiguous case to resolve.
|
|
31
|
+
//
|
|
32
|
+
// ⭐ That costs nothing — it CLOSES a divergence that was live and silent.
|
|
33
|
+
// `uwx/collection-source.js::reportNestedRecords` warns today that records below
|
|
34
|
+
// a collection's top level "build and render locally but are absent from the
|
|
35
|
+
// synced set": the delivery lane recursed and the sync lane was one level deep.
|
|
36
|
+
// `records.yml` replaces on-disk nesting outright, so the two lanes stop
|
|
37
|
+
// disagreeing rather than being taught to agree.
|
|
38
|
+
|
|
39
|
+
import { readdir } from 'node:fs/promises'
|
|
40
|
+
import { existsSync } from 'node:fs'
|
|
41
|
+
import { join, extname, basename } from 'node:path'
|
|
42
|
+
|
|
43
|
+
/** Where a site's entities live, relative to its root. */
|
|
44
|
+
export const ENTITIES_DIR = 'entities'
|
|
45
|
+
|
|
46
|
+
/** Source extensions an entity file may have. Mirrors the sync-lane reader. */
|
|
47
|
+
export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
48
|
+
|
|
49
|
+
const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The schema ref a pool path names.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} dirs - the directory segments below `entities/`
|
|
55
|
+
* @returns {string|null} the ref, or null when the depth names no schema
|
|
56
|
+
*/
|
|
57
|
+
export function schemaForPoolDirs(dirs) {
|
|
58
|
+
if (dirs.length === 1) return `@/${dirs[0]}`
|
|
59
|
+
if (dirs.length === 2) return `@${dirs[0]}/${dirs[1]}`
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a schema's entities live — the inverse of `schemaForPoolDirs`.
|
|
65
|
+
*
|
|
66
|
+
* ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
|
|
67
|
+
* exists at all: the reader derives a model from a path and the pull side derives
|
|
68
|
+
* a path from a model, and if those two ever disagree a pulled record lands
|
|
69
|
+
* somewhere the next build reads as a different schema. Same rule as
|
|
70
|
+
* `deferredFromSchema` — a deriver and its recognizer must not be two copies.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} schema - a ref: `@/name` or `@org/name`
|
|
73
|
+
* @returns {string[]|null} the directory segments below `entities/`, or null for
|
|
74
|
+
* a ref this layout cannot express
|
|
75
|
+
*/
|
|
76
|
+
export function poolDirsForSchema(schema) {
|
|
77
|
+
if (typeof schema !== 'string') return null
|
|
78
|
+
const self = /^@\/([^/]+)$/.exec(schema)
|
|
79
|
+
if (self) return [self[1]]
|
|
80
|
+
const scoped = /^@([^/]+)\/([^/]+)$/.exec(schema)
|
|
81
|
+
if (scoped) return [scoped[1], scoped[2]]
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Both readings of a 2-segment pool path, for an error that has to name them.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ A reader who mistakes `entities/person/2024/ada.md` for "the `person`
|
|
89
|
+
* schema, organised by year" needs to be told what the build actually did with
|
|
90
|
+
* it, not merely that something did not resolve. The wrong reading is the
|
|
91
|
+
* plausible one, so the message carries both.
|
|
92
|
+
*/
|
|
93
|
+
export function poolPathReadings(dirs) {
|
|
94
|
+
return {
|
|
95
|
+
read: schemaForPoolDirs(dirs),
|
|
96
|
+
alternative: dirs.length === 2 ? `@/${dirs[0]}` : null,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a site's entity pool.
|
|
102
|
+
*
|
|
103
|
+
* Returns entities in a stable, path-sorted order — the wire's package digest
|
|
104
|
+
* depends on it — each carrying the model its path declares.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ NOTHING HERE RESOLVES A SCHEMA. Whether `@std/person` is a schema this site
|
|
107
|
+
* can actually see is a question for whoever holds the foundation's built schema
|
|
108
|
+
* map, and only that caller can raise the error §4 of the model asks for. This
|
|
109
|
+
* reports the pool's SHAPE — a file with no schema above it, a file nested too
|
|
110
|
+
* deep — because those are answerable from the filesystem alone.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} siteRoot
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {string} [opts.dir] - override the pool directory (site-root-relative)
|
|
115
|
+
* @returns {Promise<{
|
|
116
|
+
* entities: Array<{ id, schema, slug, dirs, relPath, absPath, ext }>,
|
|
117
|
+
* errors: string[],
|
|
118
|
+
* exists: boolean,
|
|
119
|
+
* }>}
|
|
120
|
+
* `id` is the entity's path under `entities/` without its extension — unique
|
|
121
|
+
* by construction, stable across pushes, and derivable identically on both
|
|
122
|
+
* sides without either lane holding the other's ids.
|
|
123
|
+
*/
|
|
124
|
+
export async function readEntityPool(siteRoot, opts = {}) {
|
|
125
|
+
const rel = opts.dir || ENTITIES_DIR
|
|
126
|
+
const base = join(siteRoot, rel)
|
|
127
|
+
if (!existsSync(base)) return { entities: [], errors: [], exists: false }
|
|
128
|
+
|
|
129
|
+
const entities = []
|
|
130
|
+
const errors = []
|
|
131
|
+
|
|
132
|
+
const walk = async (dir, dirs) => {
|
|
133
|
+
let listing
|
|
134
|
+
try {
|
|
135
|
+
listing = await readdir(dir, { withFileTypes: true })
|
|
136
|
+
} catch {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
for (const e of listing.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
140
|
+
if (isHidden(e.name)) continue
|
|
141
|
+
const full = join(dir, e.name)
|
|
142
|
+
if (e.isDirectory()) {
|
|
143
|
+
if (dirs.length >= 2) {
|
|
144
|
+
// Deeper than a schema dir. Say what the path was read AS, because the
|
|
145
|
+
// author's intent (organising records inside a schema) is the reading
|
|
146
|
+
// this layout deliberately does not have.
|
|
147
|
+
const { read } = poolPathReadings(dirs)
|
|
148
|
+
errors.push(
|
|
149
|
+
`${rel}/${[...dirs, e.name].join('/')}/ is nested below a schema folder. ` +
|
|
150
|
+
`\`${rel}/\` declares a model and nothing else — \`${[...dirs].join('/')}\` ` +
|
|
151
|
+
`already names ${read}, so there is no meaning left for a folder inside it. ` +
|
|
152
|
+
`Organise records in records.yml (a \`folder:\` entry), not on disk.`
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
await walk(full, [...dirs, e.name])
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (!e.isFile()) continue
|
|
160
|
+
const ext = extname(e.name).toLowerCase()
|
|
161
|
+
if (!ENTITY_EXTENSIONS.has(ext)) continue
|
|
162
|
+
if (dirs.length === 0) {
|
|
163
|
+
errors.push(
|
|
164
|
+
`${rel}/${e.name} sits directly in \`${rel}/\`, which names no model. ` +
|
|
165
|
+
`Move it under a schema folder — \`${rel}/<name>/\` for \`@/<name>\`, ` +
|
|
166
|
+
`or \`${rel}/<org>/<name>/\` for \`@<org>/<name>\`.`
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
// ⛔ THE SLUG IS THE FILENAME STEM, WHOLE — nothing is stripped from it.
|
|
171
|
+
// A leading number orders a set (`01-`, `02-`) at least as often as it is a
|
|
172
|
+
// DATE (`2026-03-…`), and the two are indistinguishable by shape, so
|
|
173
|
+
// consuming one into the record's name mangles the other. A number is read
|
|
174
|
+
// to SORT by (`compareByNumericPrefix`) and never to rename.
|
|
175
|
+
const slug = basename(e.name, ext)
|
|
176
|
+
entities.push({
|
|
177
|
+
id: [...dirs, slug].join('/'),
|
|
178
|
+
schema: schemaForPoolDirs(dirs),
|
|
179
|
+
slug,
|
|
180
|
+
file: e.name,
|
|
181
|
+
dirs: [...dirs],
|
|
182
|
+
relPath: [rel, ...dirs, e.name].join('/'),
|
|
183
|
+
poolPath: [...dirs, e.name].join('/'),
|
|
184
|
+
absPath: full,
|
|
185
|
+
ext,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await walk(base, [])
|
|
191
|
+
return { entities, errors, exists: true }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The pool grouped by the schema each path declares.
|
|
196
|
+
*
|
|
197
|
+
* ⭐ This is what a query resolves against: it names a `schema:` and the pool
|
|
198
|
+
* follows, so there is no disk path for it to name. (`source:` survives for
|
|
199
|
+
* REMOTE sources, where the address is genuinely external.)
|
|
200
|
+
*
|
|
201
|
+
* @returns {Map<string, Array>} schema ref → entities, in pool order
|
|
202
|
+
*/
|
|
203
|
+
export function groupPoolBySchema(entities) {
|
|
204
|
+
const bySchema = new Map()
|
|
205
|
+
for (const e of entities || []) {
|
|
206
|
+
if (!e?.schema) continue
|
|
207
|
+
if (!bySchema.has(e.schema)) bySchema.set(e.schema, [])
|
|
208
|
+
bySchema.get(e.schema).push(e)
|
|
209
|
+
}
|
|
210
|
+
return bySchema
|
|
211
|
+
}
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
|
|
5
5
|
//
|
|
6
6
|
// refine refine · inherit · detail · limit · sort · where · filter
|
|
7
|
-
//
|
|
7
|
+
// query query · schema · … — and NOT `path`/`url`
|
|
8
8
|
// source path · url · schema · …
|
|
9
|
+
|
|
9
10
|
//
|
|
10
|
-
// The build RESOLVES a `
|
|
11
|
-
// declaration that rides the sync wire carries BOTH the authored `
|
|
12
|
-
//
|
|
11
|
+
// The build RESOLVES a `query:` shorthand into a concrete location, so the
|
|
12
|
+
// declaration that rides the sync wire carries BOTH the authored `query` and the
|
|
13
|
+
// derived `path`. Projecting that back verbatim writes a file that is neither
|
|
13
14
|
// shape cleanly: `collection` wins the classification, and the `path` beside it is
|
|
14
15
|
// then an unrecognized key on its own declaration.
|
|
15
16
|
//
|
|
@@ -19,33 +20,33 @@
|
|
|
19
20
|
// fetch:
|
|
20
21
|
// path: /data/members.json ← derived; also a build artifact path
|
|
21
22
|
// schema: members
|
|
22
|
-
//
|
|
23
|
+
// query: members ← what the author actually wrote
|
|
23
24
|
//
|
|
24
25
|
// [uniweb] fetch: unrecognized key "path" was ignored. Keys recognized on this
|
|
25
|
-
// declaration:
|
|
26
|
+
// declaration: detailPage, filter, limit, merge, prerender, query, schema,
|
|
26
27
|
// sort, transform, where.
|
|
27
28
|
//
|
|
28
29
|
// ⭐ The round trip has to invert the resolution, not copy it. `/data/<name>.json`
|
|
29
|
-
// is a materialization of a
|
|
30
|
-
//
|
|
30
|
+
// is a materialization of a query, never its definition — so it is precisely the
|
|
31
|
+
// thing an authored file should not contain.
|
|
31
32
|
//
|
|
32
33
|
// ⚖️ DROPS ONLY WHAT IS DERIVABLE, not everything unrecognized. A key we do not know
|
|
33
34
|
// might be one a newer producer authored, and silently discarding it on every pull
|
|
34
35
|
// would make the round trip lossy in a way nothing reports. `path` and `url` beside
|
|
35
|
-
// a `
|
|
36
|
-
//
|
|
36
|
+
// a `query` are recoverable from the query itself; anything else survives and the
|
|
37
|
+
// validator's warning stays the honest signal.
|
|
37
38
|
|
|
38
39
|
/** Which of the three shapes a declaration is — the same order `data-fetcher` uses. */
|
|
39
40
|
export function fetchShapeOf(fetch) {
|
|
40
41
|
if (!fetch || typeof fetch !== 'object') return null
|
|
41
42
|
if (fetch.refine === true || fetch.inherit === true) return 'refine'
|
|
42
|
-
if (fetch.
|
|
43
|
+
if (fetch.query) return 'query'
|
|
43
44
|
return 'source'
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Keys a shape derives rather than the author writing them. */
|
|
47
48
|
const DERIVED_BY_SHAPE = {
|
|
48
|
-
|
|
49
|
+
query: ['path', 'url'],
|
|
49
50
|
refine: [],
|
|
50
51
|
source: []
|
|
51
52
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* It began in `./config.js`, which imports a Vite plugin. That made it unreachable
|
|
8
8
|
* from any lane that must not pull Vite — and the sync lane, needing exactly this
|
|
9
|
-
* answer, grew its own weaker copy instead (`../uwx/
|
|
9
|
+
* answer, grew its own weaker copy instead (`../uwx/records.js`), which read
|
|
10
10
|
* `package.json` `dependencies.foundation`: a key no current template produces, so
|
|
11
11
|
* it returned null for every scaffolded site. A third copy in the CLI describes
|
|
12
12
|
* itself as mirroring "a subset of" this one.
|
package/src/site/index.js
CHANGED
|
@@ -38,10 +38,10 @@ export {
|
|
|
38
38
|
isPdfFile
|
|
39
39
|
} from './advanced-processors.js'
|
|
40
40
|
export {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} from './
|
|
41
|
+
processQueries,
|
|
42
|
+
writeQueryFiles,
|
|
43
|
+
getQueryLastModified
|
|
44
|
+
} from './query-processor.js'
|
|
45
45
|
export { collectSchemalessData, collectSchemalessDataAssets, rewriteSchemalessDataAssets } from './schemaless-data.js'
|
|
46
46
|
export {
|
|
47
47
|
parseFetchConfig,
|
package/src/site/plugin.js
CHANGED
|
@@ -49,7 +49,8 @@ import {
|
|
|
49
49
|
import { collectSiteContent, mountEntriesOf } from './content-collector.js'
|
|
50
50
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
51
51
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
52
|
-
import {
|
|
52
|
+
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
53
|
+
import { ENTITIES_DIR } from './entity-pool.js'
|
|
53
54
|
import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
|
|
54
55
|
import { shouldSplitContent } from './split-content.js'
|
|
55
56
|
import { FONT_LINKS_MARKER } from './head-markers.js'
|
|
@@ -566,13 +567,13 @@ export function siteContentPlugin(options = {}) {
|
|
|
566
567
|
let watcher = null
|
|
567
568
|
let server = null
|
|
568
569
|
let localeTranslations = {} // Cache: { locale: translations }
|
|
569
|
-
let
|
|
570
|
+
let recordTranslations = {} // Cache: { locale: record translations }
|
|
570
571
|
let localesDir = 'locales' // Default, updated from site config
|
|
571
|
-
let
|
|
572
|
+
let queriesConfig = null // Cached for watcher setup
|
|
572
573
|
let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
|
|
573
574
|
let resolvedMountPaths = [] // Absolute dirs mounted under pages/ via site.yml paths:
|
|
574
575
|
let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
|
|
575
|
-
let
|
|
576
|
+
let resolvedEntitiesDir = null // site.yml `paths.entities`, site-root-relative
|
|
576
577
|
let headHtml = '' // Contents of site/head.html for injection
|
|
577
578
|
let basePath = '/' // Vite's config.base, always has trailing slash
|
|
578
579
|
|
|
@@ -602,12 +603,12 @@ export function siteContentPlugin(options = {}) {
|
|
|
602
603
|
/**
|
|
603
604
|
* Load collection translations for a specific locale
|
|
604
605
|
*/
|
|
605
|
-
async function
|
|
606
|
-
if (
|
|
607
|
-
return
|
|
606
|
+
async function loadRecordTranslations(locale) {
|
|
607
|
+
if (recordTranslations[locale]) {
|
|
608
|
+
return recordTranslations[locale]
|
|
608
609
|
}
|
|
609
610
|
|
|
610
|
-
const localePath = join(resolvedSitePath, localesDir, '
|
|
611
|
+
const localePath = join(resolvedSitePath, localesDir, 'records', `${locale}.json`)
|
|
611
612
|
if (!existsSync(localePath)) {
|
|
612
613
|
return null
|
|
613
614
|
}
|
|
@@ -615,7 +616,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
615
616
|
try {
|
|
616
617
|
const content = await readFile(localePath, 'utf-8')
|
|
617
618
|
const translations = JSON.parse(content)
|
|
618
|
-
|
|
619
|
+
recordTranslations[locale] = translations
|
|
619
620
|
return translations
|
|
620
621
|
} catch {
|
|
621
622
|
return null
|
|
@@ -792,7 +793,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
792
793
|
try {
|
|
793
794
|
// Do an early content collection to get the collections config
|
|
794
795
|
const earlyContent = await collectForBundle(resolvedSitePath, { foundationPath })
|
|
795
|
-
|
|
796
|
+
queriesConfig = earlyContent.config?.queries
|
|
796
797
|
|
|
797
798
|
// Resolve content directory paths from site.yml paths: group
|
|
798
799
|
const paths = earlyContent?.config?.paths || {}
|
|
@@ -802,18 +803,16 @@ export function siteContentPlugin(options = {}) {
|
|
|
802
803
|
resolvedLayoutPath = paths.layout
|
|
803
804
|
? resolve(resolvedSitePath, paths.layout)
|
|
804
805
|
: resolve(resolvedSitePath, 'layout')
|
|
805
|
-
|
|
806
|
-
? resolve(resolvedSitePath, paths.collections)
|
|
807
|
-
: null
|
|
806
|
+
resolvedEntitiesDir = paths.entities || null
|
|
808
807
|
resolvedMountPaths = mountEntriesOf(paths).map(([, rel]) => resolve(resolvedSitePath, rel))
|
|
809
808
|
|
|
810
|
-
if (
|
|
811
|
-
console.log('[site-content]
|
|
812
|
-
const
|
|
813
|
-
await
|
|
809
|
+
if (queriesConfig) {
|
|
810
|
+
console.log('[site-content] Materializing queries...')
|
|
811
|
+
const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath)
|
|
812
|
+
await writeQueryFiles(resolvedSitePath, byQuery, queriesConfig)
|
|
814
813
|
}
|
|
815
814
|
} catch (err) {
|
|
816
|
-
console.warn('[site-content] Early
|
|
815
|
+
console.warn('[site-content] Early query materialization failed:', err.message)
|
|
817
816
|
}
|
|
818
817
|
}
|
|
819
818
|
|
|
@@ -828,9 +827,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
828
827
|
resolvedLayoutPath = paths.layout
|
|
829
828
|
? resolve(resolvedSitePath, paths.layout)
|
|
830
829
|
: resolve(resolvedSitePath, 'layout')
|
|
831
|
-
|
|
832
|
-
? resolve(resolvedSitePath, paths.collections)
|
|
833
|
-
: null
|
|
830
|
+
resolvedEntitiesDir = paths.entities || null
|
|
834
831
|
}
|
|
835
832
|
},
|
|
836
833
|
|
|
@@ -845,13 +842,13 @@ export function siteContentPlugin(options = {}) {
|
|
|
845
842
|
headHtml = await loadHeadHtml()
|
|
846
843
|
console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
|
|
847
844
|
|
|
848
|
-
//
|
|
845
|
+
// Materialize each query if the site declares any.
|
|
849
846
|
// In dev mode, this was already done in configResolved (before server starts)
|
|
850
847
|
// In production, do it here
|
|
851
|
-
if (isProduction && siteContent.config?.
|
|
852
|
-
console.log('[site-content]
|
|
853
|
-
const
|
|
854
|
-
await
|
|
848
|
+
if (isProduction && siteContent.config?.queries) {
|
|
849
|
+
console.log('[site-content] Materializing queries...')
|
|
850
|
+
const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedEntitiesDir, basePath)
|
|
851
|
+
await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
|
|
855
852
|
}
|
|
856
853
|
|
|
857
854
|
// Execute data fetches in dev mode
|
|
@@ -867,7 +864,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
867
864
|
|
|
868
865
|
// Clear translation cache on rebuild
|
|
869
866
|
localeTranslations = {}
|
|
870
|
-
|
|
867
|
+
recordTranslations = {}
|
|
871
868
|
} catch (err) {
|
|
872
869
|
console.error('[site-content] Failed to collect content:', err.message)
|
|
873
870
|
// Production: a failed collect must fail the build — falling through
|
|
@@ -909,22 +906,22 @@ export function siteContentPlugin(options = {}) {
|
|
|
909
906
|
}
|
|
910
907
|
|
|
911
908
|
// Debounce collection rebuilds separately (writes to file system)
|
|
912
|
-
let
|
|
913
|
-
const
|
|
914
|
-
if (
|
|
915
|
-
|
|
916
|
-
console.log('[site-content]
|
|
909
|
+
let recordRebuildTimeout = null
|
|
910
|
+
const scheduleRecordRebuild = () => {
|
|
911
|
+
if (recordRebuildTimeout) clearTimeout(recordRebuildTimeout)
|
|
912
|
+
recordRebuildTimeout = setTimeout(async () => {
|
|
913
|
+
console.log('[site-content] Records changed, regenerating JSON...')
|
|
917
914
|
try {
|
|
918
|
-
// Use
|
|
919
|
-
const
|
|
920
|
-
if (
|
|
921
|
-
const processed = await
|
|
922
|
-
await
|
|
915
|
+
// Use queriesConfig (cached from configResolved) or siteContent
|
|
916
|
+
const byQuery = queriesConfig || siteContent?.config?.queries
|
|
917
|
+
if (byQuery) {
|
|
918
|
+
const processed = await processQueries(resolvedSitePath, byQuery, resolvedEntitiesDir, basePath)
|
|
919
|
+
await writeQueryFiles(resolvedSitePath, processed, byQuery)
|
|
923
920
|
}
|
|
924
921
|
// Send full reload to client
|
|
925
922
|
server.ws.send({ type: 'full-reload' })
|
|
926
923
|
} catch (err) {
|
|
927
|
-
console.error('[site-content]
|
|
924
|
+
console.error('[site-content] Record rebuild failed:', err.message)
|
|
928
925
|
}
|
|
929
926
|
}, 100)
|
|
930
927
|
}
|
|
@@ -991,23 +988,21 @@ export function siteContentPlugin(options = {}) {
|
|
|
991
988
|
// head.html may not exist, that's ok
|
|
992
989
|
}
|
|
993
990
|
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
991
|
+
// ⭐ WATCH THE POOL, NOT A DIRECTORY PER QUERY. This used to resolve
|
|
992
|
+
// `config.path` for each declaration and watch each one, so a schema
|
|
993
|
+
// folder no query had mentioned yet — a new one, mid-session — was
|
|
994
|
+
// watched by nothing and its records never rebuilt. `entities/` is one
|
|
995
|
+
// recursive root and covers every schema, present and future.
|
|
996
|
+
{
|
|
997
|
+
const contentPaths = new Set([
|
|
998
|
+
resolve(resolvedSitePath, resolvedEntitiesDir || ENTITIES_DIR)
|
|
999
|
+
])
|
|
1005
1000
|
|
|
1006
1001
|
for (const contentPath of contentPaths) {
|
|
1007
1002
|
if (existsSync(contentPath)) {
|
|
1008
1003
|
try {
|
|
1009
|
-
watchers.push(watch(contentPath, { recursive: true },
|
|
1010
|
-
console.log(`[site-content] Watching ${contentPath} for
|
|
1004
|
+
watchers.push(watch(contentPath, { recursive: true }, scheduleRecordRebuild))
|
|
1005
|
+
console.log(`[site-content] Watching ${contentPath} for record changes`)
|
|
1011
1006
|
} catch (err) {
|
|
1012
1007
|
console.warn('[site-content] Could not watch content directory:', err.message)
|
|
1013
1008
|
}
|
|
@@ -1028,7 +1023,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
1028
1023
|
const localeWatcher = watch(localesPath, { recursive: false }, () => {
|
|
1029
1024
|
console.log('[site-content] Translation files changed, clearing cache...')
|
|
1030
1025
|
localeTranslations = {}
|
|
1031
|
-
|
|
1026
|
+
recordTranslations = {}
|
|
1032
1027
|
server.ws.send({ type: 'full-reload' })
|
|
1033
1028
|
})
|
|
1034
1029
|
additionalWatchers.push(localeWatcher)
|
|
@@ -1053,16 +1048,16 @@ export function siteContentPlugin(options = {}) {
|
|
|
1053
1048
|
}
|
|
1054
1049
|
|
|
1055
1050
|
// Watch collection translations directory
|
|
1056
|
-
const
|
|
1057
|
-
if (existsSync(
|
|
1051
|
+
const recordLocalesPath = resolve(localesPath, 'records')
|
|
1052
|
+
if (existsSync(recordLocalesPath)) {
|
|
1058
1053
|
try {
|
|
1059
|
-
const collWatcher = watch(
|
|
1060
|
-
console.log('[site-content]
|
|
1061
|
-
|
|
1054
|
+
const collWatcher = watch(recordLocalesPath, { recursive: false }, () => {
|
|
1055
|
+
console.log('[site-content] Record translations changed, clearing cache...')
|
|
1056
|
+
recordTranslations = {}
|
|
1062
1057
|
server.ws.send({ type: 'full-reload' })
|
|
1063
1058
|
})
|
|
1064
1059
|
additionalWatchers.push(collWatcher)
|
|
1065
|
-
console.log(`[site-content] Watching ${
|
|
1060
|
+
console.log(`[site-content] Watching ${recordLocalesPath} for record translation changes`)
|
|
1066
1061
|
} catch (err) {
|
|
1067
1062
|
// collections locales dir may not exist, that's ok
|
|
1068
1063
|
}
|
|
@@ -1212,7 +1207,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
1212
1207
|
if (localeDataMatch) {
|
|
1213
1208
|
const locale = localeDataMatch[1]
|
|
1214
1209
|
const filename = localeDataMatch[2]
|
|
1215
|
-
const
|
|
1210
|
+
const queryName = filename.replace('.json', '')
|
|
1216
1211
|
const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
|
|
1217
1212
|
|
|
1218
1213
|
if (existsSync(sourcePath)) {
|
|
@@ -1221,15 +1216,15 @@ export function siteContentPlugin(options = {}) {
|
|
|
1221
1216
|
const items = JSON.parse(raw)
|
|
1222
1217
|
|
|
1223
1218
|
// Load collection translations for this locale
|
|
1224
|
-
const translations = await
|
|
1219
|
+
const translations = await loadRecordTranslations(locale) || {}
|
|
1225
1220
|
|
|
1226
1221
|
// Check for free-form translations
|
|
1227
1222
|
const freeformDir = join(resolvedSitePath, localesDir, 'freeform', locale)
|
|
1228
1223
|
const hasFreeform = existsSync(freeformDir)
|
|
1229
1224
|
|
|
1230
1225
|
// Translate using the collections module
|
|
1231
|
-
const {
|
|
1232
|
-
const translated = await
|
|
1226
|
+
const { translateRecordData } = await import('../i18n/records.js')
|
|
1227
|
+
const translated = await translateRecordData(items, queryName, resolvedSitePath, {
|
|
1233
1228
|
locale,
|
|
1234
1229
|
localesDir: join(resolvedSitePath, localesDir),
|
|
1235
1230
|
translations,
|
|
@@ -1240,7 +1235,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
1240
1235
|
res.end(JSON.stringify(translated, null, 2))
|
|
1241
1236
|
return
|
|
1242
1237
|
} catch (err) {
|
|
1243
|
-
console.warn(`[site-content] Failed to serve localized
|
|
1238
|
+
console.warn(`[site-content] Failed to serve localized records ${filename}: ${err.message}`)
|
|
1244
1239
|
// Fall through to Vite's static server
|
|
1245
1240
|
}
|
|
1246
1241
|
}
|