uniweb 0.60.0 → 0.62.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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Giving a foundation its name — `name` in `main.js`'s default export.
3
+ *
4
+ * The rule itself (main.js `name`, else package.json `name`; `src` and `foundation`
5
+ * refused) is `@uniweb/build`'s `foundation-name.js`, shared by the build, `register`
6
+ * and `push`. This module only WRITES the name: the scaffold at `create` / `add`, and
7
+ * `register` when it asks for one.
8
+ *
9
+ * ⛔ NO `@uniweb/build` IMPORT, AND NO IMPORT OF main.js. `uniweb create` runs before
10
+ * any project exists: `@uniweb/build` is an optional peer that may not be installed,
11
+ * and main.js's own imports may not be either. Hence text scans rather than a load,
12
+ * and the one duplicated fact below, pinned to the build's list by
13
+ * `test/foundation-name.test.js`. What a foundation IS named is always read by the
14
+ * build (`readFoundationName`); these only decide where to write.
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { join, resolve, basename, dirname } from 'node:path'
19
+
20
+ // The folders a foundation's code lives in — never a foundation's name
21
+ // (`@uniweb/build`'s FORBIDDEN_FOUNDATION_NAMES; the test keeps them equal).
22
+ export const SCAFFOLD_FOLDER_NAMES = new Set(['src', 'foundation'])
23
+
24
+ // Written above the name wherever the CLI adds one — the scaffold's main.js
25
+ // template says the same.
26
+ const NAME_COMMENT = '// What this foundation registers as — @<org>/<name> — and what sites pin.'
27
+
28
+ /**
29
+ * A name made from free text — a project's name, a folder's — in the form a
30
+ * foundation name takes: lowercase letters, digits and inner hyphens.
31
+ * `My Site` → `my-site`. Null when nothing usable is left, or when what is left
32
+ * names a folder rather than a foundation.
33
+ *
34
+ * @param {unknown} raw
35
+ * @returns {string|null}
36
+ */
37
+ export function normalizeFoundationName(raw) {
38
+ const name = String(raw ?? '')
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/^@[^/]*\//, '')
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ return name && !SCAFFOLD_FOLDER_NAMES.has(name) ? name : null
45
+ }
46
+
47
+ /**
48
+ * A name to offer a foundation that has none of its own: its folder's — or, for
49
+ * the `src/` and `foundation/` a scaffold makes, its project's folder. Null when
50
+ * neither makes one.
51
+ *
52
+ * @param {string} foundationDir
53
+ * @returns {string|null}
54
+ */
55
+ export function suggestFoundationName(foundationDir) {
56
+ const dir = resolve(foundationDir)
57
+ return (
58
+ normalizeFoundationName(basename(dir)) ||
59
+ normalizeFoundationName(basename(dirname(dir)))
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Whether `main.js`'s default export names the foundation — a `name` key at the
65
+ * export's own level, not one nested in `props` or `vars`.
66
+ *
67
+ * A scan, not a parse (see the module note): it skips strings and comments and
68
+ * counts brackets, which is all an object literal of declarations needs. A name
69
+ * that arrives by a spread is invisible to it — the build's read is the truth.
70
+ *
71
+ * @param {string} text - main.js source
72
+ * @returns {boolean}
73
+ */
74
+ export function mainNamesFoundation(text) {
75
+ const opening = /export\s+default\s*\{/.exec(text)
76
+ if (!opening) return false
77
+ let depth = 1
78
+ let atKey = true // just after the opening brace, or a comma at the top level
79
+ for (let i = opening.index + opening[0].length; i < text.length && depth > 0; i++) {
80
+ const c = text[i]
81
+ if (c === '/' && text[i + 1] === '/') {
82
+ i = text.indexOf('\n', i)
83
+ if (i < 0) break
84
+ } else if (c === '/' && text[i + 1] === '*') {
85
+ i = text.indexOf('*/', i + 2) + 1
86
+ if (i <= 0) break
87
+ } else if (c === "'" || c === '"' || c === '`') {
88
+ let j = i + 1
89
+ while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1
90
+ if (depth === 1 && atKey && text.slice(i + 1, j) === 'name' && /^\s*:/.test(text.slice(j + 1))) {
91
+ return true
92
+ }
93
+ i = j
94
+ atKey = false
95
+ } else if ('{[('.includes(c)) {
96
+ depth++
97
+ atKey = false
98
+ } else if ('}])'.includes(c)) {
99
+ depth--
100
+ } else if (depth === 1 && c === ',') {
101
+ atKey = true
102
+ } else if (depth === 1 && atKey && /[A-Za-z_$]/.test(c)) {
103
+ const word = /^[A-Za-z_$][\w$]*/.exec(text.slice(i))[0]
104
+ // `name: 'x'`, or the shorthand `{ name }`
105
+ if (word === 'name' && /^\s*[:,}]/.test(text.slice(i + word.length))) return true
106
+ i += word.length - 1
107
+ atKey = false
108
+ } else if (!/\s/.test(c)) {
109
+ atKey = false
110
+ }
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * Write `name` into a foundation's `main.js`.
117
+ *
118
+ * With `replace`, the one `name: '<replace>'` literal is rewritten — the case where
119
+ * main.js already names the foundation, with a name that cannot register. Without
120
+ * it, `name` becomes the default export's first key. A missing main.js is created.
121
+ *
122
+ * ⚠️ A text edit, not a parse: it handles `export default { … }` — every scaffold
123
+ * and every template — and says so otherwise rather than guessing.
124
+ *
125
+ * @param {string} mainFile
126
+ * @param {string} name - already a valid foundation name
127
+ * @param {{ replace?: string|null }} [opts]
128
+ * @returns {{ ok: true } | { ok: false, reason: string }}
129
+ */
130
+ export function writeFoundationName(mainFile, name, { replace = null } = {}) {
131
+ const line = `${NAME_COMMENT}\n name: '${name}',`
132
+ if (!existsSync(mainFile)) {
133
+ writeFileSync(mainFile, `export default {\n ${line}\n}\n`)
134
+ return { ok: true }
135
+ }
136
+ const text = readFileSync(mainFile, 'utf8')
137
+
138
+ if (replace !== null) {
139
+ const literal = new RegExp(
140
+ `(\\bname\\s*:\\s*)(['"\`])${replace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\2`,
141
+ 'g'
142
+ )
143
+ const hits = text.match(literal) || []
144
+ if (hits.length !== 1) {
145
+ return {
146
+ ok: false,
147
+ reason: `main.js does not name the foundation "${replace}" in exactly one place`
148
+ }
149
+ }
150
+ writeFileSync(mainFile, text.replace(literal, `$1'${name}'`))
151
+ return { ok: true }
152
+ }
153
+
154
+ const opening = /export\s+default\s*\{/.exec(text)
155
+ if (!opening) {
156
+ return { ok: false, reason: 'main.js has no `export default { … }` to add a name to' }
157
+ }
158
+ // The name leads the export — on lines of its own, so a multi-line export keeps
159
+ // its first line and gains a blank one between.
160
+ const at = opening.index + opening[0].length
161
+ writeFileSync(mainFile, `${text.slice(0, at)}\n ${line}\n${text.slice(at)}`)
162
+ return { ok: true }
163
+ }
164
+
165
+ /**
166
+ * Give a freshly scaffolded foundation its name, unless its main.js already has one.
167
+ *
168
+ * Called after template content is applied, because a template's own main.js
169
+ * replaces the scaffolded one — and with it the name the scaffold wrote.
170
+ *
171
+ * @param {string} foundationDir - the scaffolded package (flat layout: main.js at its root)
172
+ * @param {string|null} name
173
+ */
174
+ export function ensureFoundationName(foundationDir, name) {
175
+ if (!name) return
176
+ const mainFile = join(foundationDir, 'main.js')
177
+ if (existsSync(mainFile) && mainNamesFoundation(readFileSync(mainFile, 'utf8'))) return
178
+ writeFoundationName(mainFile, name)
179
+ }
package/src/utils/git.js CHANGED
@@ -37,12 +37,15 @@ export function siteContentRoots(siteDir) {
37
37
  // root added below already covered it and the bare `'collections.yml'` entry
38
38
  // that used to sit in this list resolved to a path no site ever had. A
39
39
  // root-level file has no directory entry standing in for it.
40
+ //
41
+ // The records folder's `folder.yml` is the reverse case: it lives in the records
42
+ // directory, which is a root below, so it needs no entry. (It was `records.yml` at
43
+ // the site root until 2026-09-21, and was listed here then.)
40
44
  const roots = new Set([
41
45
  'site.yml',
42
46
  'theme.yml',
43
47
  'head.html',
44
48
  'queries.yml',
45
- 'records.yml',
46
49
  'locales'
47
50
  ])
48
51
  let paths = {}
@@ -54,7 +57,7 @@ export function siteContentRoots(siteDir) {
54
57
  }
55
58
  roots.add(paths.pages || 'pages')
56
59
  roots.add(paths.layout || 'layout')
57
- roots.add(paths.entities || 'entities')
60
+ roots.add(paths.records || 'records')
58
61
  return [...roots]
59
62
  }
60
63
 
@@ -1,42 +1,47 @@
1
1
  // ⛔ THE ONE PLACE AN ORDINARY ACT IS DESTRUCTIVE.
2
2
  //
3
- // `records.yml` is the sync control, and `missing` and `empty` deliberately mean
4
- // different things: missing leaves the server's folder untouched, empty says the
5
- // folder holds nothing and the backend removes what is there. The asymmetry is
6
- // well-shaped — the safe state is the ABSENCE of a file, so a live folder cannot
7
- // be wiped by deleting one, and the destructive act requires affirmatively
8
- // creating one.
3
+ // A push sends the site's records folder whole, and it REPLACES the backend's. A site
4
+ // with no records directory sends none and leaves the backend's alone; a records
5
+ // directory that holds no records sends an EMPTY folder, and the backend removes what
6
+ // is there. The asymmetry is well-shaped — the safe state is the ABSENCE of the
7
+ // directory, so a site whose records live only on the backend is never emptied by a
8
+ // push of its pages — and it leaves exactly one sharp edge: a directory emptied by
9
+ // accident, or kept with only a placeholder (`records/.gitkeep`), and pushed.
9
10
  //
10
- // ⚠️ WHICH LEAVES EXACTLY ONE SHARP EDGE: a PLACEHOLDER. Someone creates an empty
11
- // `records.yml` intending to fill it in, pushes, and the live folder empties.
12
- // That is plausible and it is the only path where a normal act destroys content.
11
+ // ⭐ THE CLI DOES THE ASKING. Never make "empty" mean "missing" to dodge this: that
12
+ // would delete a capability to avoid writing a prompt.
13
13
  //
14
- // ⭐ THE FORMAT STAYS HONEST AND THE CLI DOES THE ASKING. Never make "empty" mean
15
- // "missing" to dodge this: that would delete a capability to avoid writing a
16
- // prompt.
14
+ // ⛔ Until 2026-09-21 both states were `records.yml`'s — missing and empty — because
15
+ // that file listed the records. The directory holds them now (`@uniweb/build`'s
16
+ // `sendsFolder` decides what a push sends, and this asks about exactly its empty case).
17
17
  //
18
18
  // The count comes from the placement identity a previous push banked — what WE
19
19
  // last saw the folder hold. It needs no network call, and it is the right source:
20
20
  // a site that has never pushed has nothing to lose and is never asked.
21
21
 
22
- import { readRecordsConfig, FOLDER_EMPTY } from '@uniweb/build/uwx'
22
+ import { readEntityPool } from '@uniweb/build/uwx'
23
23
  import { readFolderItemUuids } from '../backend/site-sync.js'
24
24
  import { confirm, isNonInteractive, getCliPrefix } from './interactive.js'
25
25
 
26
26
  /**
27
- * Leaf placements in a banked path→uuid map.
27
+ * Record placements in a banked placement map.
28
28
  *
29
- * A branch's path is a prefix of every path beneath it, so anything that is a
30
- * prefix of another key is a folder rather than a record. Counting raw keys would
31
- * report a two-record site inside one folder as three things to lose.
29
+ * ⭐ A placement banked since 2026-09-21 is keyed by the record it references —
30
+ * `@<record uuid>`, one per record — so where those exist they ARE the count. A map
31
+ * banked before holds `name` chains only: a branch's path is a prefix of every path
32
+ * beneath it, so anything that is a prefix of another key is a folder rather than a
33
+ * record. Counting raw keys would report a two-record site inside one folder as
34
+ * three things to lose — or, with both kinds of key, each record twice.
32
35
  */
33
36
  export function countPlacedRecords(pathToUuid) {
34
- const paths = Object.keys(pathToUuid || {})
35
- return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(`${p}/`))).length
37
+ const keys = Object.keys(pathToUuid || {})
38
+ const byRecord = keys.filter((k) => k.startsWith('@'))
39
+ if (byRecord.length) return byRecord.length
40
+ return keys.filter((p) => !keys.some((q) => q !== p && q.startsWith(`${p}/`))).length
36
41
  }
37
42
 
38
43
  /**
39
- * Stop an empty `records.yml` from silently emptying a live folder.
44
+ * Stop an empty records directory from silently emptying the backend's folder.
40
45
  *
41
46
  * @param {object} params
42
47
  * @param {string} params.siteDir
@@ -48,8 +53,10 @@ export function countPlacedRecords(pathToUuid) {
48
53
  * @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
49
54
  */
50
55
  export async function guardEmptyRecords({ siteDir, backend, args = [], warn, note }) {
51
- const cfg = await readRecordsConfig(siteDir)
52
- if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
56
+ const pool = await readEntityPool(siteDir)
57
+ // Only a directory that is there and holds nothing at all — no records, and no
58
+ // files that fail to be records — sends the empty folder.
59
+ if (!pool.exists || pool.entities.length > 0 || pool.errors.length > 0) return { ok: true, count: 0 }
53
60
 
54
61
  const count = countPlacedRecords(readFolderItemUuids(siteDir, backend))
55
62
  // Nothing banked ⇒ nothing this push can remove. A first push of an empty
@@ -58,13 +65,13 @@ export async function guardEmptyRecords({ siteDir, backend, args = [], warn, not
58
65
  if (count === 0) return { ok: true, count: 0 }
59
66
 
60
67
  warn(
61
- `records.yml is empty, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
62
- `from the live folder.`
68
+ `${pool.dir}/ holds no records, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
69
+ `from the site's folder on the backend.`
63
70
  )
64
71
  note(
65
- 'An empty records.yml means "the folder holds nothing" — it is not the same as ' +
66
- 'having no records.yml, which leaves the live folder alone. If you meant to ' +
67
- 'start listing records, delete the file until you have.'
72
+ `An empty ${pool.dir}/ means "the folder holds nothing" — it is not the same as having no ` +
73
+ `${pool.dir}/ at all, which leaves the backend's folder alone. If you did not mean to remove ` +
74
+ `them, put the files back (or pull) before pushing.`
68
75
  )
69
76
 
70
77
  // ⚠️ `--yes` ONLY. `-y` is not a flag this CLI has anywhere, and adding one here
@@ -76,6 +83,6 @@ export async function guardEmptyRecords({ siteDir, backend, args = [], warn, not
76
83
  return { ok: false, count }
77
84
  }
78
85
 
79
- const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the live folder?`, false)
86
+ const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the site's folder?`, false)
80
87
  return { ok: yes, count }
81
88
  }
@@ -59,6 +59,25 @@ export function bareHandle(scope) {
59
59
  .replace(/\/.*$/, '')
60
60
  }
61
61
 
62
+ /**
63
+ * A publish scope in the one form the registry names things by — `@acme` — from any
64
+ * spelling `--scope` or `package.json::uniweb.scope` may carry it in (`@acme`, `acme`,
65
+ * `@acme/…`). Null when there is no handle in it.
66
+ *
67
+ * ⛔ Every name built from a scope goes through this. The `.uwx` assembly
68
+ * (`@uniweb/build`'s `buildRegistryPackage`) has always accepted `acme` as well as
69
+ * `@acme`, so a name composed from the RAW value disagreed with the one it registered:
70
+ * until 2026-09-21 `register --scope std` registered `@std/src` and then asked to
71
+ * deliver code for `std/src`, which the registry refuses (a scoped name is `@org/name`).
72
+ *
73
+ * @param {unknown} value
74
+ * @returns {string|null}
75
+ */
76
+ export function publishScope(value) {
77
+ const handle = bareHandle(value)
78
+ return handle ? `@${handle}` : null
79
+ }
80
+
62
81
  /**
63
82
  * Validate a handle's GRAMMAR client-side (reserved names are the server's
64
83
  * call — a 409 carries the verdict). Returns an error string, or null.
@@ -76,14 +76,16 @@ export async function getWorkspaceTemplateOutputs({ blank = false } = {}) {
76
76
  }
77
77
 
78
78
  /**
79
- * Scaffold a foundation from the foundation package template
79
+ * Scaffold a foundation from the foundation package template.
80
80
  *
81
- * @param {string} targetDir - Target directory for the foundation
81
+ * @param {string} targetDir
82
82
  * @param {Object} context - Template context
83
- * @param {string} context.name - Package name
83
+ * @param {string} context.name - Package name — workspace plumbing (`src`, `docs-src`)
84
+ * @param {string} [context.registryName] - The foundation's own name, written to
85
+ * main.js's `name`: what it registers as (`@org/<name>`). Omitted when none can be
86
+ * made, and `uniweb register` asks for one
84
87
  * @param {string} context.projectName - Workspace name
85
- * @param {boolean} [context.isExtension] - Whether this is an extension
86
- * @param {Object} [options] - Processing options
88
+ * @param {boolean} [context.isExtension]
87
89
  */
88
90
  export async function scaffoldFoundation(targetDir, context, options = {}) {
89
91
  registerVersions(getVersionsForTemplates())
@@ -6,7 +6,8 @@
6
6
  * - defaultLayout: Name of the default layout from src/layouts/ (optional)
7
7
  * - props: Foundation-wide props accessible via website.foundationProps
8
8
  *
9
- * Identity (name, version, description) comes from package.json.
9
+ * Name: `name` in the default export is what this foundation registers as
10
+ * (@org/<name>) — `uniweb create` adds it. The version comes from package.json.
10
11
  */
11
12
 
12
13
  // Create a layout at src/layouts/MyLayout/index.jsx
@@ -14,10 +14,19 @@
14
14
 
15
15
  {{#if isExtension}}
16
16
  export default {
17
+ {{#if registryName}}
18
+ name: '{{registryName}}',
19
+ {{/if}}
17
20
  extension: true,
18
21
  }
19
22
  {{else}}
20
23
  export default {
24
+ {{#if registryName}}
25
+ // ─── Name ───────────────────────────────────────────────────────────────────
26
+ // What this foundation registers as — @<org>/<name> — and what sites pin.
27
+ name: '{{registryName}}',
28
+
29
+ {{/if}}
21
30
  // ─── Layout ─────────────────────────────────────────────────────────────────
22
31
  // Create layouts in layouts/MyLayout/index.jsx (auto-discovered).
23
32
  // defaultLayout: 'MyLayout',
@@ -25,7 +25,7 @@ index: home
25
25
  # pages/docs: ../../../docs # Mount docs repo at /docs route
26
26
  # pages/blog: ../../blog-content # Mount blog content at /blog route
27
27
  # layout: ./custom-layout # Custom layout directory
28
- # entities: ./data # Where this site's entities live
28
+ # records: ./data # Where this site's records live
29
29
  #
30
30
  # To give a mounted route a layout or a title, add a local folder for it
31
31
  # (pages/docs/) holding a folder.yml — folder.yml, not page.yml, because the
@@ -41,14 +41,17 @@ index: home
41
41
  # ─── Structured Content ───────────────────────────────────────────────────────
42
42
  # Three things, and they are separate on purpose:
43
43
  #
44
- # entities/{schema}/ your stored records. The folder names their data schema:
45
- # entities/article/ → @/article (your foundation's)
46
- # entities/std/person/ → @std/person
47
- # records.yml WHAT IS PUBLISHED. Listing an entity here is what makes it
48
- # a record; anything you leave out is a draft. Usually three
49
- # lines:
50
- # - article/*.md
51
- # - person/*.md
44
+ # records/{schema}/ your site's records — every file here is one. The folder
45
+ # names their data schema:
46
+ # records/article/ → @/article (your foundation's)
47
+ # records/std/person/ → @std/person
48
+ # A file whose name starts with `_` is not a record.
49
+ # records/folder.yml OPTIONAL — sorts records into folders a query can slice
50
+ # with `scope:`. The one file in records/ that is not a
51
+ # record. Most sites have none:
52
+ # - folder: archive
53
+ # records:
54
+ # - article/2025-*.md
52
55
  # queries.yml HOW CONTENT IS REACHED — named queries over those records:
53
56
  # recent:
54
57
  # schema: '@/article'