@uniweb/build 0.14.15 → 0.14.17

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.14.15",
3
+ "version": "0.14.17",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,9 +63,9 @@
63
63
  "@uniweb/theming": "0.1.4"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.19",
67
66
  "@uniweb/content-reader": "1.1.12",
68
- "@uniweb/schemas": "0.2.3"
67
+ "@uniweb/schemas": "0.2.3",
68
+ "@uniweb/runtime": "0.8.20"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Data ball — the static-delivery half of a composite `uniweb deploy`. A site's
3
+ * collections partition by schema presence: a collection that resolves a data schema
4
+ * syncs as folder entities; a SCHEMA-LESS collection has no entity model, so its built
5
+ * `dist/data/<name>.json` (cascade + any `deferred:` per-record files) is delivered
6
+ * statically. This bundles that schema-less subset of `dist/data/**` plus the whole
7
+ * `dist/_search/**` index into one JSON doc the deploy uploads as a single
8
+ * content-addressed asset; the backend unwraps it into the `/data/*` + `/_search/*`
9
+ * bytes the gateway serves.
10
+ *
11
+ * { data: { "<relpath-under-data>": <json> }, // schema-less collections only
12
+ * search: { "<relpath-under-_search>": <json> } } // the whole (baked) index
13
+ *
14
+ * Search is NOT filtered: the index is baked over all content (the live/baked seam —
15
+ * schema-backed lists are served live from entities, but their search entries are baked
16
+ * here until the next deploy).
17
+ */
18
+
19
+ import { existsSync } from 'node:fs'
20
+ import { readFile, readdir } from 'node:fs/promises'
21
+ import { join, relative, sep } from 'node:path'
22
+ import { isLocalAssetPath } from './assets.js'
23
+
24
+ // Walk a dist subdir for *.json → { "<posix-relpath>": <parsedJson> }. Unparseable
25
+ // files are skipped (build-emitted data is always valid JSON; this just stays safe).
26
+ async function readJsonTree(dir) {
27
+ if (!existsSync(dir)) return {}
28
+ const out = {}
29
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
30
+ for (const entry of entries) {
31
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue
32
+ const full = join(entry.parentPath || entry.path, entry.name)
33
+ const rel = relative(dir, full).split(sep).join('/')
34
+ try {
35
+ out[rel] = JSON.parse(await readFile(full, 'utf8'))
36
+ } catch {
37
+ // not valid JSON — skip
38
+ }
39
+ }
40
+ return out
41
+ }
42
+
43
+ // The collection a `dist/data` relpath belongs to: the first path segment, minus a
44
+ // trailing `.json`. `articles.json` → `articles`; `articles/hello.json` → `articles`.
45
+ function collectionOf(relPath) {
46
+ const first = relPath.split('/')[0]
47
+ return first.endsWith('.json') ? first.slice(0, -5) : first
48
+ }
49
+
50
+ /**
51
+ * Assemble the static-data ball from a built site's dist/.
52
+ *
53
+ * @param {string} distDir - the site's built dist/ directory
54
+ * @param {string[]} [schemalessNames] - collection names with no data schema (from
55
+ * `emitSyncPackages(...).schemaless`); only these contribute `data`.
56
+ * @returns {Promise<{ data: Object, search: Object }|null>} null when there is nothing
57
+ * to deliver (no schema-less data AND no search index).
58
+ */
59
+ export async function assembleDataBall(distDir, schemalessNames = []) {
60
+ const schemaless = new Set(schemalessNames)
61
+ const allData = await readJsonTree(join(distDir, 'data'))
62
+ const data = {}
63
+ for (const [relPath, value] of Object.entries(allData)) {
64
+ if (schemaless.has(collectionOf(relPath))) data[relPath] = value
65
+ }
66
+ const search = await readJsonTree(join(distDir, '_search'))
67
+ if (Object.keys(data).length === 0 && Object.keys(search).length === 0) return null
68
+ return { data, search }
69
+ }
70
+
71
+ // --- local media in the ball -------------------------------------------------
72
+ // Schema-less collection data rides in the ball, so a local image in a schema-less
73
+ // record (e.g. a note's `image: /images/x.png`) needs the same upload + serve-URL
74
+ // rewrite the entity content gets (emitSyncPackages' `assetRewrite`) — otherwise the
75
+ // served `/data/<name>.json` keeps a dangling local path. The deploy collects the
76
+ // ball's refs, uploads them on the SAME asset lane, then rewrites the ball before
77
+ // uploading it. The backend serves a `serve_url` in the ball identically to one in an
78
+ // entity (it unwraps the ball verbatim), so this is purely producer-side.
79
+
80
+ /**
81
+ * Site-root local asset refs anywhere in the ball (`/images/x.png`, `/collections/...`).
82
+ * Built `dist/data` refs are already site-root (the collection processor copied
83
+ * co-located assets to `public/collections/**`), so only `/`-prefixed refs are collected.
84
+ * @param {{data:object,search:object}|null} ball
85
+ * @returns {string[]} deduped refs to upload
86
+ */
87
+ export function collectBallAssets(ball) {
88
+ const refs = new Set()
89
+ const walk = (n) => {
90
+ if (typeof n === 'string') {
91
+ if (isLocalAssetPath(n) && n.startsWith('/')) refs.add(n)
92
+ return
93
+ }
94
+ if (Array.isArray(n)) { for (const x of n) walk(x); return }
95
+ if (n && typeof n === 'object') for (const v of Object.values(n)) walk(v)
96
+ }
97
+ walk(ball)
98
+ return [...refs]
99
+ }
100
+
101
+ /**
102
+ * Rewrite the ball: replace every local ref the map covers with its serve URL. Pure —
103
+ * returns a NEW ball (the input is reused elsewhere). A ref the map omits (upload
104
+ * failed/skipped) is left untouched — never a broken URL.
105
+ * @param {{data:object,search:object}|null} ball
106
+ * @param {Record<string,string>} map - ref → serve URL
107
+ * @returns {{data:object,search:object}|null} a new ball, or the input when there's nothing to do
108
+ */
109
+ export function rewriteBallAssets(ball, map) {
110
+ if (!ball || !map || Object.keys(map).length === 0) return ball
111
+ const walk = (n) => {
112
+ if (typeof n === 'string') return map[n] || n
113
+ if (Array.isArray(n)) return n.map(walk)
114
+ if (n && typeof n === 'object') {
115
+ const out = {}
116
+ for (const [k, v] of Object.entries(n)) out[k] = walk(v)
117
+ return out
118
+ }
119
+ return n
120
+ }
121
+ return walk(ball)
122
+ }
package/src/site/index.js CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  writeCollectionFiles,
39
39
  getCollectionLastModified
