@uniweb/build 1.3.7 → 1.3.8

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": "1.3.7",
3
+ "version": "1.3.8",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,12 +56,12 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/content-writer": "~1.1.2",
60
59
  "@uniweb/content-reader": "~1.4.0",
60
+ "@uniweb/content-writer": "~1.1.2",
61
61
  "@uniweb/projections": "~1.1.1",
62
+ "@uniweb/schemas": "~1.3.1",
62
63
  "@uniweb/semantic-parser": "~1.8.1",
63
- "@uniweb/theming": "~1.2.0",
64
- "@uniweb/schemas": "~1.3.1"
64
+ "@uniweb/theming": "~1.2.0"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "~1.1.4"
@@ -73,8 +73,8 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/runtime": "~1.1.4",
77
- "@uniweb/core": "~1.2.2"
76
+ "@uniweb/core": "~1.2.2",
77
+ "@uniweb/runtime": "~1.1.4"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -53,8 +53,7 @@
53
53
  // there is no population to carry): a leftover `entities/` beside `records/` would
54
54
  // otherwise hold records nothing reads, silently.
55
55
 
56
- import { readdir } from 'node:fs/promises'
57
- import { existsSync, readFileSync, statSync } from 'node:fs'
56
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
58
57
  import { join, extname, basename, resolve } from 'node:path'
59
58
  import yaml from 'js-yaml'
60
59
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -263,6 +262,17 @@ export function poolPathReadings(dirs) {
263
262
  * leaves the backend's folder alone for a site with no records directory.
264
263
  */
265
264
  export async function readEntityPool(siteRoot, opts = {}) {
265
+ return readEntityPoolSync(siteRoot, opts)
266
+ }
267
+
268
+ /**
269
+ * `readEntityPool`, for a caller that cannot wait — the pull's projection, which asks what a
270
+ * `folder.yml` already says (`records-project.js`). One walk, which the async form returns.
271
+ *
272
+ * @param {string} siteRoot
273
+ * @param {object} [opts] - as `readEntityPool`'s
274
+ */
275
+ export function readEntityPoolSync(siteRoot, opts = {}) {
266
276
  let rel
267
277
  if (opts.dir) {
268
278
  rel = String(opts.dir).replace(/\/+$/, '')
@@ -280,10 +290,10 @@ export async function readEntityPool(siteRoot, opts = {}) {
280
290
  // A file record is named by its stem, so two files of one stem cannot both be one.
281
291
  const fileStems = new Map()
282
292
 
283
- const walk = async (dir, dirs) => {
293
+ const walk = (dir, dirs) => {
284
294
  let listing
285
295
  try {
286
- listing = await readdir(dir, { withFileTypes: true })
296
+ listing = readdirSync(dir, { withFileTypes: true })
287
297
  } catch {
288
298
  return
289
299
  }
@@ -304,7 +314,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
304
314
  )
305
315
  continue
306
316
  }
307
- await walk(full, [...dirs, e.name])
317
+ walk(full, [...dirs, e.name])
308
318
  continue
309
319
  }
310
320
  if (!e.isFile()) continue
@@ -356,7 +366,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
356
366
  }
357
367
  }
358
368
 
359
- await walk(base, [])
369
+ walk(base, [])
360
370
  return { entities, errors, exists: true, dir: rel }
361
371
  }
362
372
 
@@ -296,7 +296,13 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
296
296
  if (document?.$disabled === true) record.draft = true
297
297
 
298
298
  if (format === 'json') return JSON.stringify(record, null, 2) + '\n'
299
- if (format === 'md') return `---\n${yaml.dump(record)}---\n${dec.body}`
299
+ if (format === 'md') {
300
+ // ⭐ Written as a section is (`project-writer.js::assembleSection`): a blank line after the front
301
+ // matter, and a final newline. ⛔ Until 2026-10-17 it had neither, so a pull that rewrote an
302
+ // edited record took both out of the author's file.
303
+ const body = String(dec.body ?? '').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '')
304
+ return `---\n${yaml.dump(record)}---\n${body ? `\n${body}\n` : ''}`
305
+ }
300
306
  return yaml.dump(record) // yaml / yaml
