@uniweb/build 0.56.0 → 0.57.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 +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/entity-pool.js +136 -42
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +2 -1
- package/src/uwx/backfill.js +2 -2
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +9 -9
- package/src/uwx/records-project.js +95 -64
- package/src/uwx/records.js +310 -214
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +3 -3
- package/src/uwx/site.js +45 -39
- package/src/uwx/sync-package.js +19 -15
- package/src/validate-data.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
+
"@uniweb/content-reader": "^1.2.5",
|
|
60
61
|
"@uniweb/schemas": "^0.3.3",
|
|
61
|
-
"@uniweb/projections": "^0.7.3",
|
|
62
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
|
-
"@uniweb/content-reader": "^1.2.5",
|
|
64
63
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
64
|
+
"@uniweb/theming": "^0.1.15",
|
|
65
|
+
"@uniweb/projections": "^0.7.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.3"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.29.7",
|
|
78
|
+
"@uniweb/runtime": "^0.27.3"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/content/index.js
CHANGED
|
@@ -21,6 +21,9 @@ export {
|
|
|
21
21
|
writeQueryFiles,
|
|
22
22
|
getQueryLastModified,
|
|
23
23
|
} from '../site/query-processor.js'
|
|
24
|
+
// Where a site's records live — `paths.records`, else `records/` — so a caller
|
|
25
|
+
// holding its own config (a document's, not a `site.yml`) asks the one resolver.
|
|
26
|
+
export { resolveRecordsDir } from '../site/entity-pool.js'
|
|
24
27
|
export {
|
|
25
28
|
parseFetchConfig,
|
|
26
29
|
executeFetch,
|
package/src/i18n/data-strings.js
CHANGED
|
@@ -31,7 +31,7 @@ export const HEURISTIC_SKIP_FIELDS = new Set([
|
|
|
31
31
|
'pmid', 'bibtex', 'pdf', 'code', 'data', 'slides', 'video',
|
|
32
32
|
'repository', 'caseStudy', 'website', 'avatar', 'image',
|
|
33
33
|
'thumbnail', 'currency', 'order', 'hidden', 'current',
|
|
34
|
-
'featured', 'published', 'allDay', 'remote', 'hybrid',
|
|
34
|
+
'featured', 'published', 'draft', 'allDay', 'remote', 'hybrid',
|
|
35
35
|
'noindex', 'corresponding', 'required', 'virtual',
|
|
36
36
|
'lastModified', 'date', 'updated', 'posted', 'submitted',
|
|
37
37
|
'accepted', 'startDate', 'endDate', 'deadline',
|
package/src/i18n/freeform.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* locales/freeform/{locale}/
|
|
11
11
|
* pages/{pageRoute}/{stableId}.md - By route
|
|
12
12
|
* page-ids/{pageId}/{stableId}.md - By page ID (stable)
|
|
13
|
-
*
|
|
13
|
+
* records/{schema dirs}/{slug}.md - Records
|
|
14
14
|
*
|
|
15
15
|
* Resolution order for sections:
|
|
16
16
|
* 1. page-ids/{pageId}/{stableId}.md (if page has id:)
|
|
@@ -22,7 +22,7 @@ import { readFile, readdir, stat } from 'fs/promises'
|
|
|
22
22
|
import { existsSync } from 'node:fs'
|
|
23
23
|
import { join, relative, dirname, sep } from 'node:path'
|
|
24
24
|
import yaml from 'js-yaml'
|
|
25
|
-
import { poolDirsForSchema,
|
|
25
|
+
import { poolDirsForSchema, schemaForPoolDirs, RECORDS_DIR } from '../site/entity-pool.js'
|
|
26
26
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
27
27
|
import { computeSourceHash } from './freeform-manifest.js'
|
|
28
28
|
|
|
@@ -101,13 +101,13 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Load a free-form translation for one
|
|
104
|
+
* Load a free-form translation for one RECORD.
|
|
105
105
|
*
|
|
106
|
-
* Path:
|
|
106
|
+
* Path: records/{schema dirs}/{slug}.md — mirroring the records directory exactly.
|
|
107
107
|
*
|
|
108
|
-
* ⛔ KEYED BY THE
|
|
108
|
+
* ⛔ KEYED BY THE RECORD, NOT BY A QUERY. It used to be
|
|
109
109
|
* `collections/{queryName}/{slug}.md`, which was fine only while a
|
|
110
|
-
* collection was also a directory of files. With `
|
|
110
|
+
* collection was also a directory of files. With `records/{schema}/` holding records,
|
|
111
111
|
* TWO queries can cover one schema — so a query-keyed path would make an author
|
|
112
112
|
* write the same translation once per query, and finding neither from the other.
|
|
113
113
|
* ⚠️ And a query is renameable where a record's model is not: keying on the query
|
|
@@ -185,7 +185,7 @@ async function discoverMarkdownFiles(dir, baseDir) {
|
|
|
185
185
|
* register a translation that has no manifest entry yet.
|
|
186
186
|
*
|
|
187
187
|
* ⭐ Each path is relative to the locale's free-form directory — `pages/about/story.md`,
|
|
188
|
-
* `page-ids/<id>/intro.md`, `
|
|
188
|
+
* `page-ids/<id>/intro.md`, `records/article/hello.md` — the form the manifest keys a
|
|
189
189
|
* translation by and `freeformPathsFor` derives. ⛔ Until 2026-09-14 each was relative
|
|
190
190
|
* to its own subdirectory (`about/story.md`), so none matched a manifest key: the build
|
|
191
191
|
* registered no new file, and a status counted every stale or orphaned file as up to date.
|
|
@@ -217,10 +217,10 @@ export async function discoverFreeformTranslations(locale, localesDir) {
|
|
|
217
217
|
result.pageIds = (await discoverMarkdownFiles(pageIdsDir, freeformDir)).sort()
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// Discover
|
|
221
|
-
const
|
|
222
|
-
if (existsSync(
|
|
223
|
-
result.records = (await discoverMarkdownFiles(
|
|
220
|
+
// Discover record translations
|
|
221
|
+
const recordsDir = join(freeformDir, RECORDS_DIR)
|
|
222
|
+
if (existsSync(recordsDir)) {
|
|
223
|
+
result.records = (await discoverMarkdownFiles(recordsDir, freeformDir)).sort()
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
return result
|
|
@@ -246,7 +246,7 @@ export async function getFreeformFileMeta(filePath) {
|
|
|
246
246
|
* Parse a free-form translation file path to extract metadata
|
|
247
247
|
*
|
|
248
248
|
* @param {string} relativePath - Path relative to locale's freeform dir
|
|
249
|
-
* @returns {Object} { type, pageRoute?, pageId?,
|
|
249
|
+
* @returns {Object} { type, pageRoute?, pageId?, schema?, stableId, slug? }
|
|
250
250
|
*/
|
|
251
251
|
export function parseFreeformPath(relativePath) {
|
|
252
252
|
const parts = relativePath.split('/')
|
|
@@ -266,11 +266,12 @@ export function parseFreeformPath(relativePath) {
|
|
|
266
266
|
return { type: 'pageId', pageId, stableId }
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
if (parts[0] ===
|
|
270
|
-
//
|
|
269
|
+
if (parts[0] === RECORDS_DIR) {
|
|
270
|
+
// records/std/person/ada.md → { type: 'record', schema: '@std/person', slug: 'ada' }
|
|
271
|
+
// ⛔ This returned `queryName: parts[1]` until 2026-09-21 — the first schema
|
|
272
|
+
// segment (`std` above), and never a query's name. Nothing read it.
|
|
271
273
|
const slug = parts[parts.length - 1].replace('.md', '')
|
|
272
|
-
|
|
273
|
-
return { type: 'record', queryName, slug }
|
|
274
|
+
return { type: 'record', schema: schemaForPoolDirs(parts.slice(1, -1)), slug }
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
return { type: 'unknown', relativePath }
|
|
@@ -343,7 +344,7 @@ function freeformSections(siteContent) {
|
|
|
343
344
|
* - `canJudge(path)` — whether this content can say a translation at `path` is
|
|
344
345
|
* orphaned. ⛔ Only a page section's (`pages/…`, `page-ids/…`), and not one for a page
|
|
345
346
|
* whose sections the content does not carry — a prerendered site with split content
|
|
346
|
-
* rewrites `site-content.json` without them. A record's (`
|
|
347
|
+
* rewrites `site-content.json` without them. A record's (`records/…`) is read from
|
|
347
348
|
* records this content does not hold. A check that cannot see the source must never
|
|
348
349
|
* call its translation orphaned: `prune --freeform` deletes what it is told is.
|
|
349
350
|
*
|
|
@@ -413,10 +414,10 @@ export function buildFreeformPath(section, page, preferPageId = true) {
|
|
|
413
414
|
*
|
|
414
415
|
* @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
|
|
415
416
|
* @param {string} slug - the entity's slug
|
|
416
|
-
* @returns {string|null} e.g. `
|
|
417
|
+
* @returns {string|null} e.g. `records/article/getting-started.md`, or null for a
|
|
417
418
|
* ref this layout cannot express
|
|
418
419
|
*/
|
|
419
420
|
export function buildFreeformRecordPath(schema, slug) {
|
|
420
421
|
const dirs = poolDirsForSchema(schema)
|
|
421
|
-
return dirs ? `${
|
|
422
|
+
return dirs ? `${RECORDS_DIR}/${dirs.join('/')}/${slug}.md` : null
|
|
422
423
|
}
|
package/src/i18n/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
buildLocalizedRecords,
|
|
22
22
|
getRecordLocales,
|
|
23
23
|
translateRecordData,
|
|
24
|
-
|
|
24
|
+
RECORD_LOCALES_DIR
|
|
25
25
|
} from './records.js'
|
|
26
26
|
import { resolveDefaultLocale } from '@uniweb/core'
|
|
27
27
|
import { generateSearchIndex } from '@uniweb/projections'
|
|
@@ -75,7 +75,7 @@ export {
|
|
|
75
75
|
buildLocalizedRecords,
|
|
76
76
|
getRecordLocales,
|
|
77
77
|
translateRecordData,
|
|
78
|
-
|
|
78
|
+
RECORD_LOCALES_DIR,
|
|
79
79
|
|
|
80
80
|
// Locale resolution
|
|
81
81
|
getAvailableLocales,
|
|
@@ -234,7 +234,7 @@ export async function extractRecordManifest(siteRoot, options = {}) {
|
|
|
234
234
|
const manifest = await extractRecordContent(siteRoot)
|
|
235
235
|
|
|
236
236
|
// Ensure collections locales directory exists
|
|
237
|
-
const recordLocalesDir = join(siteRoot, localesDir,
|
|
237
|
+
const recordLocalesDir = join(siteRoot, localesDir, RECORD_LOCALES_DIR)
|
|
238
238
|
if (!existsSync(recordLocalesDir)) {
|
|
239
239
|
await mkdir(recordLocalesDir, { recursive: true })
|
|
240
240
|
}
|
|
@@ -517,7 +517,7 @@ async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
|
|
|
517
517
|
}
|
|
518
518
|
|
|
519
519
|
// Check for orphaned translations — only those this content can judge: never a
|
|
520
|
-
// record's (`
|
|
520
|
+
// record's (`records/…`), which is read from records the site content does not hold.
|
|
521
521
|
const orphaned = await getOrphanedTranslations(freeformDir, validPaths)
|
|
522
522
|
for (const item of orphaned) {
|
|
523
523
|
if (!canJudge(item.path)) continue
|
package/src/i18n/records.js
CHANGED
|
@@ -23,7 +23,7 @@ import { loadFreeformRecord } from './freeform.js'
|
|
|
23
23
|
// is not, wherever the value came from. Moved rather than copied: two tuned
|
|
24
24
|
// denylists would drift, and drift here is silent.
|
|
25
25
|
import { resolveQueriesConfig } from '../site/queries-config.js'
|
|
26
|
-
import { poolDirsForSchema, schemaForPoolDirs,
|
|
26
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
27
27
|
import {
|
|
28
28
|
NON_TRANSLATABLE_TYPES,
|
|
29
29
|
HEURISTIC_SKIP_FIELDS,
|
|
@@ -37,7 +37,11 @@ import {
|
|
|
37
37
|
// contexts were keyed by the QUERY, so two queries over one schema produced two
|
|
38
38
|
// entries for one record, each invisible from the other, and a renamed query
|
|
39
39
|
// orphaned every translation under it.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// ⚠️ `RECORD_LOCALES_DIR`, and not `RECORDS_DIR`, since 2026-09-21: that name is the
|
|
42
|
+
// site's own `records/` directory now (`site/entity-pool.js`), and this one is
|
|
43
|
+
// `locales/records/` — two directories, one word, so the constant says which.
|
|
44
|
+
export const RECORD_LOCALES_DIR = 'records'
|
|
41
45
|
|
|
42
46
|
// ---------------------------------------------------------------------------
|
|
43
47
|
// Schema resolution
|
|
@@ -72,7 +76,7 @@ async function resolveSchema(queryName, siteRoot) {
|
|
|
72
76
|
// an author to write into it was the one remaining place the framework
|
|
73
77
|
// contradicted its own rule that `collections/` is the only way to provide
|
|
74
78
|
// structured data. The schema describes the source, so it lives with it.
|
|
75
|
-
const companionPath = join(siteRoot,
|
|
79
|
+
const companionPath = join(resolveRecordsDir(siteRoot).abs, `${queryName}.schema.js`)
|
|
76
80
|
if (existsSync(companionPath)) {
|
|
77
81
|
try {
|
|
78
82
|
const mod = await import(pathToFileURL(companionPath).href)
|
|
@@ -657,7 +661,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
657
661
|
const {
|
|
658
662
|
locales = [],
|
|
659
663
|
outputDir = join(siteRoot, 'dist'),
|
|
660
|
-
recordLocalesDir = join(siteRoot, 'locales',
|
|
664
|
+
recordLocalesDir = join(siteRoot, 'locales', RECORD_LOCALES_DIR),
|
|
661
665
|
localesDir = join(siteRoot, 'locales'),
|
|
662
666
|
freeformEnabled = true
|
|
663
667
|
} = options
|
|
@@ -987,7 +991,7 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
987
991
|
* @returns {Promise<string[]>} Array of locale codes
|
|
988
992
|
*/
|
|
989
993
|
export async function getRecordLocales(localesPath) {
|
|
990
|
-
const recordLocalesDir = join(localesPath,
|
|
994
|
+
const recordLocalesDir = join(localesPath, RECORD_LOCALES_DIR)
|
|
991
995
|
if (!existsSync(recordLocalesDir)) return []
|
|
992
996
|
|
|
993
997
|
try {
|
|
@@ -24,6 +24,7 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
24
24
|
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
27
|
+
import { resolveRecordsDir } from './entity-pool.js'
|
|
27
28
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
28
29
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
29
30
|
import {
|
|
@@ -61,7 +62,7 @@ import {
|
|
|
61
62
|
* re-derive, so we always ship enough for the ones that do.
|
|
62
63
|
* - `data/<collection>.json` (+ per-record files for `deferred:`
|
|
63
64
|
* collections) — same shape `processQueries` produces today.
|
|
64
|
-
* - `records/<path under
|
|
65
|
+
* - `records/<path under the records directory>` — the records' co-located assets, where
|
|
65
66
|
* the compiled records point.
|
|
66
67
|
* - `assets/<media>` — processed images / video posters / PDF
|
|
67
68
|
* thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
|
|
@@ -143,7 +144,7 @@ export async function buildSiteData({
|
|
|
143
144
|
const byQuery = await processQueries(
|
|
144
145
|
resolvedSiteRoot,
|
|
145
146
|
siteContent.config.queries,
|
|
146
|
-
siteContent.config?.paths
|
|
147
|
+
resolveRecordsDir(resolvedSiteRoot, siteContent.config?.paths).rel,
|
|
147
148
|
basePath,
|
|
148
149
|
{ locale: resolveDefaultLocale(siteContent.config) ?? null }
|
|
149
150
|
)
|
|
@@ -155,7 +156,7 @@ export async function buildSiteData({
|
|
|
155
156
|
await cp(publicDataDir, distDataDir, { recursive: true })
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under
|
|
159
|
+
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under records/>`,
|
|
159
160
|
// where the compiled records point (`/records/…`), mirrored the way vite mirrors
|
|
160
161
|
// all of `public/` on the bundle lane. ⛔ Until 2026-09-14 only `public/data` was
|
|
161
162
|
// copied, so a record's image URL named a file this lane's `dist/` did not have.
|
package/src/site/config.js
CHANGED
|
@@ -484,7 +484,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
484
484
|
const allowed = ['..']
|
|
485
485
|
const parentDir = resolve(siteRoot, '..')
|
|
486
486
|
const paths = siteConfig.paths || {}
|
|
487
|
-
for (const key of ['pages', 'layout', '
|
|
487
|
+
for (const key of ['pages', 'layout', 'records']) {
|
|
488
488
|
if (paths[key]) {
|
|
489
489
|
const resolved = resolve(siteRoot, paths[key])
|
|
490
490
|
if (!resolved.startsWith(parentDir)) {
|
|
@@ -2727,9 +2727,10 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2727
2727
|
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2728
2728
|
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2729
2729
|
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2730
|
-
// ⛔ `$`-prefixed keys
|
|
2731
|
-
// `$backend`, `$services`, `$secrets` —
|
|
2732
|
-
//
|
|
2730
|
+
// ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2731
|
+
// `$backend`, `$services`, `$secrets` — until they moved to `sync.json`
|
|
2732
|
+
// (2026-09-20), and a `site.yml` written before then can still carry them. This
|
|
2733
|
+
// payload is a PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
2733
2734
|
// or kit reads a `config.$*` key), so this removes noise for four of them and a
|
|
2734
2735
|
// real disclosure for the fifth: `$secrets` carries no values, but its entries
|
|
2735
2736
|
// name every secret the site has, and an inventory of credential names is not
|
package/src/site/entity-pool.js
CHANGED
|
@@ -1,49 +1,130 @@
|
|
|
1
|
-
// A site's
|
|
1
|
+
// A site's RECORDS on disk — every file in `records/`, and the model each one has.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `records.
|
|
3
|
+
// ⭐ PLACING A FILE IN `records/` IS WHAT MAKES IT A RECORD (ruled 2026-09-21
|
|
4
|
+
// [Diego]). The directory is the file side of the site's records folder: the
|
|
5
|
+
// backend's folder holds references to entities, and putting a ref in it is what
|
|
6
|
+
// makes one a record — putting a file in this directory is the same act. Nothing
|
|
7
|
+
// else is needed, and nothing lists it. (`records.yml` only organizes records into
|
|
8
|
+
// sub-folders; see `records-config.js`.) A file whose name starts with `_` or `.`
|
|
9
|
+
// is not read, so it is not a record.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// ⛔ A RECORD IS NOT "PUBLISHED" BY BEING HERE. `push` sends the site's records to a
|
|
12
|
+
// backend, and they are served once the SITE is published — publishing the site
|
|
13
|
+
// publishes its folder and what is in it. Nothing about a single record decides that.
|
|
14
|
+
//
|
|
15
|
+
// ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. `collections/<name>/` used to
|
|
16
|
+
// mean three things at once — these files are entities, their schema is
|
|
17
|
+
// `@/<name>`, and they are grouped as `<name>` for placement. A schema folder here
|
|
18
|
+
// declares only the model; grouping is `records.yml`'s.
|
|
19
|
+
//
|
|
20
|
+
// ⇒ So nothing here reads a query or a folder. A site's records are a fact about
|
|
21
|
+
// the filesystem — plus `site.yml::paths.records`, which says where they live.
|
|
12
22
|
//
|
|
13
23
|
// ⭐ DEPTH NAMES THE SCOPE — the schema-ref grammar, spelled as directories:
|
|
14
24
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
25
|
+
// records/person/ada.md → @/person (the foundation's own)
|
|
26
|
+
// records/std/person/ada.md → @std/person (the shared standard set)
|
|
27
|
+
// records/acme/project/x.md → @acme/project (an org's)
|
|
18
28
|
//
|
|
19
29
|
// matching `build/src/resolve-data-schema.js`, which is the only grammar there
|
|
20
30
|
// is: a bare directory name can mean `@/<name>` and nothing else.
|
|
21
31
|
//
|
|
22
|
-
// ⛔ BARE, NOT `
|
|
32
|
+
// ⛔ BARE, NOT `records/@std/`. Measured: `@` is a reserved indicator in YAML
|
|
23
33
|
// 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
|
|
24
34
|
// `bad indentation of a sequence entry`, which names neither the cause nor the
|
|
25
|
-
// fix. The `@` form would force quotes on
|
|
26
|
-
//
|
|
35
|
+
// fix. The `@` form would force quotes on every pattern in `records.yml` that
|
|
36
|
+
// names a scoped schema.
|
|
27
37
|
//
|
|
28
38
|
// ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
|
|
29
39
|
// 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.
|
|
40
|
+
// with nothing to classify and no ambiguous case to resolve. Organization is a
|
|
41
|
+
// `folder:` in `records.yml`, never a directory.
|
|
31
42
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// `records.yml` replaces on-disk nesting outright, so the two lanes stop
|
|
37
|
-
// disagreeing rather than being taught to agree.
|
|
43
|
+
// ⛔ THE DIRECTORY WAS `entities/` UNTIL 2026-09-21, and `site.yml::paths.entities`
|
|
44
|
+
// the key that moved it. Both are refused by name rather than read (no alias —
|
|
45
|
+
// there is no population to carry): a leftover `entities/` beside `records/` would
|
|
46
|
+
// otherwise hold records nothing reads, silently.
|
|
38
47
|
|
|
39
48
|
import { readdir } from 'node:fs/promises'
|
|
40
|
-
import { existsSync } from 'node:fs'
|
|
41
|
-
import { join, extname, basename } from 'node:path'
|
|
49
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
50
|
+
import { join, extname, basename, resolve } from 'node:path'
|
|
51
|
+
import yaml from 'js-yaml'
|
|
52
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
53
|
+
|
|
54
|
+
/** Where a site's records live, relative to its root, unless `site.yml::paths.records` moves them. */
|
|
55
|
+
export const RECORDS_DIR = 'records'
|
|
56
|
+
|
|
57
|
+
/** The directory's name until 2026-09-21. Read only to refuse it. */
|
|
58
|
+
const RETIRED_DIR = 'entities'
|
|
59
|
+
|
|
60
|
+
function isDirectory(path) {
|
|
61
|
+
try {
|
|
62
|
+
return statSync(path).isDirectory()
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
function readSitePaths(siteRoot) {
|
|
69
|
+
const file = join(siteRoot, 'site.yml')
|
|
70
|
+
if (!existsSync(file)) return {}
|
|
71
|
+
try {
|
|
72
|
+
const doc = yaml.load(readFileSync(file, 'utf8'), YAML_OPTIONS)
|
|
73
|
+
return doc && typeof doc.paths === 'object' && doc.paths !== null ? doc.paths : {}
|
|
74
|
+
} catch {
|
|
75
|
+
// An unreadable site.yml is reported by whoever reads the site; this only
|
|
76
|
+
// needs the one key, and the default is the right answer without it.
|
|
77
|
+
return {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
45
80
|
|
|
46
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Refuse the retired `entities/` directory when it is not the one the site reads.
|
|
83
|
+
*
|
|
84
|
+
* ⛔ A RENAMED DIRECTORY FAILS SILENTLY — its files are simply not read, and a site
|
|
85
|
+
* with no records looks exactly like a site whose records moved. So its presence
|
|
86
|
+
* stops the build, the push and every other reader, naming the new place.
|
|
87
|
+
*/
|
|
88
|
+
export function refuseRetiredRecordsDir(siteRoot, rel) {
|
|
89
|
+
const retired = resolve(siteRoot, RETIRED_DIR)
|
|
90
|
+
if (resolve(siteRoot, rel) === retired) return
|
|
91
|
+
if (!isDirectory(retired)) return
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[uniweb] ${RETIRED_DIR}/ is not read — a site's records live in \`${rel}/\` now. ` +
|
|
94
|
+
`Rename the directory (\`git mv ${RETIRED_DIR} ${rel}\`) and every file in it is a record, ` +
|
|
95
|
+
`as before; records.yml no longer lists them.`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Where a site's records live — `site.yml::paths.records`, else `records/`.
|
|
101
|
+
*
|
|
102
|
+
* ⭐ THE ONE RESOLVER, and every lane asks it: the build, the push, the pull and
|
|
103
|
+
* the CLI. ⛔ Until 2026-09-21 only the build honoured the key — then called
|
|
104
|
+
* `paths.entities` — while push read the default, so a site that moved its records
|
|
105
|
+
* built fine and then failed to push, with an error naming a directory it did not
|
|
106
|
+
* use (measured).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} siteRoot
|
|
109
|
+
* @param {object} [paths] - `site.yml::paths`, when the caller has already read it;
|
|
110
|
+
* otherwise `site.yml` is read here.
|
|
111
|
+
* @returns {{ rel: string, abs: string }} `rel` as written (for messages), `abs`
|
|
112
|
+
* resolved against the site root — an absolute `paths.records` is honoured.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveRecordsDir(siteRoot, paths) {
|
|
115
|
+
const p = paths === undefined ? readSitePaths(siteRoot) : paths || {}
|
|
116
|
+
if (p.entities !== undefined) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[uniweb] site.yml: \`paths.entities\` is now \`paths.records\` — the directory a site's ` +
|
|
119
|
+
`records live in. Rename the key.`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
const rel = typeof p.records === 'string' && p.records.trim() ? p.records.trim().replace(/\/+$/, '') : RECORDS_DIR
|
|
123
|
+
refuseRetiredRecordsDir(siteRoot, rel)
|
|
124
|
+
return { rel, abs: resolve(siteRoot, rel) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Source extensions a record file may have. Mirrors the sync-lane reader. */
|
|
47
128
|
export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
48
129
|
|
|
49
130
|
const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
|
|
@@ -51,7 +132,7 @@ const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
|
|
|
51
132
|
/**
|
|
52
133
|
* The schema ref a pool path names.
|
|
53
134
|
*
|
|
54
|
-
* @param {string[]} dirs - the directory segments below `
|
|
135
|
+
* @param {string[]} dirs - the directory segments below `records/`
|
|
55
136
|
* @returns {string|null} the ref, or null when the depth names no schema
|
|
56
137
|
*/
|
|
57
138
|
export function schemaForPoolDirs(dirs) {
|
|
@@ -61,7 +142,7 @@ export function schemaForPoolDirs(dirs) {
|
|
|
61
142
|
}
|
|
62
143
|
|
|
63
144
|
/**
|
|
64
|
-
* Where a schema's
|
|
145
|
+
* Where a schema's records live — the inverse of `schemaForPoolDirs`.
|
|
65
146
|
*
|
|
66
147
|
* ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
|
|
67
148
|
* exists at all: the reader derives a model from a path and the pull side derives
|
|
@@ -70,7 +151,7 @@ export function schemaForPoolDirs(dirs) {
|
|
|
70
151
|
* `deferredFromSchema` — a deriver and its recognizer must not be two copies.
|
|
71
152
|
*
|
|
72
153
|
* @param {string} schema - a ref: `@/name` or `@org/name`
|
|
73
|
-
* @returns {string[]|null} the directory segments below `
|
|
154
|
+
* @returns {string[]|null} the directory segments below `records/`, or null for
|
|
74
155
|
* a ref this layout cannot express
|
|
75
156
|
*/
|
|
76
157
|
export function poolDirsForSchema(schema) {
|
|
@@ -85,7 +166,7 @@ export function poolDirsForSchema(schema) {
|
|
|
85
166
|
/**
|
|
86
167
|
* Both readings of a 2-segment pool path, for an error that has to name them.
|
|
87
168
|
*
|
|
88
|
-
* ⚠️ A reader who mistakes `
|
|
169
|
+
* ⚠️ A reader who mistakes `records/person/2024/ada.md` for "the `person`
|
|
89
170
|
* schema, organised by year" needs to be told what the build actually did with
|
|
90
171
|
* it, not merely that something did not resolve. The wrong reading is the
|
|
91
172
|
* plausible one, so the message carries both.
|
|
@@ -98,33 +179,46 @@ export function poolPathReadings(dirs) {
|
|
|
98
179
|
}
|
|
99
180
|
|
|
100
181
|
/**
|
|
101
|
-
* Read a site's
|
|
182
|
+
* Read a site's records.
|
|
102
183
|
*
|
|
103
|
-
* Returns
|
|
184
|
+
* Returns them in a stable, path-sorted order — the wire's package digest
|
|
104
185
|
* depends on it — each carrying the model its path declares.
|
|
105
186
|
*
|
|
106
187
|
* ⚠️ NOTHING HERE RESOLVES A SCHEMA. Whether `@std/person` is a schema this site
|
|
107
188
|
* can actually see is a question for whoever holds the foundation's built schema
|
|
108
189
|
* map, and only that caller can raise the error §4 of the model asks for. This
|
|
109
|
-
* reports the
|
|
110
|
-
* deep — because those are answerable from the filesystem alone.
|
|
190
|
+
* reports the directory's SHAPE — a file with no schema above it, a file nested
|
|
191
|
+
* too deep — because those are answerable from the filesystem alone.
|
|
111
192
|
*
|
|
112
193
|
* @param {string} siteRoot
|
|
113
194
|
* @param {object} [opts]
|
|
114
|
-
* @param {string} [opts.dir] -
|
|
195
|
+
* @param {string} [opts.dir] - the records directory, site-root-relative or
|
|
196
|
+
* absolute, when the caller already resolved it; otherwise `resolveRecordsDir`
|
|
197
|
+
* reads `site.yml::paths.records`.
|
|
115
198
|
* @returns {Promise<{
|
|
116
199
|
* entities: Array<{ id, schema, slug, dirs, relPath, absPath, ext }>,
|
|
117
200
|
* errors: string[],
|
|
118
201
|
* exists: boolean,
|
|
202
|
+
* dir: string,
|
|
119
203
|
* }>}
|
|
120
|
-
* `id` is the
|
|
204
|
+
* `id` is the record's path under `records/` without its extension — unique
|
|
121
205
|
* by construction, stable across pushes, and derivable identically on both
|
|
122
|
-
* sides without either lane holding the other's ids.
|
|
206
|
+
* sides without either lane holding the other's ids. `exists` is whether the
|
|
207
|
+
* directory is there at all, which is not the same as holding nothing: a push
|
|
208
|
+
* leaves the backend's folder alone for a site with no records directory.
|
|
123
209
|
*/
|
|
124
210
|
export async function readEntityPool(siteRoot, opts = {}) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
211
|
+
let rel
|
|
212
|
+
if (opts.dir) {
|
|
213
|
+
rel = String(opts.dir).replace(/\/+$/, '')
|
|
214
|
+
refuseRetiredRecordsDir(siteRoot, rel)
|
|
215
|
+
} else {
|
|
216
|
+
rel = resolveRecordsDir(siteRoot).rel
|
|
217
|
+
}
|
|
218
|
+
// ⛔ `resolve`, not `join`: an absolute `paths.records` is supported, and
|
|
219
|
+
// `join(siteRoot, '/abs')` is a path under the site root that does not exist.
|
|
220
|
+
const base = resolve(siteRoot, rel)
|
|
221
|
+
if (!isDirectory(base)) return { entities: [], errors: [], exists: false, dir: rel }
|
|
128
222
|
|
|
129
223
|
const entities = []
|
|
130
224
|
const errors = []
|
|
@@ -188,7 +282,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
188
282
|
}
|
|
189
283
|
|
|
190
284
|
await walk(base, [])
|
|
191
|
-
return { entities, errors, exists: true }
|
|
285
|
+
return { entities, errors, exists: true, dir: rel }
|
|
192
286
|
}
|
|
193
287
|
|
|
194
288
|
/**
|