40
40
  } from './collection-processor.js'
41
+ export { assembleDataBall, collectBallAssets, rewriteBallAssets } from './data-ball.js'
41
42
  export {
42
43
  parseFetchConfig,
43
44
  executeFetch,
@@ -422,17 +422,20 @@ async function loadSourceRecords(siteRoot, decl) {
422
422
  * `@uniweb/data-schema` declaration (or null). The verb wires this to the
423
423
  * backend's Model-read route. Without it, the local foundation is required.
424
424
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
425
- * @returns {Promise<{ entities: object[], index: object[], warnings: string[], mappedCount: number }>}
425
+ * @returns {Promise<{ entities: object[], index: object[], warnings: string[], schemaless: Array<{name: string}>, mappedCount: number }>}
426
+ * `schemaless` lists collections that resolved no data schema (the convention-
427
+ * default soft-skip) — not synced as entities; the composite deploy delivers
428
+ * them statically via the data ball.
426
429
  */
427
430
  export async function buildCollectionEntities(siteRoot, opts = {}) {
428
431
  // Merged collections config (collections.yml over site.yml::collections). Reused
429
432
  // from the caller when provided (sync-package shares it with the folder builder).
430
433
  const colConfig = opts.collectionsConfig || (await resolveCollectionsConfig(siteRoot))
431
434
  if (!colConfig.folderSync) {
432
- return { entities: [], index: [], warnings: [], mappedCount: 0, colConfig }
435
+ return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
433
436
  }
434
437
  const mapped = syncableCollections(colConfig.declarations)
435
- if (mapped.length === 0) return { entities: [], index: [], warnings: [], mappedCount: 0, colConfig }
438
+ if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
436
439
 
437
440
  // A Model declaration comes from a LOCAL foundation (offline) or, for a
438
441
  // non-local Model, from the injected async `resolveModel(name)` — the verb wires
@@ -474,6 +477,10 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
474
477
  const entities = []
475
478
  const index = []
476
479
  const warnings = []
480
+ // Collections that resolved no data schema (the convention-default soft-skip
481
+ // below) — not synced as folder entities. The composite deploy delivers these
482
+ // statically (the "data ball") instead, so the caller can route them there.
483
+ const schemaless = []
477
484
  // The sync response is keyed per ($model, $id), so the pair must be unique
478
485
  // within one submission (two collections on the same Model could otherwise
479
486
  // reuse a slug).
@@ -489,6 +496,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
489
496
  warnings.push(
490
497
  `${name}: no data schema "${modelName}" (subfolder-name default) — not synced`
491
498
  )
499
+ schemaless.push({ name })
492
500
  continue
493
501
  }
494
502
  throw new Error(
@@ -577,7 +585,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
577
585
  warnings.push(...mappedOut.warnings)
578
586
  }
579
587
 
580
- return { entities, index, warnings, mappedCount: mapped.length, colConfig }
588
+ return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig }
581
589
  }
582
590
 
583
591
  /**
@@ -101,6 +101,7 @@ const INFO_TO_SITE_YML = {
101
101
  data: 'data',
102
102
  template: 'template',
103
103
  seo: 'seo',
104
+ app: 'app', // deployment's @uniweb/app-spec ref (bare uuid; deployment-local)
104
105
  }
105
106
 
106
107
  /**
package/src/uwx/site.js CHANGED
@@ -571,6 +571,11 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
571
571
  setIf(info, 'search', siteYml.search)
572
572
  setIf(info, 'paths', siteYml.paths)
573
573
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
574
+ // `app` — the deployment's `@uniweb/app-spec` reference (a bare uuid string),
575
+ // bound when the site is hosted as a composite. Authored config that round-trips
576
+ // verbatim/opaque (like `foundation`), so a pull→edit→push preserves it; it is
577
+ // deployment-LOCAL (not portable across deployments). (uwx-format.md → info.app.)
578
+ setIf(info, 'app', siteYml.app)
574
579
  // `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
575
580
  // backend applies a clonability designation to this site-content entity (it is
576
581
  // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
@@ -23,14 +23,15 @@ import { buildCollectionEntities, entityContentHash } from './collections.js'
23
23
  import { buildFolderEntity } from './folder.js'
24
24
  import { siteProjectToDocument } from './site.js'
25
25
  import { emitEntitySyncPackage } from './entity-document.js'
26
+ import { isLocalAssetPath } from '../site/assets.js'
26
27
 
27
28
  const SITE_MODEL_NAME = '@uniweb/site-content'
28
29
  const SITE_ENTITY_KEY = 'site-content'
29
30
 
30
31
  const cacheKey = (entity) => `${entity.model} ${entity.id}`
31
32
 
32
- function emitLane(entities, exporter, exportedAt) {
33
- const models = [...new Set(entities.map((e) => e.model))]
33
+ function emitLane(entities, exporter, exportedAt, extraModels = []) {
34
+ const models = [...new Set([...entities.map((e) => e.model), ...extraModels])]
34
35
  const buffer = emitEntitySyncPackage({
35
36
  entities,
36
37
  modelsRequired: models.map((name) => ({ name_at_export: name })),
@@ -40,6 +41,68 @@ function emitLane(entities, exporter, exportedAt) {
40
41
  return { buffer, entityCount: entities.length, models }
41
42
  }
42
43
 
44
+ // Collect the Models referenced by the folder's `ref` leaves (`entry.model`), walking
45
+ // the contents/$children tree. The folder is built from the FULL record set, so it
46
+ // references every record's Model — including records the send-only-changed filter
47
+ // drops from THIS package (a re-push where the folder changed but the records didn't).
48
+ // The backend requires every referenced Model declared in modelsRequired, so these must
49
+ // ride even when their record entities don't.
50
+ function collectReferencedModels(node, acc) {
51
+ if (!node || typeof node !== 'object') return acc
52
+ if (node.entry && typeof node.entry.model === 'string') acc.add(node.entry.model)
53
+ for (const key of ['$children', 'contents']) {
54
+ if (Array.isArray(node[key])) for (const child of node[key]) collectReferencedModels(child, acc)
55
+ }
56
+ return acc
57
+ }
58
+
59
+ // --- local-media over push (Slice 5) ----------------------------------------
60
+ // A site's content references local media by the author's original path
61
+ // (`/images/hero.png`, `./hero.png`); the backend stores media content-addressed
62
+ // and serves it by URL. The deploy uploads the local files and swaps the refs for
63
+ // the backend's serve URLs. Both steps walk the produced entity documents with one
64
+ // generic recursion, filtered by `isLocalAssetPath` (a `/`/`./`/`../` prefix PLUS a
65
+ // media extension) — so it covers PM image `src`, `background`/`params`, record
66
+ // media fields, and localized `{locale: doc}` maps uniformly and safely (non-media
67
+ // strings like `/data/x.json` never match). Mirrors the build's `walkDataAssets`.
68
+
69
+ // Invoke visitor(ref) for every local asset path string anywhere in the document.
70
+ function walkEntityAssets(node, visitor) {
71
+ if (typeof node === 'string') {
72
+ if (isLocalAssetPath(node)) visitor(node)
73
+ return
74
+ }
75
+ if (Array.isArray(node)) {
76
+ for (const item of node) walkEntityAssets(item, visitor)
77
+ return
78
+ }
79
+ if (node && typeof node === 'object') {
80
+ for (const v of Object.values(node)) walkEntityAssets(v, visitor)
81
+ }
82
+ }
83
+
84
+ // In-place: replace every local-asset-path string the map covers with its serve
85
+ // URL. A ref the map omits (upload failed/skipped) is left untouched — never a
86
+ // broken URL. Returns the (mutated) node.
87
+ function rewriteEntityAssets(node, map) {
88
+ if (Array.isArray(node)) {
89
+ for (let i = 0; i < node.length; i++) {
90
+ const v = node[i]
91
+ if (typeof v === 'string') { if (map[v]) node[i] = map[v] }
92
+ else rewriteEntityAssets(v, map)
93
+ }
94
+ return node
95
+ }
96
+ if (node && typeof node === 'object') {
97
+ for (const key of Object.keys(node)) {
98
+ const v = node[key]
99
+ if (typeof v === 'string') { if (map[v]) node[key] = map[v] }
100
+ else rewriteEntityAssets(v, map)
101
+ }
102
+ }
103
+ return node
104
+ }
105
+
43
106
  /**
44
107
  * Build the two sync packages for a site.
45
108
  *
@@ -51,11 +114,22 @@ function emitLane(entities, exporter, exportedAt) {
51
114
  * @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
52
115
  * @param {boolean} [opts.sendAll] - bypass the prior-hash filter
53
116
  * @param {boolean} [opts.includeSite] - include the site-content lane (default true)
117
+ * @param {object} [opts.injectInfo] - deploy-derived `info.*` to stamp on the
118
+ * site-content document (e.g. `{ data_bundle }`, the static-data ball URL);
119
+ * wire-only — never authored in site.yml, never projected back on pull.
120
+ * @param {Object<string,string>} [opts.assetRewrite] - map of local asset ref →
121
+ * backend serve URL; rewrites the entities' media refs before push (the
122
+ * deploy's 2nd emit). Absent → no rewrite (the f225 sync path is unchanged).
54
123
  * @param {object} [opts.exporter] @param {string} [opts.exportedAt]
55
124
  * @returns {Promise<{
56
125
  * siteContent: { buffer, entityCount, index, models }|null,
57
126
  * collections: { buffer, entityCount, index, models }|null,
58
- * hashes: Object<string,string>, warnings: string[], skipped: number }>}
127
+ * hashes: Object<string,string>, warnings: string[], skipped: number,
128
+ * schemaless: Array<{name: string}>, localAssets: string[] }>}
129
+ * `schemaless` lists collections that resolved no data schema (soft-skipped from
130
+ * the sync) — the composite deploy delivers these statically via the data ball.
131
+ * `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
132
+ * must upload + rewrite to serve URLs; co-located refs are warned and skipped.
59
133
  * Each lane is null when it has nothing to push. The collections `index` keeps a
60
134
  * leading `{ kind: 'folder' }` placeholder (submission position 0 → the folder
61
135
  * entity) so record back-fill stays positionally aligned; the folder itself has no
@@ -84,6 +158,45 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
84
158
  })
85
159
 
86
160
  const siteDoc = includeSite ? await siteProjectToDocument(siteRoot, { sourceLocale }) : null
161
+ // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
162
+ // stamped here — NOT authored in site.yml, so they ride the wire but never project
163
+ // back on pull (the `info.assets` precedent). They are part of the hashed content,
164
+ // so a changed bundle URL correctly re-fires the site-content lane.
165
+ if (siteDoc && opts.injectInfo && typeof opts.injectInfo === 'object') {
166
+ siteDoc.info = { ...siteDoc.info, ...opts.injectInfo }
167
+ }
168
+
169
+ // Local-media over push (Slice 5). `assetRewrite` ({ '/images/x.png': serveUrl })
170
+ // is supplied by the deploy's SECOND emit, after it has uploaded the files the
171
+ // FIRST emit surfaced in `localAssets`. It is absent on the collect emit and on
172
+ // every non-deploy caller, so the f225 sync path is byte-identical without it.
173
+ const assetRewrite =
174
+ opts.assetRewrite && typeof opts.assetRewrite === 'object' ? opts.assetRewrite : null
175
+ if (assetRewrite) {
176
+ if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite)
177
+ for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite)
178
+ }
179
+ // Collect the site-root local refs the deploy must upload (`/images/x.png`).
180
+ // Co-located refs (`./x`, `../x`) need the source `.md` location to resolve — the
181
+ // entity doesn't carry it — so warn once each and skip (v1: use a site-root path).
182
+ const localAssetSet = new Set()
183
+ const colocatedSeen = new Set()
184
+ const collectFrom = (doc) =>
185
+ doc &&
186
+ walkEntityAssets(doc, (ref) => {
187
+ if (ref.startsWith('/')) localAssetSet.add(ref)
188
+ else if (!colocatedSeen.has(ref)) {
189
+ colocatedSeen.add(ref)
190
+ warnings.push(
191
+ `local-media: co-located asset "${ref}" is not uploaded on the composite deploy — ` +
192
+ `use a site-root path (e.g. /images/${ref.replace(/^[./]+/, '')})`
193
+ )
194
+ }
195
+ })
196
+ collectFrom(siteDoc)
197
+ for (const e of col.entities) collectFrom(e.document)
198
+ const localAssets = [...localAssetSet]
199
+
87
200
  const siteEntity = siteDoc
88
201
  ? { id: siteDoc.$id, model: siteDoc.$model, file: 'entities/site-content.json', document: siteDoc }
89
202
  : null
@@ -114,7 +227,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
114
227
  // back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
115
228
  const entities = [folder, ...changedRecords.map((r) => r.entity)]
116
229
  const index = [{ kind: 'folder' }, ...changedRecords.map((r) => r.index)]
117
- collections = { ...emitLane(entities, exporter, exportedAt), index }
230
+ // The folder references every record's Model via `entry.model` — including records
231
+ // filtered out here by send-only-changed. Declare them all (the backend rejects a
232
+ // folder that references an undeclared Model).
233
+ const referencedModels = [...collectReferencedModels(folder.document, new Set())]
234
+ collections = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
118
235
  }
119
236
 
120
237
  // --- site-content lane -------------------------------------------------------
@@ -128,5 +245,5 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
128
245
  // the content lane by its presence/absence.
129
246
  const siteContentUuid = siteDoc?.$uuid
130
247
 
131
- return { siteContent, collections, siteContentUuid, hashes, warnings, skipped }
248
+ return { siteContent, collections, siteContentUuid, hashes, warnings, skipped, schemaless: col.schemaless, localAssets }
132
249
  }
package/src/uwx/zip.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // Minimal, zero-dependency ZIP writer/reader for .uwx containers.
2
2
  //
3
- // DESIGN DECISION: Stored only (compression method 0), no Deflate. A .uwx
4
- // container is a ZIP; compression is an optimization, not part of the
5
- // contract, and every standard ZIP reader handles Stored entries. Staying
6
- // Stored-only removes a class of cross-tool byte asymmetry and needs no
7
- // zlib. If package size ever matters, a Deflate path can be added later via
8
- // Node's built-in `zlib.deflateRawSync` without changing callers.
3
+ // DESIGN DECISION: our WRITER (`createZip`) emits Stored only (compression
4
+ // method 0), no Deflate. A .uwx container is a ZIP; compression is an
5
+ // optimization, not part of the contract, and every standard ZIP reader
6
+ // handles Stored entries. Staying Stored-only on write removes a class of
7
+ // cross-tool byte asymmetry. The READER (`readZip`) additionally inflates
8
+ // Deflate (method 8) entries — the backend's pull `.uwx` Deflates larger
9
+ // entities, and the framework must read what the backend produces.
9
10
  //
10
11
  // No Zip64: per-record JSON files are far below 4 GiB and entry counts far
11
12
  // below 65535. The format is otherwise the classic APPNOTE layout, all
@@ -17,6 +18,7 @@
17
18
  // makes byte output reproducible for a given input.
18
19
 
19
20
  import { crc32 } from './crc32.js'
21
+ import { inflateRawSync } from 'node:zlib'
20
22
 
21
23
  const LOCAL_SIG = 0x04034b50
22
24
  const CENTRAL_SIG = 0x02014b50
@@ -89,11 +91,12 @@ export function createZip(files) {
89
91
  }
90
92
 
91
93
  /**
92
- * Reader for our own Stored archivesused by tests and by inspection /
93
- * `--dry-run`. Not a general-purpose unzip.
94
+ * Reader for `.uwx` containers. Handles Stored (method 0 what our writer emits)
95
+ * and Deflate (method 8 — what the backend's pull `.uwx` uses); any other method
96
+ * throws. Not otherwise a general-purpose unzip (no Zip64, no encryption).
94
97
  *
95
98
  * @param {Buffer} buf
96
- * @returns {Map<string, Buffer>} name -> data
99
+ * @returns {Map<string, Buffer>} name -> uncompressed data
97
100
  */
