uniweb 0.33.1 → 0.34.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,80 @@
1
+ // ⛔ THE ONE PLACE AN ORDINARY ACT IS DESTRUCTIVE.
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.
9
+ //
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.
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.
17
+ //
18
+ // The count comes from the placement identity a previous push banked — what WE
19
+ // last saw the folder hold. It needs no network call, and it is the right source:
20
+ // a site that has never pushed has nothing to lose and is never asked.
21
+
22
+ import { readRecordsConfig, FOLDER_EMPTY } from '@uniweb/build/uwx'
23
+ import { readFolderItemUuids } from '../backend/site-sync.js'
24
+ import { confirm, isNonInteractive, getCliPrefix } from './interactive.js'
25
+
26
+ /**
27
+ * Leaf placements in a banked path→uuid map.
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.
32
+ */
33
+ export function countPlacedRecords(pathToUuid) {
34
+ const paths = Object.keys(pathToUuid || {})
35
+ return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(`${p}/`))).length
36
+ }
37
+
38
+ /**
39
+ * Stop an empty `records.yml` from silently emptying a live folder.
40
+ *
41
+ * @param {object} params
42
+ * @param {string} params.siteDir
43
+ * @param {string[]} params.args - the verb's argv, for --yes / non-interactive
44
+ * @param {(m: string) => void} params.warn - the CALLER's reporter. Each verb owns
45
+ * its own output style; a second copy here would drift from all of them.
46
+ * @param {(m: string) => void} params.note
47
+ * @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
48
+ */
49
+ export async function guardEmptyRecords({ siteDir, args = [], warn, note }) {
50
+ const cfg = await readRecordsConfig(siteDir)
51
+ if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
52
+
53
+ const count = countPlacedRecords(readFolderItemUuids(siteDir))
54
+ // Nothing banked ⇒ nothing this push can remove. A first push of an empty
55
+ // folder is a legitimate (if odd) thing to do, and asking about it would train
56
+ // people to type y.
57
+ if (count === 0) return { ok: true, count: 0 }
58
+
59
+ warn(
60
+ `records.yml is empty, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
61
+ `from the live folder.`
62
+ )
63
+ note(
64
+ 'An empty records.yml means "the folder holds nothing" — it is not the same as ' +
65
+ 'having no records.yml, which leaves the live folder alone. If you meant to ' +
66
+ 'start listing records, delete the file until you have.'
67
+ )
68
+
69
+ // ⚠️ `--yes` ONLY. `-y` is not a flag this CLI has anywhere, and adding one here
70
+ // would have been caught by `flag-guard-coverage.test.js` — which it was.
71
+ if (args.includes('--yes')) return { ok: true, count }
72
+ if (isNonInteractive(args)) {
73
+ warn(`Refusing to remove ${count} record${count === 1 ? '' : 's'} without confirmation.`)
74
+ note(`Re-run with --yes if that is what you want: ${getCliPrefix()} push --yes`)
75
+ return { ok: false, count }
76
+ }
77
+
78
+ const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the live folder?`, false)
79
+ return { ok: yes, count }
80
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Report collections publishing without a data schema.
2
+ * Report queries publishing without a data schema.
3
3
  *
4
4
  * ⭐ This is a PRODUCT decision the author is making, usually without knowing:
5
5
  * entities or static files. It is reported at warn level for that reason — the
@@ -14,10 +14,10 @@
14
14
  * @param {Array<{name: string, model?: string}>} schemaless
15
15
  * @param {{ warn: (m: string) => void, dim: (m: string) => void }} out
16
16
  */
17
- export function reportSchemalessCollections(schemaless, out) {
17
+ export function reportSchemalessQueries(schemaless, out) {
18
18
  if (!schemaless?.length) return
19
19
  const names = schemaless.map((c) => c.name)
20
- const label = names.length === 1 ? 'collection' : 'collections'
20
+ const label = names.length === 1 ? 'query' : 'queries'
21
21
  out.warn(
22
22
  `${names.length} ${label} shipping as STATIC FILES, not entities: ${names.join(', ')}`
23
23
  )
@@ -25,6 +25,6 @@ export function reportSchemalessCollections(schemaless, out) {
25
25
  out.dim(
26
26
  `Declare a data schema to get entities. Each resolves its schema by subfolder name (${schemaless
27
27
  .map((c) => `${c.name} → ${c.model || c.name}`)
28
- .join(', ')}), or set \`schema:\` on the collection.`
28
+ .join(', ')}), or set \`schema:\` on the query.`
29
29
  )
30
30
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Static collection data — the passthrough lane.
3
3
  *
4
- * A site's **schema-less** collections have no entity model, so their compiled
4
+ * A site's **schema-less** queries have no entity model, so their compiled
5
5
  * `dist/data/**` JSON is delivered as files rather than synced as entities.
6
6
  * This plans and uploads that set: one plan call, one object per file, each
7
7
  * PUT to the target the backend returns.
@@ -93,7 +93,7 @@ export async function uploadSiteData({
93
93
  //
94
94
  // ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
95
95
  // succeeds, and only a visitor's fetch 404s. Confirmed against the
96
- // shipped contract's own example (`collections/articles.json` with a
96
+ // shipped contract's own example (`data/articles.json` with a
97
97
  // `/data/`-bearing `serve_base`), which supersedes an earlier
98
98
  // parenthetical that showed the prefix.
99
99
  path: relPath,
@@ -25,20 +25,38 @@ 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
- # collections: ./data # Collections directory
28
+ # entities: ./data # Where this site's entities 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
32
32
  # mounted directory is a folder of pages. Whatever that stub leaves unset comes
33
33
  # from the mounted directory's own folder.yml, so it need only say what differs.
34
34
 
35
- # ─── Data Sources ─────────────────────────────────────────────────────────────
36
- # Define collections (local markdown folders) or fetch remote/local JSON data.
37
- # Pages and sections reference sources by name via `data: source-name`.
35
+ # ─── Structured Content ───────────────────────────────────────────────────────
36
+ # Three things, and they are separate on purpose:
38
37
  #
39
- # collections:
38
+ # entities/{schema}/ your stored records. The folder names their data schema:
39
+ # entities/article/ → @/article (your foundation's)
40
+ # entities/std/person/ → @std/person
41
+ # records.yml WHAT IS PUBLISHED. Listing an entity here is what makes it
42
+ # a record; anything you leave out is a draft. Usually three
43
+ # lines:
44
+ # - article/*.md
45
+ # - person/*.md
46
+ # queries.yml HOW CONTENT IS REACHED — named queries over those records:
47
+ # recent:
48
+ # schema: '@/article'
49
+ # sort: date desc
50
+ # limit: 10
51
+ #
52
+ # Pages and sections then name a query: `data: recent`, or
53
+ # `fetch: { query: recent }`.
54
+ #
55
+ # You can also keep queries here instead of in queries.yml:
56
+ #
57
+ # queries:
40
58
  # articles:
41
- # path: collections/articles # Folder of .md entity files
59
+ # schema: '@/article'
42
60
  # sort: date desc # Sort by frontmatter field
43
61
  #
44
62
  # fetch: