@uniweb/build 0.11.2 → 0.11.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,14 +51,15 @@
51
51
  "jest": "^29.7.0"
52
52
  },
53
53
  "dependencies": {
54
+ "@citestyle/bibtex": "^1.0.0",
54
55
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
55
56
  "js-yaml": "^4.1.0",
56
57
  "sharp": "^0.33.2",
57
58
  "@uniweb/theming": "0.1.3"
58
59
  },
59
60
  "optionalDependencies": {
60
- "@uniweb/runtime": "0.8.4",
61
- "@uniweb/content-reader": "1.1.5",
61
+ "@uniweb/content-reader": "1.1.7",
62
+ "@uniweb/runtime": "0.8.6",
62
63
  "@uniweb/schemas": "0.2.1"
63
64
  },
64
65
  "peerDependencies": {
@@ -68,7 +69,7 @@
68
69
  "@tailwindcss/vite": "^4.0.0",
69
70
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
70
71
  "vite-plugin-svgr": "^4.0.0",
71
- "@uniweb/core": "0.7.3"
72
+ "@uniweb/core": "0.7.5"
72
73
  },
73
74
  "peerDependenciesMeta": {
74
75
  "vite": {
@@ -1,13 +1,38 @@
1
1
  /**
2
2
  * Clean Content Entry Point
3
3
  *
4
- * Exposes only collectSiteContent and its clean dependency chain
5
- * (Node builtins, js-yaml, @uniweb/theming). No Vite, sharp, or React.
6
- *
7
- * Used by Studio's sidecar (Bun-compiled binary) where those heavy
8
- * peer/native dependencies aren't available.
4
+ * Exposes content-collection helpers and their clean dependency chain
5
+ * (Node builtins, js-yaml, @uniweb/theming, @uniweb/core). No Vite,
6
+ * sharp, or React. Used by Studio's sidecar (Bun-compiled binary) and
7
+ * by @uniweb/unipress (CLI compile path) where those heavy peer/native
8
+ * dependencies aren't available.
9
9
  *
10
10
  * @module @uniweb/build/content
11
11
  */
12
12
 
13
13
  export { collectSiteContent } from '../site/content-collector.js'
14
+
15
+ // Collections + fetch resolution. Pure functions on the clean dep
16
+ // chain — re-exported here so headless callers (unipress, sidecars)
17
+ // can resolve `collections:` declarations without importing
18
+ // `@uniweb/build/site` (which pulls Vite via its plugin index).
19
+ export {
20
+ processCollections,
21
+ writeCollectionFiles,
22
+ getCollectionLastModified,
23
+ } from '../site/collection-processor.js'
24
+ export {
25
+ parseFetchConfig,
26
+ executeFetch,
27
+ applyFilter,
28
+ applySort,
29
+ applyPostProcessing,
30
+ mergeDataIntoContent,
31
+ singularize,
32
+ } from '../site/data-fetcher.js'
33
+
34
+ // Cross-reference registry. Walks the document tree, finds every
35
+ // block-level element with a {#id} attribute, registers the id with
36
+ // its inferred kind + counter. Consumed by the framework's <Ref>
37
+ // component to render `[#id]` cross-references.
38
+ export { buildXrefRegistry } from '../site/xref-registry.js'
@@ -5,8 +5,15 @@
5
5
  * Collections are defined in site.yml and processed at build time.
6
6
  *
7
7
  * Features:
8
- * - Discovers markdown (.md), data (.yml/.yaml), and JSON (.json) files in collection folders
9
- * - Parses frontmatter for metadata (markdown), full YAML (data items), or JSON (data items)
8
+ * - Discovers markdown (.md), data (.yml/.yaml), JSON (.json), and BibTeX (.bib)
9
+ * files in collection folders
10
+ * - Parses frontmatter for metadata (markdown), full YAML or JSON (data items),
11
+ * or BibTeX → CSL-JSON (bibliography items)
12
+ * - Pure-data formats (YAML, JSON, BibTeX) accept either one record per file
13
+ * (mapping at the top, slug from filename) or many records per file (array
14
+ * at the top, each item carries its own slug; BibTeX always produces an
15
+ * array, with the cite key as slug). Multiple files in the same folder
16
+ * merge — the loader flattens one level after collecting them.
10
17
  * - Converts markdown body to ProseMirror JSON
11
18
  * - Supports filtering, sorting, and limiting
12
19
  * - Auto-generates excerpts and extracts first images (markdown items only)
@@ -29,6 +36,7 @@ import { readFile, readdir, stat, writeFile, mkdir, copyFile } from 'node:fs/pro
29
36
  import { join, basename, extname, dirname, relative, resolve } from 'node:path'
30
37
  import { existsSync } from 'node:fs'
31
38
  import yaml from 'js-yaml'
39
+ import { parseBibtex } from '@citestyle/bibtex'
32
40
  import { applyFilter, applySort } from './data-fetcher.js'
33
41
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
34
42
 
@@ -393,21 +401,35 @@ async function processDataItemAssets(data, itemPath, siteRoot, collectionName, b
393
401
  * Process a single data item from a YAML file
394
402
  *
395
403
  * YAML items are pure data — no ProseMirror conversion, no body, no excerpt,
396
- * no image extraction, no lastModified. The output is slug + YAML fields.
404
+ * no image extraction, no lastModified.
405
+ *
406
+ * A YAML file containing a top-level array returns all items (single-file
407
+ * collection); each item must carry its own `slug`. A YAML file containing
408
+ * a mapping returns a single item with `slug` derived from the filename.
409
+ * Mirrors `processJsonItem` for parity across pure-data formats.
397
410
  *
398
411
  * @param {string} dir - Collection directory path
399
412
  * @param {string} filename - YAML filename (.yml or .yaml)
400
- * @returns {Promise<Object|null>} Processed item or null if unpublished
413
+ * @returns {Promise<Object|Array|null>} Processed item(s) or null if unpublished
401
414
  */
402
415
  async function processDataItem(dir, filename, siteRoot, collectionName, basePath) {
403
416
  const filepath = join(dir, filename)
404
417
  const raw = await readFile(filepath, 'utf-8')
405
- const slug = basename(filename, extname(filename))
406
418
  const data = yaml.load(raw) || {}
407
419
 
408
- // Skip unpublished items
409
- if (data.published === false) return null
420
+ // Array multiple items (single-file collection)
421
+ if (Array.isArray(data)) {
422
+ for (const item of data) {
423
+ if (item && typeof item === 'object') {
424
+ await processDataItemAssets(item, filepath, siteRoot, collectionName, basePath)
425
+ }
426
+ }
427
+ return data
428
+ }
410
429
 
430
+ // Mapping → single item
431
+ if (data.published === false) return null
432
+ const slug = basename(filename, extname(filename))
411
433
  const item = { slug, ...data }
412
434
  await processDataItemAssets(item, filepath, siteRoot, collectionName, basePath)
413
435
  return item
@@ -447,6 +469,29 @@ async function processJsonItem(dir, filename, siteRoot, collectionName, basePath
447
469
  return item
448
470
  }
449
471
 
472
+ /**
473
+ * Process a single BibTeX file into an array of CSL-JSON bibliography items.
474
+ *
475
+ * Each `@entry{key, ...}` becomes one item. The BibTeX cite key is preserved
476
+ * as `id` (CSL-JSON convention) and copied to `slug` so per-record file
477
+ * emission and runtime lookups behave the same as for other formats.
478
+ *
479
+ * No asset processing — bibliography records reference URLs and DOIs, not
480
+ * local files.
481
+ *
482
+ * @param {string} dir - Collection directory path
483
+ * @param {string} filename - BibTeX filename (.bib)
484
+ * @returns {Promise<Array<Object>>} Array of CSL-JSON items, each with `slug`
485
+ */
486
+ async function processBibtexItem(dir, filename) {
487
+ const filepath = join(dir, filename)
488
+ const raw = await readFile(filepath, 'utf-8')
489
+ const entries = parseBibtex(raw)
490
+ return entries
491
+ .filter(entry => entry && entry.id)
492
+ .map(entry => ({ slug: entry.id, ...entry }))
493
+ }
494
+
450
495
  /**
451
496
  * Process a single content item from a markdown file
452
497
  *
@@ -512,12 +557,16 @@ async function collectItems(siteDir, config, collectionsBase, basePath) {
512
557
  const files = await readdir(collectionDir)
513
558
  const itemFiles = files.filter(f =>
514
559
  !f.startsWith('_') &&
515
- (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json'))
560
+ (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json') || f.endsWith('.bib'))
516
561
  )
517
562
 
518
- // Process all collection files (markdown → content items, YAML/JSON → data items)
563
+ // Process all collection files (markdown → content items, YAML/JSON → data
564
+ // items, BibTeX → CSL-JSON bibliography items).
519
565
  let items = await Promise.all(
520
566
  itemFiles.map(file => {
567
+ if (file.endsWith('.bib')) {
568
+ return processBibtexItem(collectionDir, file)
569
+ }
521
570
  if (file.endsWith('.json')) {
522
571
  return processJsonItem(collectionDir, file, siteDir, config.name, basePath)
523
572
  }
@@ -528,7 +577,8 @@ async function collectItems(siteDir, config, collectionsBase, basePath) {
528
577
  })
529
578
  )
530
579
 
531
- // Flatten arrays from JSON files that contain multiple items
580
+ // Flatten one level: array-form YAML/JSON files and every .bib file
581
+ // contribute their entries individually.
532
582
  items = items.flat()
533
583
 
534
584
  // Filter out nulls (unpublished items)
@@ -673,7 +723,7 @@ export async function getCollectionLastModified(siteDir, config) {
673
723
  const files = await readdir(collectionDir)
674
724
  const itemFiles = files.filter(f =>
675
725
  !f.startsWith('_') &&
676
- (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json'))
726
+ (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json') || f.endsWith('.bib'))
677
727
  )
678
728
 
679
729
  let lastModified = null
@@ -181,13 +181,19 @@ async function readYamlFile(filePath) {
181
181
  /**
182
182
  * Extract inset references from a ProseMirror document.
183
183
  *
184
- * Walks top-level nodes for `inset_ref` (produced by content-reader
185
- * for `![alt](@ComponentName){params}` syntax). Each ref is removed from the
186
- * document and replaced with an `inset_placeholder` node carrying a
187
- * unique refId. The extracted refs are returned as an array.
184
+ * Walks the document recursively for `inset_ref` nodes (produced by
185
+ * content-reader for the `![alt](@ComponentName){params}` /
186
+ * `[text](@ComponentName){params}` / `[@key]{params}` forms). Each ref
187
+ * is removed and replaced in-place with an `inset_placeholder` node
188
+ * carrying a unique refId. The extracted refs are returned as an array.
189
+ *
190
+ * Inline insets (mid-paragraph) are kept as inline placeholders so the
191
+ * paragraph's text flow is preserved; block-level insets (own line)
192
+ * stay at the document root. Both share the same refId/getInset(refId)
193
+ * lookup machinery — only the position differs.
188
194
  *
189
195
  * @param {Object} doc - ProseMirror document (mutated in place)
190
- * @returns {Array} Array of { refId, type, params, description }
196
+ * @returns {Array} Array of { refId, type, params, description, embedKind }
191
197
  */
192
198
  function extractInsets(doc) {
193
199
  if (!doc?.content || !Array.isArray(doc.content)) return []
@@ -195,25 +201,34 @@ function extractInsets(doc) {
195
201
  const insets = []
196
202
  let refIndex = 0
197
203
 
198
- for (let i = 0; i < doc.content.length; i++) {
199
- const node = doc.content[i]
200
- if (node.type === 'inset_ref') {
201
- const { component, alt, ...params } = node.attrs || {}
202
- const refId = `inset_${refIndex++}`
203
- insets.push({
204
- refId,
205
- type: component,
206
- params: Object.keys(params).length > 0 ? params : {},
207
- title: alt || null,
208
- })
209
- // Replace in-place with placeholder
210
- doc.content[i] = {
211
- type: 'inset_placeholder',
212
- attrs: { refId },
204
+ function visit(nodes) {
205
+ if (!Array.isArray(nodes)) return
206
+ for (let i = 0; i < nodes.length; i++) {
207
+ const node = nodes[i]
208
+ if (!node) continue
209
+ if (node.type === 'inset_ref') {
210
+ const { component, alt, embedKind, ...params } = node.attrs || {}
211
+ const refId = `inset_${refIndex++}`
212
+ insets.push({
213
+ refId,
214
+ type: component,
215
+ embedKind: embedKind || 'visual',
216
+ params: Object.keys(params).length > 0 ? params : {},
217
+ title: alt || null,
218
+ })
219
+ nodes[i] = {
220
+ type: 'inset_placeholder',
221
+ attrs: { refId, embedKind: embedKind || 'visual' },
222
+ }
223
+ continue
224
+ }
225
+ if (Array.isArray(node.content)) {
226
+ visit(node.content)
213
227
  }
214
228
  }
215
229
  }
216
230
 
231
+ visit(doc.content)
217
232
  return insets
218
233
  }
219
234
 
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Cross-reference registry — populated at content-collection time.
3
+ *
4
+ * Walks the parsed document tree to find every block-level element
5
+ * carrying a {#id} attribute, infers the element's kind from its node
6
+ * type, and records the entry with a per-kind counter. The result
7
+ * attaches to siteContent.xref and is consumed by the framework's
8
+ * <Ref> component to render `[#id]` cross-references.
9
+ *
10
+ * Built-in kinds:
11
+ * - heading → 'section' (hierarchical counter: 1, 1.1, 1.1.1, 2, …)
12
+ * - image → 'figure' (flat arabic counter)
13
+ * - math_display → 'equation' (flat arabic counter)
14
+ * - table → 'table' (flat arabic counter)
15
+ *
16
+ * Foundation extensions: foundations may declare additional kinds via
17
+ * `foundation.xref.kinds`. The id-collection pass uses the foundation's
18
+ * declared `prefix` (e.g. `thm` → `theorem`) to classify ids that don't
19
+ * land on a built-in element type.
20
+ *
21
+ * The registry is per-document, not per-page: counters span the whole
22
+ * document so authors get consistent numbering even when their document
23
+ * spans multiple pages. (For page-local counter resets, foundations can
24
+ * declare `resetOn: 'chapter'` on a kind — applied during render, not
25
+ * here.)
26
+ *
27
+ * Output shape:
28
+ * {
29
+ * entries: {
30
+ * <id>: {
31
+ * id, // the id itself
32
+ * kind, // 'figure' | 'equation' | 'section' | 'table' | <foundation-declared>
33
+ * counter, // number for flat kinds, dotted string for hierarchical
34
+ * counterText, // displayable counter ('3', '3.2')
35
+ * sourcePath, // page route the id was declared on (for back-refs)
36
+ * },
37
+ * },
38
+ * }
39
+ */
40
+
41
+ const KIND_BY_TYPE = {
42
+ heading: 'section',
43
+ image: 'figure',
44
+ math_display: 'equation',
45
+ table: 'table',
46
+ }
47
+
48
+ export function buildXrefRegistry(siteContent, options = {}) {
49
+ const { foundationKinds = {}, onWarn = (msg) => console.warn(msg) } = options
50
+
51
+ const entries = {}
52
+ const flatCounters = {} // kind -> next integer counter (figure, equation, table, foundation-declared kinds)
53
+ const sectionStack = [] // hierarchical counters per heading level
54
+
55
+ // Index foundation prefixes for id-prefix inference.
56
+ const prefixToKind = {}
57
+ for (const [kind, meta] of Object.entries(foundationKinds)) {
58
+ if (meta?.prefix) prefixToKind[meta.prefix] = kind
59
+ }
60
+
61
+ function nextFlat(kind) {
62
+ flatCounters[kind] = (flatCounters[kind] || 0) + 1
63
+ return flatCounters[kind]
64
+ }
65
+
66
+ // Track the shallowest heading depth seen — chapter content commonly
67
+ // starts at h2 (chapter title is rendered separately as h1 by the
68
+ // foundation), so the first encountered level becomes the document's
69
+ // top counter level. Re-anchoring: if a later heading appears at a
70
+ // shallower level than `topLevel`, drop topLevel down to match.
71
+ let topLevel = null
72
+
73
+ function nextHierarchical(level) {
74
+ if (topLevel == null || level < topLevel) topLevel = level
75
+ const depth = level - topLevel + 1 // 1-based depth from the document's top level
76
+
77
+ // Trim deeper levels when surfacing back to a shallower heading.
78
+ while (sectionStack.length >= depth) sectionStack.pop()
79
+ while (sectionStack.length < depth - 1) sectionStack.push(1) // implicit parents start at 1
80
+ sectionStack.push((sectionStack[depth - 1] || 0) + 1)
81
+ sectionStack.length = depth
82
+ return sectionStack.slice().join('.')
83
+ }
84
+
85
+ function inferKind(node) {
86
+ // 1. Node-type-based: built-ins.
87
+ const builtin = KIND_BY_TYPE[node.type]
88
+ if (builtin) return builtin
89
+ // 2. Explicit kind attribute on the node ({.kind} or kind=…).
90
+ if (node.attrs?.kind && (KIND_BY_TYPE[node.attrs.kind] || foundationKinds[node.attrs.kind])) {
91
+ return node.attrs.kind
92
+ }
93
+ // 3. Foundation-declared prefix on the id.
94
+ const id = node.attrs?.id
95
+ if (id && id.includes('-')) {
96
+ const prefix = id.slice(0, id.indexOf('-'))
97
+ if (prefixToKind[prefix]) return prefixToKind[prefix]
98
+ }
99
+ return null
100
+ }
101
+
102
+ function visit(node, sourcePath) {
103
+ if (!node || typeof node !== 'object') return
104
+
105
+ // Headings get a hierarchical counter regardless of whether they
106
+ // carry an id — the {#id} just labels them; the counter itself is
107
+ // assigned by tree position.
108
+ let counter = null
109
+ let counterText = null
110
+ if (node.type === 'heading') {
111
+ const level = Math.max(1, Math.min(6, node.attrs?.level || 1))
112
+ counterText = nextHierarchical(level)
113
+ counter = counterText
114
+ }
115
+
116
+ const id = node.attrs?.id
117
+ if (id) {
118
+ const kind = inferKind(node)
119
+ if (!kind) {
120
+ onWarn(`[xref] {#${id}} on unrecognized element type "${node.type}" — ignored`)
121
+ } else if (entries[id]) {
122
+ onWarn(`[xref] duplicate id "${id}" — keeping first registration`)
123
+ } else {
124
+ if (counter == null) {
125
+ counter = nextFlat(kind)
126
+ counterText = String(counter)
127
+ }
128
+ entries[id] = { id, kind, counter, counterText, sourcePath: sourcePath || '' }
129
+ }
130
+ } else if (node.type === 'heading') {
131
+ // Heading without an id still advances the counter — but we don't
132
+ // register it (no way to reference it). The advance is needed so
133
+ // that the *next* labeled heading gets the right hierarchical
134
+ // counter.
135
+ // counter already computed above; just no entry.
136
+ }
137
+
138
+ if (Array.isArray(node.content)) {
139
+ for (const child of node.content) visit(child, sourcePath)
140
+ }
141
+ }
142
+
143
+ for (const page of siteContent.pages || []) {
144
+ for (const section of page.sections || []) {
145
+ const content = section.content
146
+ if (content?.type === 'doc' && Array.isArray(content.content)) {
147
+ for (const child of content.content) visit(child, page.route)
148
+ }
149
+ }
150
+ }
151
+
152
+ return { entries }
153
+ }