uniweb 1.0.0 → 1.0.2

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 (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `site/snapshot.yml` — how `uniweb snapshot` composes a site's preview image,
3
+ * kept with the site so the next run reproduces the same look.
4
+ *
5
+ * It is the command's defaults for this site, and flags override it. Nothing else
6
+ * reads it: the build, `push` and `pull` all leave a site's root files alone
7
+ * unless they name them.
8
+ */
9
+
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
12
+ import yaml from 'js-yaml'
13
+
14
+ import { didYouMean } from './args.js'
15
+
16
+ export const SETTINGS_FILE = 'snapshot.yml'
17
+
18
+ /** In the order they are written. */
19
+ export const SETTING_KEYS = [
20
+ 'route',
21
+ 'layout',
22
+ 'tone',
23
+ 'gap',
24
+ 'overlap',
25
+ 'strip',
26
+ 'side',
27
+ 'frame',
28
+ 'size',
29
+ 'scale',
30
+ 'quality',
31
+ 'hide',
32
+ 'out'
33
+ ]
34
+
35
+ const HEADER = [
36
+ '# How `uniweb snapshot` composes this site\'s preview image.',
37
+ '# Flags override these; `uniweb snapshot <flags> --save` writes them here.',
38
+ ''
39
+ ].join('\n')
40
+
41
+ export class SettingsError extends Error {}
42
+
43
+ /**
44
+ * The settings in `<siteDir>/snapshot.yml`, or `{}` when there is none. `out` comes
45
+ * back absolute, resolved against the site folder.
46
+ */
47
+ export function readSnapshotSettings(siteDir) {
48
+ const file = join(siteDir, SETTINGS_FILE)
49
+ if (!existsSync(file)) return { file, settings: {} }
50
+
51
+ let data
52
+ try {
53
+ data = yaml.load(readFileSync(file, 'utf8'))
54
+ } catch (err) {
55
+ throw new SettingsError(`${SETTINGS_FILE} is not valid YAML: ${err.message.split('\n')[0]}`)
56
+ }
57
+ if (data === undefined || data === null) return { file, settings: {} }
58
+ if (typeof data !== 'object' || Array.isArray(data)) {
59
+ throw new SettingsError(`${SETTINGS_FILE} should be a list of settings, like \`gap: 48\`.`)
60
+ }
61
+
62
+ const settings = {}
63
+ for (const [key, value] of Object.entries(data)) {
64
+ if (!SETTING_KEYS.includes(key)) {
65
+ const suggestion = didYouMean(key, SETTING_KEYS)
66
+ throw new SettingsError(
67
+ `${SETTINGS_FILE} has an unknown setting \`${key}\`.` + (suggestion ? ` Did you mean \`${suggestion}\`?` : '')
68
+ )
69
+ }
70
+ if (value === null || value === undefined) continue
71
+ if (key === 'hide') settings.hide = Array.isArray(value) ? value.map(String) : [String(value)]
72
+ else if (key === 'out') settings.out = resolve(siteDir, String(value))
73
+ else settings[key] = value
74
+ }
75
+ return { file, settings }
76
+ }
77
+
78
+ /** Flags over the file. A gap or an overlap from the flags replaces either from the file. */
79
+ export function mergeSettings(fromFile = {}, fromFlags = {}) {
80
+ const merged = { ...fromFile }
81
+ if (fromFlags.gap !== undefined || fromFlags.overlap !== undefined) {
82
+ delete merged.gap
83
+ delete merged.overlap
84
+ }
85
+ for (const [key, value] of Object.entries(fromFlags)) {
86
+ if (value !== undefined) merged[key] = value
87
+ }
88
+ return merged
89
+ }
90
+
91
+ /**
92
+ * Write the flags of this run into `<siteDir>/snapshot.yml`, over what it already
93
+ * holds. The file is rewritten in full, in `SETTING_KEYS` order, under a header.
94
+ *
95
+ * @returns {{ file: string, saved: string[] }} the keys this run set
96
+ */
97
+ export function saveSnapshotSettings(siteDir, fromFlags, fromFile = readSnapshotSettings(siteDir).settings) {
98
+ const file = join(siteDir, SETTINGS_FILE)
99
+ const merged = mergeSettings(fromFile, fromFlags)
100
+ const ordered = {}
101
+ for (const key of SETTING_KEYS) {
102
+ if (merged[key] === undefined) continue
103
+ ordered[key] = key === 'out' ? toSitePath(siteDir, merged.out) : merged[key]
104
+ }
105
+ writeFileSync(file, HEADER + yaml.dump(ordered, { lineWidth: -1 }))
106
+ return { file, saved: SETTING_KEYS.filter((key) => fromFlags[key] !== undefined) }
107
+ }
108
+
109
+ function toSitePath(siteDir, path) {
110
+ const rel = relative(siteDir, path)
111
+ return isAbsolute(rel) ? path : rel.split(sep).join('/')
112
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Lightweight Update Notification
3
+ *
4
+ * Checks the npm registry for newer versions of the CLI.
5
+ * Runs at most once per day, caches results in ~/.uniweb/update-check.json.
6
+ * Uses Node 20+ built-in fetch — no external dependencies.
7
+ */
8
+
9
+ import { homedir } from 'node:os'
10
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { detectGlobalCliPm, globalCliUpdateCmd } from './pm.js'
13
+
14
+ const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
15
+ const STATE_DIR = join(homedir(), '.uniweb')
16
+ const STATE_FILE = join(STATE_DIR, 'update-check.json')
17
+
18
+ /**
19
+ * Compare two semver strings.
20
+ * Returns 1 if a > b, -1 if a < b, 0 if equal.
21
+ */
22
+ function compareSemver(a, b) {
23
+ const pa = a.split('.').map(Number)
24
+ const pb = b.split('.').map(Number)
25
+ for (let i = 0; i < 3; i++) {
26
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
27
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
28
+ }
29
+ return 0
30
+ }
31
+
32
+ /**
33
+ * Read cached update check state.
34
+ */
35
+ function readState() {
36
+ try {
37
+ if (existsSync(STATE_FILE)) {
38
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'))
39
+ }
40
+ } catch {
41
+ /* ignore corrupt cache */
42
+ }
43
+ return {}
44
+ }
45
+
46
+ /**
47
+ * Write update check state to disk.
48
+ */
49
+ function writeState(state) {
50
+ try {
51
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
52
+ writeFileSync(STATE_FILE, JSON.stringify(state))
53
+ } catch {
54
+ /* ignore write errors */
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Print update notification to stderr (doesn't interfere with piped output).
60
+ * A trailing notice for a finished command; `create` and `clone` do not get
61
+ * one, they stop first (refuseOutdatedProjectStart).
62
+ */
63
+ function printNotification(current, latest) {
64
+ const yellow = '\x1b[33m'
65
+ const cyan = '\x1b[36m'
66
+ const dim = '\x1b[2m'
67
+ const reset = '\x1b[0m'
68
+ const updateCmd = globalCliUpdateCmd(detectGlobalCliPm())
69
+ console.error('')
70
+ console.error(
71
+ `${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`
72
+ )
73
+ console.error(
74
+ `${dim}Run${reset} ${updateCmd} ${dim}to update the CLI${reset}`
75
+ )
76
+ }
77
+
78
+ /**
79
+ * Synchronously read the cache and print a notification if a newer
80
+ * version is known. No network fetch — only reads what `startUpdateCheck`
81
+ * has previously cached. Returns true if a notification was printed.
82
+ *
83
+ * For `--version` / `-v` off a terminal: the user was already asking about
84
+ * the version, so staleness is mentioned while we're here. Goes to stderr so
85
+ * scripts capturing stdout aren't affected.
86
+ *
87
+ * @param {string} currentVersion
88
+ * @returns {boolean} true if a notification was printed
89
+ */
90
+ export function maybeNotifyFromCache(currentVersion) {
91
+ const state = readState()
92
+ if (!state.latestVersion) return false
93
+ if (compareSemver(state.latestVersion, currentVersion) <= 0) return false
94
+ printNotification(currentVersion, state.latestVersion)
95
+ return true
96
+ }
97
+
98
+ /**
99
+ * Whether a release newer than `current` is published. Unknown (`latest` null:
100
+ * offline, or a slow registry) is not behind.
101
+ *
102
+ * @param {string} current
103
+ * @param {string|null} latest
104
+ * @returns {boolean}
105
+ */
106
+ export function isBehind(current, latest) {
107
+ return Boolean(latest) && compareSemver(latest, current) > 0
108
+ }
109
+
110
+ /** Arguments as a shell would need them typed again. */
111
+ export function shellJoin(args) {
112
+ return args
113
+ .map((a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`))
114
+ .join(' ')
115
+ }
116
+
117
+ /**
118
+ * Stop `create` or `clone` when a global CLI has been replaced by a newer
119
+ * release — before anything is written.
120
+ *
121
+ * ⭐ A PROJECT STARTS ON THE CLI THAT CREATES IT: its version table pins the
122
+ * `@uniweb/*` packages, and its official templates come from the release it was
123
+ * published with. Inside a project a global install only launches the project's
124
+ * own CLI; outside one, for `create` and `clone`, it does the work itself — so
125
+ * an outdated one starts the project outdated, and says nothing. `npx uniweb`
126
+ * with no version runs a global install too, which is how a 0.12 CLI went on
127
+ * offering the ten templates it knew while sixteen shipped (2026-10-08).
128
+ *
129
+ * Any newer release stops it, not only a newer minor: the version table moves
130
+ * whenever any package does — a patch of the CLI can pin a new breaking line of
131
+ * one — and the fix is one command either way. Unknown proceeds: this stops a
132
+ * known mistake, it does not require the network.
133
+ *
134
+ * @param {string} current - this CLI's version
135
+ * @param {string[]} argv - the command and its arguments, repeated in the fix
136
+ * @returns {Promise<boolean>} true when it refused, the reason printed
137
+ */
138
+ export async function refuseOutdatedProjectStart(current, argv) {
139
+ const latest = await getLatestVersion({ maxAgeMs: 60 * 60 * 1000 })
140
+ if (!isBehind(current, latest)) return false
141
+ const red = '\x1b[31m'
142
+ const cyan = '\x1b[36m'
143
+ const dim = '\x1b[2m'
144
+ const reset = '\x1b[0m'
145
+ console.error(`${red}✗${reset} This uniweb is ${current}; the latest release is ${latest}.`)
146
+ console.error(
147
+ ` ${dim}A project starts on the packages and templates of the CLI that creates it.${reset}`
148
+ )
149
+ console.error(` Run the latest: ${cyan}npx uniweb@latest ${shellJoin(argv)}${reset}`)
150
+ console.error(
151
+ ` Or update this one: ${cyan}${globalCliUpdateCmd(detectGlobalCliPm())}${reset}`
152
+ )
153
+ return true
154
+ }
155
+
156
+ /**
157
+ * Resolve the latest published CLI version WITHOUT printing anything.
158
+ *
159
+ * The notify helpers in this module own both the lookup and the message.
160
+ * That's right for callers who want the house notice, and wrong for callers
161
+ * whose remedy differs — `uniweb update` running from a project's
162
+ * node_modules can't tell the user to `npm i -g uniweb`, because the version
163
+ * they're on is pinned by their own package.json. Those callers need the
164
+ * fact, not the sentence.
165
+ *
166
+ * Caching and the timeout live here rather than at the call site so every
167
+ * lookup gets them. `update.js` previously had its own copy with neither: an
168
+ * unbounded `await fetch()` before the command did any work, which turns a
169
+ * flaky network into a hang on a routine verb.
170
+ *
171
+ * @param {object} [opts]
172
+ * @param {number} [opts.timeoutMs=1500] Network cap. Slow/offline returns
173
+ * whatever the cache holds rather than blocking.
174
+ * @param {boolean} [opts.allowNetwork=true] False = cache-only. Pass false
175
+ * for non-TTY callers; scripts must stay fast and offline-safe (the same
176
+ * convention `--version` follows).
177
+ * @param {number} [opts.maxAgeMs] How old a cache entry may be before a
178
+ * refetch is attempted. Defaults to the module's 1-day interval.
179
+ * @returns {Promise<string|null>} The latest version, or null if unknown.
180
+ */
181
+ export async function getLatestVersion({
182
+ timeoutMs = 1500,
183
+ allowNetwork = true,
184
+ maxAgeMs = CHECK_INTERVAL
185
+ } = {}) {
186
+ const state = readState()
187
+ const cached = state.latestVersion || null
188
+ const fresh = state.lastCheck && Date.now() - state.lastCheck < maxAgeMs
189
+ if (fresh && cached) return cached
190
+ // A stale cache entry still beats nothing for an advisory, so it's the
191
+ // fallback for every failure path below rather than a hard null.
192
+ if (!allowNetwork) return cached
193
+
194
+ const controller = new AbortController()
195
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
196
+ try {
197
+ const res = await fetch('https://registry.npmjs.org/uniweb/latest', {
198
+ signal: controller.signal
199
+ })
200
+ if (!res.ok) return cached
201
+ const data = await res.json()
202
+ const latest = data?.version || null
203
+ if (!latest) return cached
204
+ writeState({ lastCheck: Date.now(), latestVersion: latest })
205
+ return latest
206
+ } catch {
207
+ return cached
208
+ } finally {
209
+ clearTimeout(timer)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Fetch the latest version (with a tight timeout) and print a notice if
215
+ * a newer version is found. Updates the on-disk cache as a side effect
216
+ * so future cache-only callers benefit too.
217
+ *
218
+ * Use this for TTY invocations of `--version` / `-v` where the user is
219
+ * interactively asking about the version and a brief network wait is
220
+ * acceptable. Don't use it for non-TTY callers — scripts capturing
221
+ * stdout (`version=$(uniweb -v)`) need a fast, offline-safe path.
222
+ *
223
+ * @param {string} currentVersion
224
+ * @param {object} [opts]
225
+ * @param {number} [opts.timeoutMs=1500] Network timeout. Slow / offline
226
+ * calls return silently — never block the verb for long.
227
+ * @returns {Promise<boolean>} true if a notice was printed.
228
+ */
229
+ export async function fetchAndNotifyIfNewer(
230
+ currentVersion,
231
+ { timeoutMs = 1500 } = {}
232
+ ) {
233
+ const controller = new AbortController()
234
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
235
+ let latest = null
236
+ try {
237
+ const res = await fetch('https://registry.npmjs.org/uniweb/latest', {
238
+ signal: controller.signal
239
+ })
240
+ if (res.ok) {
241
+ const data = await res.json()
242
+ latest = data?.version || null
243
+ }
244
+ } catch {
245
+ // Aborted, network error, parse error — all silent. The verb
246
+ // shouldn't block on update-check failures.
247
+ } finally {
248
+ clearTimeout(timer)
249
+ }
250
+ if (!latest) return false
251
+ // Refresh the cache so other code paths see this fresh result.
252
+ writeState({ lastCheck: Date.now(), latestVersion: latest })
253
+ if (compareSemver(latest, currentVersion) <= 0) return false
254
+ printNotification(currentVersion, latest)
255
+ return true
256
+ }
257
+
258
+ /**
259
+ * Start a non-blocking update check.
260
+ *
261
+ * Returns a function that, when called (optionally awaited), prints
262
+ * the notification if a newer version was found.
263
+ *
264
+ * @param {string} currentVersion - The currently running CLI version
265
+ * @returns {Function} Call at the end of command execution to show notification
266
+ */
267
+ export function startUpdateCheck(currentVersion) {
268
+ let notification = null
269
+ const state = readState()
270
+
271
+ // Use cached result if checked recently
272
+ if (state.lastCheck && Date.now() - state.lastCheck < CHECK_INTERVAL) {
273
+ if (
274
+ state.latestVersion &&
275
+ compareSemver(state.latestVersion, currentVersion) > 0
276
+ ) {
277
+ notification = state.latestVersion
278
+ }
279
+ return () => {
280
+ if (notification) printNotification(currentVersion, notification)
281
+ }
282
+ }
283
+
284
+ // Background fetch (non-blocking)
285
+ const fetchPromise = fetch('https://registry.npmjs.org/uniweb/latest')
286
+ .then((r) => r.json())
287
+ .then((data) => {
288
+ const latest = data.version
289
+ writeState({ lastCheck: Date.now(), latestVersion: latest })
290
+ if (compareSemver(latest, currentVersion) > 0) {
291
+ notification = latest
292
+ }
293
+ })
294
+ .catch(() => {
295
+ /* network error — ignore silently */
296
+ })
297
+
298
+ return async () => {
299
+ await fetchPromise
300
+ if (notification) printNotification(currentVersion, notification)
301
+ }
302
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Minimal `.uwx` reader — for the commands that run BEFORE a project exists.
3
+ *
4
+ * ## Why this exists rather than importing the real one
5
+ *
6
+ * Every other `.uwx` reader in the CLI (`pull.js`, `site-sync.js`, `content.js`)
7
+ * imports `readZip` from `@uniweb/build/uwx`. That is correct for them: they run
8
+ * *inside* a project, where `@uniweb/build` resolves from the project's own
9
+ * `node_modules`.
10
+ *
11
+ * **`@uniweb/build` is not a dependency of this package** (see `package.json`),
12
+ * so a command that runs where no project exists cannot reach it — statically or
13
+ * dynamically. `uniweb clone` is exactly that command: its whole job is to turn a
14
+ * backend site into a local project that does not exist yet.
15
+ *
16
+ * So this is a deliberate second implementation, kept minimal and dependency-free
17
+ * (`node:zlib` is a builtin), the same way `clone.js` keeps its own
18
+ * `extractDocument` for the same reason. **Do not import this from a command that
19
+ * runs inside a project** — use `@uniweb/build/uwx`'s `readZip` there, so the
20
+ * producer and the consumer of a `.uwx` stay one implementation wherever they can.
21
+ *
22
+ * ## Two things measured on a real payload, both of which would break a naive reader
23
+ *
24
+ * 1. ⛔ **The archive is NOT "stored".** `pull.js` describes the format as *"our
25
+ * Stored ZIP"*, and that is true of `manifest.json` and false of the entity
26
+ * files — measured on a live pull: `manifest.json` method 0 (STORED), the
27
+ * entity JSON method 8 (DEFLATED). A stored-only reader silently yields the
28
+ * manifest and drops the document, i.e. it returns success and no entities.
29
+ * **Both methods are handled below and the tests cover a mixed archive.**
30
+ * 2. The entity payload is the *only* thing wanted; `manifest.json` is skipped, as
31
+ * every other reader in the tree does.
32
+ *
33
+ * We read the **central directory** rather than walking local file headers,
34
+ * because a local header may carry zeroed sizes when the general-purpose bit 3
35
+ * flag defers them to a trailing data descriptor. The central directory always
36
+ * carries the real sizes.
37
+ */
38
+
39
+ import { inflateRawSync } from 'node:zlib'
40
+
41
+ const SIG_EOCD = 0x06054b50
42
+ const SIG_CENTRAL = 0x02014b50
43
+ const SIG_LOCAL = 0x04034b50
44
+ const METHOD_STORED = 0
45
+ const METHOD_DEFLATED = 8
46
+
47
+ /** ZIP local-file-header magic, "PK\x03\x04" — the first two bytes are enough. */
48
+ export function looksLikeZip(buf) {
49
+ return Boolean(buf) && buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b
50
+ }
51
+
52
+ /**
53
+ * Locate the end-of-central-directory record.
54
+ *
55
+ * Scanned backwards because the record is last and variable-length (it carries an
56
+ * optional trailing comment).
57
+ *
58
+ * @param {Buffer} buf
59
+ * @returns {number} offset of the EOCD, or -1
60
+ */
61
+ function findEocd(buf) {
62
+ const min = Math.max(0, buf.length - 22 - 0xffff)
63
+ for (let i = buf.length - 22; i >= min; i--) {
64
+ if (buf.readUInt32LE(i) === SIG_EOCD) return i
65
+ }
66
+ return -1
67
+ }
68
+
69
+ /**
70
+ * Read every entry out of a ZIP as `[name, Buffer]` pairs.
71
+ *
72
+ * @param {Buffer} buf
73
+ * @returns {Array<[string, Buffer]>} empty when the buffer is not a readable ZIP
74
+ */
75
+ export function readUwxZip(buf) {
76
+ if (!looksLikeZip(buf)) return []
77
+ const eocd = findEocd(buf)
78
+ if (eocd < 0) return []
79
+
80
+ const count = buf.readUInt16LE(eocd + 10)
81
+ let ptr = buf.readUInt32LE(eocd + 16)
82
+ const out = []
83
+
84
+ for (let i = 0; i < count; i++) {
85
+ if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== SIG_CENTRAL) break
86
+
87
+ const method = buf.readUInt16LE(ptr + 10)
88
+ const csize = buf.readUInt32LE(ptr + 20)
89
+ const nameLen = buf.readUInt16LE(ptr + 28)
90
+ const extraLen = buf.readUInt16LE(ptr + 30)
91
+ const commentLen = buf.readUInt16LE(ptr + 32)
92
+ const localOff = buf.readUInt32LE(ptr + 42)
93
+ const name = buf.subarray(ptr + 46, ptr + 46 + nameLen).toString('utf8')
94
+
95
+ // The local header's name/extra lengths are independent of the central
96
+ // directory's — read them where the data actually starts, not from above.
97
+ if (localOff + 30 <= buf.length && buf.readUInt32LE(localOff) === SIG_LOCAL) {
98
+ const lNameLen = buf.readUInt16LE(localOff + 26)
99
+ const lExtraLen = buf.readUInt16LE(localOff + 28)
100
+ const start = localOff + 30 + lNameLen + lExtraLen
101
+ const raw = buf.subarray(start, start + csize)
102
+ try {
103
+ if (method === METHOD_STORED) out.push([name, Buffer.from(raw)])
104
+ else if (method === METHOD_DEFLATED) out.push([name, inflateRawSync(raw)])
105
+ // any other method: skip rather than guess
106
+ } catch {
107
+ /* a corrupt entry must not lose the readable ones */
108
+ }
109
+ }
110
+
111
+ ptr += 46 + nameLen + extraLen + commentLen
112
+ }
113
+
114
+ return out
115
+ }
116
+
117
+ /**
118
+ * The entity `$`-documents inside a `.uwx`, or a JSON body's documents.
119
+ *
120
+ * Mirrors `pull.js`'s `readPullDocuments` in behaviour — including its JSON
121
+ * fallback, so a future envelope change does not break this lane either — but
122
+ * without the `@uniweb/build` import. See the header for why that matters.
123
+ *
124
+ * @param {Buffer} buf
125
+ * @returns {object[]} parsed documents, possibly empty
126
+ */
127
+ export function readUwxDocuments(buf) {
128
+ if (!buf || buf.length === 0) return []
129
+
130
+ if (looksLikeZip(buf)) {
131
+ const docs = []
132
+ for (const [name, data] of readUwxZip(buf)) {
133
+ if (name === 'manifest.json' || !name.endsWith('.json')) continue
134
+ try {
135
+ docs.push(JSON.parse(data.toString('utf8')))
136
+ } catch {
137
+ /* skip a non-document entry */
138
+ }
139
+ }
140
+ return docs
141
+ }
142
+
143
+ let payload
144
+ try {
145
+ payload = JSON.parse(buf.toString('utf8'))
146
+ } catch {
147
+ return []
148
+ }
149
+ if (Array.isArray(payload)) return payload.filter(Boolean)
150
+ const list = Array.isArray(payload?.entities)
151
+ ? payload.entities
152
+ : Array.isArray(payload?.documents)
153
+ ? payload.documents
154
+ : null
155
+ if (list) return list.filter(Boolean)
156
+ return payload ? [payload] : []
157
+ }