301
307
  }
302
308
 
@@ -418,14 +418,16 @@ function restates(authored, decl) {
418
418
  * (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
419
419
  *
420
420
  * ⛔ A FULL WRITE, NOT A MERGE, and that is the one place this differs from every
421
- * other projected config. The file mirrors the folder's sub-folders: concrete refs
422
- * on both sides, nothing to invert, so a pull is a mirror rather than an update.
423
- * There is also nothing a shallow merge could mean here — the file is a sequence,
424
- * and merging two lists either duplicates entries or silently drops them.
421
+ * other projected config: the file is a sequence, and merging two lists either
422
+ * duplicates entries or silently drops them.
425
423
  *
426
- * The caller decides whether there is anything to write: a folder with no
427
- * sub-folders is no file at all (`folderToFolderYml` removes it), since missing and
428
- * empty mean the same. ⚠️ *Until 2026-09-21 this said an empty file was destructive
424
+ * The caller decides whether there is anything to write: a file that already says
425
+ * what the pulled folder says — its patterns included — is left alone
426
+ * (`records-project.js::organizesAlike`), and a folder with no sub-folders is no file
427
+ * at all (`folderToFolderYml` removes it), since missing and empty mean the same.
428
+ * ⛔ *Until 2026-10-17 this called the file a mirror — "concrete refs on both sides,
429
+ * nothing to invert" — and every pull wrote the folder's paths over a developer's
430
+ * patterns.* ⚠️ *Until 2026-09-21 this said an empty file was destructive
429
431
  * on the next push; that stopped being true when the directory, not this file,
430
432
  * became what decides which records the folder holds.*
431
433
  *
@@ -43,8 +43,9 @@ import { parseFrontmatter } from './entity-source.js'
43
43
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
44
44
  import { foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
45
45
  import { dataKeyTypes } from './data-key-types.js'
46
- import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
47
- import { folderYmlPath } from '../site/records-config.js'
46
+ import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir, readEntityPoolSync } from '../site/entity-pool.js'
47
+ import { folderYmlPath, resolveFolder } from '../site/records-config.js'
48
+ import { canonicalJson } from './same-content.js'
48
49
  import { contentBodyTarget } from './record-layout.js'
49
50
  import { qualifyRef } from '@uniweb/schemas/refs'
50
51
  import { unresolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
@@ -501,9 +502,14 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
501
502
  * Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
502
503
  * directory (`records/folder.yml`, or under `site.yml::paths.records`).
503
504
  *
504
- * ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
505
- * `folder.yml` holds concrete paths on both sides, so there is nothing to invert.
506
- * The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
505
+ * ⭐ A FILE THAT SAYS WHAT THE PULLED FOLDER SAYS IS LEFT AS WRITTEN — its patterns
506
+ * (`member/*.md`), its comments, its order (`organizesAlike`). Otherwise the folder
507
+ * is written as the paths the backend's folder places, which is what it says.
508
+ * ⛔ *This read "the folder round-trips trivially: `folder.yml` holds concrete paths
509
+ * on both sides, so there is nothing to invert" until 2026-10-17 — read as "the file
510
+ * holds no patterns", which it does — and every pull with a folder wrote its paths
511
+ * over the developer's patterns: a record added afterwards fell outside them.* The
512
+ * old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
507
513
  * collections — and inverting a macro is not possible in general. Taking queries
508
514
  * out of the folder is what dissolved that.
509
515
  *
@@ -600,6 +606,16 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
600
606
 
601
607
  const entries = walk(folderDoc.contents, false)
602
608
  if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
609
+ if (existsSync(abs) && organizesAlike({ abs, entries, siteRoot, poolPathByUuid })) {
610
+ return { status: 'unchanged', entries, warnings, file }
611
+ }
612
+ // Said when it replaces a developer's pattern: a record they add from here is placed by nothing.
613
+ if (existsSync(abs) && holdsPattern(abs)) {
614
+ warnings.push(
615
+ `${file}: the folder no longer places its records as its patterns say, so it was written as their ` +
616
+ `paths. A record you add from here sits at the top of the folder until ${file} places it.`
617
+ )
618
+ }
603
619
  if (entries.length === 0) {
604
620
  if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
605
621
  unlinkSync(abs)
@@ -608,6 +624,67 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
608
624
  return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
609
625
  }
610
626
 
627
+ /**
628
+ * Does the `folder.yml` on disk say what the pulled folder says — read by the rule a push reads it
629
+ * by (`resolveFolder`), over the records directory as this pull left it?
630
+ *
631
+ * ⭐ About the records the backend's folder holds, every word: the folders, by name, label and tags,
632
+ * in order; and each record's folder, its tags and label, and its place among the others. Records
633
+ * the backend does not hold yet — a file the developer added and has not pushed — are theirs, so a
634
+ * pattern placing one is not a difference; nor is a folder holding only those. So `member/*.md` is
635
+ * kept while it places exactly the members the backend's folder has there, and goes on placing the
636
+ * next one the developer adds.
637
+ *
638
+ * @returns {boolean}
639
+ */
640
+ function organizesAlike({ abs, entries, siteRoot, poolPathByUuid }) {
641
+ let authored
642
+ try {
643
+ authored = yaml.load(readFileSync(abs, 'utf8'), YAML_OPTIONS)
644
+ } catch {
645
+ return false
646
+ }
647
+ if (!Array.isArray(authored)) return false
648
+ const pool = readEntityPoolSync(siteRoot).entities
649
+ const pulled = new Set(poolPathByUuid.values())
650
+ const held = new Set(pool.filter((e) => pulled.has(e.poolPath)).map((e) => e.id))
651
+ const mine = resolveFolder(authored, pool)
652
+ const theirs = resolveFolder(entries, pool)
653
+ if (mine.errors.length || theirs.errors.length) return false
654
+ const theirFolders = new Set()
655
+ const said = (n) => ({ ...(n.label != null ? { label: n.label } : {}), ...(n.tags ? { tags: n.tags } : {}) })
656
+ // A tree as it reads for the held records. `keep(path, holds)` decides whether a folder stays.
657
+ const shape = (nodes, at, keep) =>
658
+ (nodes || []).flatMap((n) => {
659
+ if (n.kind === 'ref') return held.has(n.$entityId) ? [{ record: n.$entityId, ...said(n) }] : []
660
+ if (n.kind !== 'branch') return []
661
+ const path = [...at, n.name]
662
+ const records = shape(n.$children, path, keep)
663
+ return keep(path.join('/'), records.length > 0) ? [{ folder: n.name, ...said(n), records }] : []
664
+ })
665
+ // Every folder the backend has stays, empty or not; one of the file's stays when it holds a held
666
+ // record or the backend has it too — else it is the developer's, not pushed yet.
667
+ const theirShape = shape(theirs.nodes, [], (path) => (theirFolders.add(path), true))
668
+ const myShape = shape(mine.nodes, [], (path, holds) => holds || theirFolders.has(path))
669
+ return canonicalJson(myShape) === canonicalJson(theirShape)
670
+ }
671
+
672
+ // Does the `folder.yml` on disk place records by a pattern (`member/*.md`) anywhere?
673
+ function holdsPattern(abs) {
674
+ let authored
675
+ try {
676
+ authored = yaml.load(readFileSync(abs, 'utf8'), YAML_OPTIONS)
677
+ } catch {
678
+ return false
679
+ }
680
+ const has = (entries) =>
681
+ (Array.isArray(entries) ? entries : []).some((entry) => {
682
+ const path = typeof entry === 'string' ? entry : entry?.path
683
+ return (typeof path === 'string' && /[*?]/.test(path)) || has(entry?.records)
684
+ })
685
+ return has(authored)
686
+ }
687
+
611
688
  /**
612
689
  * Project a pulled folder + its record entities to `records/**` files.
613
690
  *