@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.
Files changed (41) hide show
  1. package/package.json +7 -7
  2. package/src/content/index.js +6 -6
  3. package/src/dev-backend.js +31 -31
  4. package/src/i18n/freeform.js +44 -24
  5. package/src/i18n/index.js +22 -22
  6. package/src/i18n/{collections.js → records.js} +114 -51
  7. package/src/i18n/sync.js +9 -8
  8. package/src/site/build-site-data.js +9 -12
  9. package/src/site/config.js +1 -1
  10. package/src/site/content-collector.js +25 -40
  11. package/src/site/data-fetcher.js +23 -10
  12. package/src/site/entity-pool.js +211 -0
  13. package/src/site/fetch-shapes.js +13 -12
  14. package/src/site/foundation-ref.js +1 -1
  15. package/src/site/index.js +4 -4
  16. package/src/site/plugin.js +58 -63
  17. package/src/site/queries-config.js +324 -0
  18. package/src/site/{collection-processor.js → query-processor.js} +180 -95
  19. package/src/site/records-config.js +299 -0
  20. package/src/site/schemaless-data.js +2 -2
  21. package/src/utils/numeric-prefix.js +63 -0
  22. package/src/uwx/backfill.js +5 -5
  23. package/src/uwx/data-schema.js +2 -2
  24. package/src/uwx/entity-source.js +122 -0
  25. package/src/uwx/folder.js +85 -77
  26. package/src/uwx/index.js +33 -13
  27. package/src/uwx/locale-sync.js +2 -2
  28. package/src/uwx/project-writer.js +36 -10
  29. package/src/uwx/queries-config.js +11 -0
  30. package/src/uwx/records-project.js +535 -0
  31. package/src/uwx/{collections.js → records.js} +152 -69
  32. package/src/uwx/site-diff.js +6 -6
  33. package/src/uwx/site-project.js +4 -4
  34. package/src/uwx/site.js +143 -22
  35. package/src/uwx/sync-package.js +32 -18
  36. package/src/validate-data.js +17 -19
  37. package/src/site/collections-config.js +0 -260
  38. package/src/uwx/collection-source.js +0 -180
  39. package/src/uwx/collections-config.js +0 -9
  40. package/src/uwx/collections-project.js +0 -335
  41. /package/src/search/{collections.js → records-index.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,15 +59,15 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
+ "@uniweb/theming": "^0.1.15",
63
+ "@uniweb/semantic-parser": "^1.3.1",
62
64
  "@uniweb/content-reader": "^1.2.4",
63
- "@uniweb/schemas": "^0.2.11",
64
65
  "@uniweb/content-writer": "^0.3.4",
65
- "@uniweb/projections": "^0.5.1",
66
- "@uniweb/semantic-parser": "^1.3.1",
67
- "@uniweb/theming": "^0.1.15"
66
+ "@uniweb/projections": "^0.5.2",
67
+ "@uniweb/schemas": "^0.2.11"
68
68
  },