98
101
  export function readZip(buf) {
99
102
  const out = new Map()
@@ -115,6 +118,7 @@ export function readZip(buf) {
115
118
  if (buf.readUInt32LE(p) !== CENTRAL_SIG) {
116
119
  throw new Error('uwx/zip: bad central directory signature')
117
120
  }
121
+ const method = buf.readUInt16LE(p + 10)
118
122
  const compSize = buf.readUInt32LE(p + 20)
119
123
  const nameLen = buf.readUInt16LE(p + 28)
120
124
  const extraLen = buf.readUInt16LE(p + 30)
@@ -127,7 +131,11 @@ export function readZip(buf) {
127
131
  const lNameLen = buf.readUInt16LE(localOff + 26)
128
132
  const lExtraLen = buf.readUInt16LE(localOff + 28)
129
133
  const dataStart = localOff + 30 + lNameLen + lExtraLen
130
- out.set(name, buf.subarray(dataStart, dataStart + compSize))
134
+ const raw = buf.subarray(dataStart, dataStart + compSize)
135
+ // Stored (0) → verbatim; Deflate (8) → inflate the raw deflate stream.
136
+ if (method === 0) out.set(name, raw)
137
+ else if (method === 8) out.set(name, inflateRawSync(raw))
138
+ else throw new Error(`uwx/zip: unsupported compression method ${method} for ${name}`)
131
139
 
132
140
  p += 46 + nameLen + extraLen + commentLen
133
141
  }