69
69
  "optionalDependencies": {
70
- "@uniweb/runtime": "^0.13.1"
70
+ "@uniweb/runtime": "^0.13.2"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -76,7 +76,7 @@
76
76
  "@tailwindcss/vite": "^4.0.0",
77
77
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
78
78
  "vite-plugin-svgr": "^4.0.0",
79
- "@uniweb/core": "^0.13.1"
79
+ "@uniweb/core": "^0.14.0"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "vite": {
@@ -12,15 +12,15 @@
12
12
 
13
13
  export { collectSiteContent } from '../site/content-collector.js'
14
14
 
15
- // Collections + fetch resolution. Pure functions on the clean dep
15
+ // Query + fetch resolution. Pure functions on the clean dep
16
16
  // chain — re-exported here so headless callers (unipress, sidecars)
17
- // can resolve `collections:` declarations without importing
17
+ // can resolve `queries:` declarations without importing
18
18
  // `@uniweb/build/site` (which pulls Vite via its plugin index).
19
19
  export {
20
- processCollections,
21
- writeCollectionFiles,
22
- getCollectionLastModified,
23
- } from '../site/collection-processor.js'
20
+ processQueries,
21
+ writeQueryFiles,
22
+ getQueryLastModified,
23
+ } from '../site/query-processor.js'
24
24
  export {
25
25
  parseFetchConfig,
26
26
  executeFetch,
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Dev backend for testing Uniweb sites with `supports: [where, limit, sort]`.
4
4
  *
5
- * Reads a directory of YAML collections (each subfolder is a collection,
5
+ * Reads a directory of YAML recordsByQuery (each subfolder is a collection,
6
6
  * each .yml file inside is a record) and exposes them via HTTP. Evaluates
7
7
  * where-objects on the server side using @uniweb/core's matchWhere — the
8
8
  * exact same evaluator the runtime uses as a fallback. This lets you
@@ -11,7 +11,7 @@
11
11
  * Wire format matches the framework default fetcher's pushdown conventions
12
12
  * (see framework/runtime/src/default-fetcher.js):
13
13
  *
14
- * GET /api/{collection} — full collection
14
+ * GET /api/{collection} — all records
15
15
  * GET /api/{collection}?_where=<JSON> — filtered by where-object
16
16
  * GET /api/{collection}?_limit=N — first N records
17
17
  * GET /api/{collection}?_sort=field:dir — sorted
@@ -19,11 +19,11 @@
19
19
  * GET /api/{collection}/{slug} — single record
20
20
  *
21
21
  * Usage:
22
- * node scripts/framework/dev-backend.js --collections <path> [--port N]
22
+ * node scripts/framework/dev-backend.js --recordsByQuery <path> [--port N]
23
23
  *
24
24
  * Example (academic-metrics):
25
25
  * node scripts/framework/dev-backend.js \
26
- * --collections framework/templates/academic-metrics/site/collections \
26
+ * --recordsByQuery framework/templates/academic-metrics/site/recordsByQuery \
27
27
  * --port 8080
28
28
  *
29
29
  * Then in the site's site.yml:
@@ -45,27 +45,27 @@ import { matchWhere } from '@uniweb/core'
45
45
 
46
46
  const { values } = parseArgs({
47
47
  options: {
48
- collections: { type: 'string', short: 'c' },
48
+ entities: { type: 'string', short: 'e' },
49
49
  port: { type: 'string', short: 'p', default: '8080' },
50
50
  },
51
51
  })
52
52
 
53
- if (!values.collections) {
54
- console.error('Usage: dev-backend.js --collections <path> [--port N]')
53
+ if (!values.entities) {
54
+ console.error('Usage: dev-backend.js --entities <path> [--port N]')
55
55
  process.exit(1)
56
56
  }
57
57
 
58
- const COLLECTIONS_DIR = resolve(values.collections)
58
+ const ENTITIES_ROOT = resolve(values.entities)
59
59
  const PORT = Number(values.port)
60
60
 
61
- if (!existsSync(COLLECTIONS_DIR)) {
62
- console.error(`Collections directory not found: ${COLLECTIONS_DIR}`)
61
+ if (!existsSync(ENTITIES_ROOT)) {
62
+ console.error(`Entities directory not found: ${ENTITIES_ROOT}`)
63
63
  process.exit(1)
64
64
  }
65
65
 
66
- // ─── Load collections from disk ─────────────────────────────────────────────
66
+ // ─── Load recordsByQuery from disk ─────────────────────────────────────────────
67
67
 
68
- async function loadCollection(dir) {
68
+ async function loadRecords(dir) {
69
69
  const files = await readdir(dir)
70
70
  const items = []
71
71
  for (const file of files) {
@@ -94,17 +94,17 @@ async function loadCollection(dir) {
94
94
  return items
95
95
  }
96
96
 
97
- async function loadAllCollections() {
98
- const entries = await readdir(COLLECTIONS_DIR)
99
- const collections = {}
97
+ async function loadAllRecords() {
98
+ const entries = await readdir(ENTITIES_ROOT)
99
+ const recordsByQuery = {}
100
100
  for (const name of entries) {
101
- const fullPath = join(COLLECTIONS_DIR, name)
101
+ const fullPath = join(ENTITIES_ROOT, name)
102
102
  const s = await stat(fullPath)
103
103
  if (!s.isDirectory()) continue
104
- collections[name] = await loadCollection(fullPath)
105
- console.log(`[dev-backend] Loaded ${collections[name].length} items from "${name}"`)
104
+ recordsByQuery[name] = await loadRecords(fullPath)
105
+ console.log(`[dev-backend] Loaded ${recordsByQuery[name].length} items from "${name}"`)
106
106
  }
107
- return collections
107
+ return recordsByQuery
108
108
  }
109
109
 
110
110
  // ─── Operator handling (mirrors default-fetcher pushdown wire format) ───────
@@ -182,16 +182,16 @@ function send(res, status, body) {
182
182
  res.end(typeof body === 'string' ? body : JSON.stringify(body))
183
183
  }
184
184
 
185
- async function handleRequest(req, res, collections) {
185
+ async function handleRequest(req, res, recordsByQuery) {
186
186
  if (req.method === 'OPTIONS') return send(res, 204, '')
187
187
 
188
188
  const url = new URL(req.url, `http://${req.headers.host}`)
189
189
  const match = url.pathname.match(/^\/api\/([^/]+)(?:\/([^/]+))?$/)
190
190
  if (!match) return send(res, 404, { error: 'Not found' })
191
191
 
192
- const [, collectionName, slug] = match
193
- const items = collections[collectionName]
194
- if (!items) return send(res, 404, { error: `Unknown collection: ${collectionName}` })
192
+ const [, queryName, slug] = match
193
+ const items = recordsByQuery[queryName]
194
+ if (!items) return send(res, 404, { error: `Unknown query: ${queryName}` })
195
195
 
196
196
  // Single record by slug.
197
197
  if (slug) {
@@ -221,14 +221,14 @@ async function handleRequest(req, res, collections) {
221
221
 
222
222
  // ─── Boot ───────────────────────────────────────────────────────────────────
223
223
 
224
- const collections = await loadAllCollections()
225
- const knownCollections = Object.keys(collections)
226
- if (knownCollections.length === 0) {
227
- console.warn('[dev-backend] No collections found.')
224
+ const recordsByQuery = await loadAllRecords()
225
+ const knownQueries = Object.keys(recordsByQuery)
226
+ if (knownQueries.length === 0) {
227
+ console.warn('[dev-backend] No recordsByQuery found.')
228
228
  }
229
229
 
230
230
  const server = createServer((req, res) => {
231
- handleRequest(req, res, collections).catch((err) => {
231
+ handleRequest(req, res, recordsByQuery).catch((err) => {
232
232
  console.error('[dev-backend] Request handler threw:', err)
233
233
  send(res, 500, { error: 'Internal server error' })
234
234
  })
@@ -236,10 +236,10 @@ const server = createServer((req, res) => {
236
236
 
237
237
  server.listen(PORT, () => {
238
238
  console.log(`[dev-backend] Listening on http://localhost:${PORT}`)
239
- console.log(`[dev-backend] Collections: ${knownCollections.join(', ') || '(none)'}`)
239
+ console.log(`[dev-backend] Queries: ${knownQueries.join(', ') || '(none)'}`)
240
240
  console.log('[dev-backend] Endpoints:')
241
- for (const name of knownCollections) {
242
- console.log(` GET /api/${name} — full collection`)
241
+ for (const name of knownQueries) {
242
+ console.log(` GET /api/${name} — all records`)
243
243
  console.log(` GET /api/${name}?_where=<JSON> — filtered`)
244
244
  console.log(` GET /api/${name}/{slug} — single record`)
245
245
  console.log(` POST /api/${name} body: { where } — operators in body`)
@@ -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
- * collections/{collectionName}/{slug}.md - Collection items
13
+ * collections/{queryName}/{slug}.md - Collection items
14
14
  *
15
15
  * Resolution order for sections:
16
16
  * 1. page-ids/{pageId}/{stableId}.md (if page has id:)
@@ -22,6 +22,7 @@ import { readFile, readdir, stat } from 'fs/promises'
22
22
  import { existsSync } from 'node:fs'
23
23
  import { join, relative, dirname } from 'node:path'
24
24
  import yaml from 'js-yaml'
25
+ import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
25
26
 
26
27
  // Try to import content-reader for markdown → ProseMirror conversion
27
28
  let markdownToProseMirror
@@ -140,24 +141,35 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
140
141
  }
141
142
 
142
143
  /**
143
- * Load free-form translation for a collection item
144
+ * Load a free-form translation for one ENTITY.
144
145
  *
145
- * Path: collections/{collectionName}/{slug}.md
146
+ * Path: entities/{schema dirs}/{slug}.md — mirroring the pool exactly.
146
147
  *
147
- * @param {Object} item - Collection item with slug
148
- * @param {string} collectionName - Name of the collection
148
+ * KEYED BY THE ENTITY, NOT BY A QUERY. It used to be
149
+ * `collections/{queryName}/{slug}.md`, which was fine only while a
150
+ * collection was also a directory of files. With `entities/{schema}/` as the pool,
151
+ * TWO queries can cover one schema — so a query-keyed path would make an author
152
+ * write the same translation once per query, and finding neither from the other.
153
+ * ⚠️ And a query is renameable where a record's model is not: keying on the query
154
+ * orphaned every translation the moment someone renamed one.
155
+ *
156
+ * @param {Object} item - entity with a slug
157
+ * @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
149
158
  * @param {string} locale - Locale code
150
159
  * @param {string} localesDir - Path to locales directory
151
160
  * @returns {Promise<Object|null>} Parsed translation { frontmatter, content } or null
152
161
  */
153
- export async function loadFreeformCollectionItem(item, collectionName, locale, localesDir) {
162
+ export async function loadFreeformRecord(item, schema, locale, localesDir) {
154
163
  const slug = item.slug
155
164
  if (!slug) return null
156
165
 
166
+ const rel = buildFreeformRecordPath(schema, slug)
167
+ if (!rel) return null
168
+
157
169
  const freeformDir = join(localesDir, 'freeform', locale)
158
170
  if (!existsSync(freeformDir)) return null
159
171
 
160
- const filePath = join(freeformDir, 'collections', collectionName, `${slug}.md`)
172
+ const filePath = join(freeformDir, rel)
161
173
  if (!existsSync(filePath)) return null
162
174
 
163
175
  try {
@@ -174,7 +186,7 @@ export async function loadFreeformCollectionItem(item, collectionName, locale, l
174
186
  relativePath: relative(join(localesDir, 'freeform', locale), filePath)
175
187
  }
176
188
  } catch (err) {
177
- console.warn(`[i18n] Failed to load free-form collection item ${filePath}: ${err.message}`)
189
+ console.warn(`[i18n] Failed to load free-form record ${filePath}: ${err.message}`)
178
190
  return null
179
191
  }
180
192
  }
@@ -221,7 +233,7 @@ export async function discoverFreeformTranslations(locale, localesDir) {
221
233
  const result = {
222
234
  pages: [],
223
235
  pageIds: [],
224
- collections: []
236
+ records: []
225
237
  }
226
238
 
227
239
  if (!existsSync(freeformDir)) return result
@@ -238,10 +250,10 @@ export async function discoverFreeformTranslations(locale, localesDir) {
238
250
  result.pageIds = await discoverMarkdownFiles(pageIdsDir, pageIdsDir)
239
251
  }
240
252
 
241
- // Discover collection translations
242
- const collectionsDir = join(freeformDir, 'collections')
243
- if (existsSync(collectionsDir)) {
244
- result.collections = await discoverMarkdownFiles(collectionsDir, collectionsDir)
253
+ // Discover entity translations
254
+ const entitiesDir = join(freeformDir, ENTITIES_DIR)
255
+ if (existsSync(entitiesDir)) {
256
+ result.records = await discoverMarkdownFiles(entitiesDir, entitiesDir)
245
257
  }
246
258
 
247
259
  return result
@@ -267,7 +279,7 @@ export async function getFreeformFileMeta(filePath) {
267
279
  * Parse a free-form translation file path to extract metadata
268
280
  *
269
281
  * @param {string} relativePath - Path relative to locale's freeform dir
270
- * @returns {Object} { type, pageRoute?, pageId?, collectionName?, stableId, slug? }
282
+ * @returns {Object} { type, pageRoute?, pageId?, queryName?, stableId, slug? }
271
283
  */
272
284
  export function parseFreeformPath(relativePath) {
273
285
  const parts = relativePath.split('/')
@@ -287,11 +299,11 @@ export function parseFreeformPath(relativePath) {
287
299
  return { type: 'pageId', pageId, stableId }
288
300
  }
289
301
 
290
- if (parts[0] === 'collections') {
291
- // collections/articles/getting-started.md → { type: 'collection', collectionName: 'articles', slug: 'getting-started' }
302
+ if (parts[0] === ENTITIES_DIR) {
303
+ // collections/articles/getting-started.md → { type: 'collection', queryName: 'articles', slug: 'getting-started' }
292
304
  const slug = parts[parts.length - 1].replace('.md', '')
293
- const collectionName = parts[1]
294
- return { type: 'collection', collectionName, slug }
305
+ const queryName = parts[1]
306
+ return { type: 'record', queryName, slug }
295
307
  }
296
308
 
297
309
  return { type: 'unknown', relativePath }
@@ -325,12 +337,20 @@ export function buildFreeformPath(section, page, preferPageId = true) {
325
337
  }
326
338
 
327
339
  /**
328
- * Build the expected free-form translation path for a collection item
340
+ * The free-form translation path for one entity — the pool's own layout, under
341
+ * `locales/freeform/{locale}/`.
342
+ *
343
+ * ⛔ ONE DERIVATION, shared by the producer (which looks a translation up) and the
344
+ * projector (which writes one). `poolDirsForSchema` is the same inverse the pull
345
+ * side places records with, so a translation cannot land beside a record it is not
346
+ * for.
329
347
  *
330
- * @param {string} collectionName - Name of the collection
331
- * @param {string} slug - Item slug
332
- * @returns {string} Relative path (e.g., 'collections/articles/getting-started.md')
348
+ * @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
349
+ * @param {string} slug - the entity's slug
350
+ * @returns {string|null} e.g. `entities/article/getting-started.md`, or null for a
351
+ * ref this layout cannot express
333
352
  */
334
- export function buildFreeformCollectionPath(collectionName, slug) {
335
- return `collections/${collectionName}/${slug}.md`
353
+ export function buildFreeformRecordPath(schema, slug) {
354
+ const dirs = poolDirsForSchema(schema)
355
+ return dirs ? `${ENTITIES_DIR}/${dirs.join('/')}/${slug}.md` : null
336
356
  }
package/src/i18n/index.js CHANGED
@@ -17,23 +17,23 @@ import { syncManifests, formatSyncReport } from './sync.js'
17
17
  import { mergeTranslations, generateAllLocales } from './merge.js'
18
18
  import { auditLocale, cleanLocale, formatAuditReport } from './audit.js'
19
19
  import {
20
- extractCollectionContent,
21
- buildLocalizedCollections,
22
- getCollectionLocales,
23
- translateCollectionData,
24
- COLLECTIONS_DIR
25
- } from './collections.js'
20
+ extractRecordContent,
21
+ buildLocalizedRecords,
22
+ getRecordLocales,
23
+ translateRecordData,
24
+ RECORDS_DIR
25
+ } from './records.js'
26
26
  import { generateSearchIndex, isSearchEnabled } from '../search/index.js'
27
27
 
28
28
  // Free-form translation support
29
29
  import {
30
30
  loadFreeformTranslation,
31
- loadFreeformCollectionItem,
31
+ loadFreeformRecord,
32
32
  discoverFreeformTranslations,
33
33
  getFreeformFileMeta,
34
34
  parseFreeformPath,
35
35
  buildFreeformPath,
36
- buildFreeformCollectionPath
36
+ buildFreeformRecordPath
37
37
  } from './freeform.js'
38
38
  import {
39
39
  computeSourceHash,
@@ -67,11 +67,11 @@ export {
67
67
  formatAuditReport,
68
68
 
69
69
  // Collection functions
70
- extractCollectionContent,
71
- buildLocalizedCollections,
72
- getCollectionLocales,
73
- translateCollectionData,
74
- COLLECTIONS_DIR,
70
+ extractRecordContent,
71
+ buildLocalizedRecords,
72
+ getRecordLocales,
73
+ translateRecordData,
74
+ RECORDS_DIR,
75
75
 
76
76
  // Locale resolution
77
77
  getAvailableLocales,
@@ -79,12 +79,12 @@ export {
79
79
 
80
80
  // Free-form translation functions
81
81
  loadFreeformTranslation,
82
- loadFreeformCollectionItem,
82
+ loadFreeformRecord,
83
83
  discoverFreeformTranslations,
84
84
  getFreeformFileMeta,
85
85
  parseFreeformPath,
86
86
  buildFreeformPath,
87
- buildFreeformCollectionPath,
87
+ buildFreeformRecordPath,
88
88
 
89
89
  // Free-form manifest functions
90
90
  computeSourceHash,
@@ -221,19 +221,19 @@ export async function extractManifest(siteRoot, siteContent, options = {}) {
221
221
  * @param {Object} options - Options
222
222
  * @returns {Promise<Object>} { manifest, report }
223
223
  */
224
- export async function extractCollectionManifest(siteRoot, options = {}) {
224
+ export async function extractRecordManifest(siteRoot, options = {}) {
225
225
  const { localesDir = DEFAULTS.localesDir, dryRun = false } = options
226
226
 
227
227
  // Extract translatable content from collections
228
- const manifest = await extractCollectionContent(siteRoot)
228
+ const manifest = await extractRecordContent(siteRoot)
229
229
 
230
230
  // Ensure collections locales directory exists
231
- const collectionsDir = join(siteRoot, localesDir, COLLECTIONS_DIR)
232
- if (!existsSync(collectionsDir)) {
233
- await mkdir(collectionsDir, { recursive: true })
231
+ const recordLocalesDir = join(siteRoot, localesDir, RECORDS_DIR)
232
+ if (!existsSync(recordLocalesDir)) {
233
+ await mkdir(recordLocalesDir, { recursive: true })
234
234
  }
235
235
 
236
- const manifestPath = join(collectionsDir, 'manifest.json')
236
+ const manifestPath = join(recordLocalesDir, 'manifest.json')
237
237
 
238
238
  // Load previous manifest for comparison
239
239
  let previousManifest = null
@@ -458,7 +458,7 @@ async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
458
458
 
459
459
  // Check for unregistered translations (new files)
460
460
  const discovered = await discoverFreeformTranslations(locale, dirname(dirname(freeformDir)))
461
- const allPaths = [...discovered.pages, ...discovered.pageIds, ...discovered.collections]
461
+ const allPaths = [...discovered.pages, ...discovered.pageIds, ...discovered.records]
462
462
  const unregistered = await getUnregisteredTranslations(freeformDir, allPaths)
463
463
  for (const path of unregistered) {
464
464
  // Auto-register new free-form translations