@johnhenry/sitefile 0.0.0-stage → 0.0.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +475 -2
  4. package/lib/src/archive-writers.d.ts +31 -0
  5. package/lib/src/archive-writers.js +197 -0
  6. package/lib/src/attest.d.ts +126 -0
  7. package/lib/src/attest.js +220 -0
  8. package/lib/src/bake.d.ts +74 -0
  9. package/lib/src/bake.js +372 -0
  10. package/lib/src/base-image.d.ts +76 -0
  11. package/lib/src/base-image.js +138 -0
  12. package/lib/src/build-args.d.ts +26 -0
  13. package/lib/src/build-args.js +164 -0
  14. package/lib/src/cache-limit.d.ts +54 -0
  15. package/lib/src/cache-limit.js +239 -0
  16. package/lib/src/casefold-data.d.ts +2 -0
  17. package/lib/src/casefold-data.js +217 -0
  18. package/lib/src/cbor.d.ts +39 -0
  19. package/lib/src/cbor.js +174 -0
  20. package/lib/src/checklinks.d.ts +42 -0
  21. package/lib/src/checklinks.js +118 -0
  22. package/lib/src/cli/bake.d.ts +2 -0
  23. package/lib/src/cli/bake.js +179 -0
  24. package/lib/src/cli/build-image.d.ts +21 -0
  25. package/lib/src/cli/build-image.js +42 -0
  26. package/lib/src/cli/cache.d.ts +8 -0
  27. package/lib/src/cli/cache.js +109 -0
  28. package/lib/src/cli/common.d.ts +29 -0
  29. package/lib/src/cli/common.js +100 -0
  30. package/lib/src/cli/git-context.d.ts +18 -0
  31. package/lib/src/cli/git-context.js +48 -0
  32. package/lib/src/cli/images.d.ts +6 -0
  33. package/lib/src/cli/images.js +285 -0
  34. package/lib/src/cli/index.d.ts +10 -0
  35. package/lib/src/cli/index.js +95 -0
  36. package/lib/src/cli/outdated.d.ts +5 -0
  37. package/lib/src/cli/outdated.js +94 -0
  38. package/lib/src/cli/registry.d.ts +2 -0
  39. package/lib/src/cli/registry.js +122 -0
  40. package/lib/src/cli/serve-worker.d.ts +2 -0
  41. package/lib/src/cli/serve-worker.js +42 -0
  42. package/lib/src/cli/servers.d.ts +15 -0
  43. package/lib/src/cli/servers.js +451 -0
  44. package/lib/src/cli/vendor.d.ts +2 -0
  45. package/lib/src/cli/vendor.js +92 -0
  46. package/lib/src/cli/watch.d.ts +26 -0
  47. package/lib/src/cli/watch.js +81 -0
  48. package/lib/src/cli.d.ts +12 -0
  49. package/lib/src/cli.js +486 -0
  50. package/lib/src/csv.d.ts +27 -0
  51. package/lib/src/csv.js +117 -0
  52. package/lib/src/deflate.d.ts +76 -0
  53. package/lib/src/deflate.js +604 -0
  54. package/lib/src/digest.d.ts +3 -0
  55. package/lib/src/digest.js +7 -0
  56. package/lib/src/errors.d.ts +34 -0
  57. package/lib/src/errors.js +20 -0
  58. package/lib/src/executor.d.ts +386 -0
  59. package/lib/src/executor.js +2193 -0
  60. package/lib/src/explain.d.ts +64 -0
  61. package/lib/src/explain.js +445 -0
  62. package/lib/src/export-formats.d.ts +16 -0
  63. package/lib/src/export-formats.js +52 -0
  64. package/lib/src/exporter.d.ts +103 -0
  65. package/lib/src/exporter.js +676 -0
  66. package/lib/src/fingerprint.d.ts +40 -0
  67. package/lib/src/fingerprint.js +168 -0
  68. package/lib/src/frontmatter.d.ts +22 -0
  69. package/lib/src/frontmatter.js +45 -0
  70. package/lib/src/index.d.ts +12 -0
  71. package/lib/src/index.js +12 -0
  72. package/lib/src/language/ast.d.ts +353 -0
  73. package/lib/src/language/ast.js +57 -0
  74. package/lib/src/language/parser.d.ts +3 -0
  75. package/lib/src/language/parser.js +612 -0
  76. package/lib/src/language/scanner.d.ts +110 -0
  77. package/lib/src/language/scanner.js +449 -0
  78. package/lib/src/language/validate.d.ts +13 -0
  79. package/lib/src/language/validate.js +367 -0
  80. package/lib/src/links.d.ts +79 -0
  81. package/lib/src/links.js +583 -0
  82. package/lib/src/lint.d.ts +10 -0
  83. package/lib/src/lint.js +223 -0
  84. package/lib/src/markdown.d.ts +29 -0
  85. package/lib/src/markdown.js +612 -0
  86. package/lib/src/media.d.ts +82 -0
  87. package/lib/src/media.js +650 -0
  88. package/lib/src/memo.d.ts +57 -0
  89. package/lib/src/memo.js +93 -0
  90. package/lib/src/oci.d.ts +178 -0
  91. package/lib/src/oci.js +380 -0
  92. package/lib/src/outdated.d.ts +46 -0
  93. package/lib/src/outdated.js +195 -0
  94. package/lib/src/pages.d.ts +10 -0
  95. package/lib/src/pages.js +54 -0
  96. package/lib/src/paths.d.ts +39 -0
  97. package/lib/src/paths.js +101 -0
  98. package/lib/src/planner.d.ts +297 -0
  99. package/lib/src/planner.js +410 -0
  100. package/lib/src/plugins/cache.d.ts +70 -0
  101. package/lib/src/plugins/cache.js +226 -0
  102. package/lib/src/plugins/exec.d.ts +51 -0
  103. package/lib/src/plugins/exec.js +144 -0
  104. package/lib/src/plugins/host.d.ts +75 -0
  105. package/lib/src/plugins/host.js +277 -0
  106. package/lib/src/plugins/wasm.d.ts +23 -0
  107. package/lib/src/plugins/wasm.js +249 -0
  108. package/lib/src/profile.d.ts +42 -0
  109. package/lib/src/profile.js +68 -0
  110. package/lib/src/ref.d.ts +40 -0
  111. package/lib/src/ref.js +79 -0
  112. package/lib/src/registry-auth.d.ts +32 -0
  113. package/lib/src/registry-auth.js +151 -0
  114. package/lib/src/registry-cache.d.ts +71 -0
  115. package/lib/src/registry-cache.js +333 -0
  116. package/lib/src/registry-ops.d.ts +51 -0
  117. package/lib/src/registry-ops.js +248 -0
  118. package/lib/src/registry.d.ts +109 -0
  119. package/lib/src/registry.js +505 -0
  120. package/lib/src/render.d.ts +134 -0
  121. package/lib/src/render.js +576 -0
  122. package/lib/src/resources/archive.d.ts +11 -0
  123. package/lib/src/resources/archive.js +306 -0
  124. package/lib/src/resources/bundle-data.d.ts +16 -0
  125. package/lib/src/resources/bundle-data.js +50 -0
  126. package/lib/src/resources/convert.d.ts +12 -0
  127. package/lib/src/resources/convert.js +63 -0
  128. package/lib/src/resources/documents.d.ts +35 -0
  129. package/lib/src/resources/documents.js +122 -0
  130. package/lib/src/resources/git.d.ts +103 -0
  131. package/lib/src/resources/git.js +720 -0
  132. package/lib/src/resources/https.d.ts +40 -0
  133. package/lib/src/resources/https.js +256 -0
  134. package/lib/src/resources/ignore.d.ts +33 -0
  135. package/lib/src/resources/ignore.js +119 -0
  136. package/lib/src/resources/local.d.ts +64 -0
  137. package/lib/src/resources/local.js +248 -0
  138. package/lib/src/resources/lockfile.d.ts +52 -0
  139. package/lib/src/resources/lockfile.js +172 -0
  140. package/lib/src/resources/netpolicy.d.ts +6 -0
  141. package/lib/src/resources/netpolicy.js +82 -0
  142. package/lib/src/resources/types.d.ts +63 -0
  143. package/lib/src/resources/types.js +13 -0
  144. package/lib/src/resources/vendor.d.ts +94 -0
  145. package/lib/src/resources/vendor.js +296 -0
  146. package/lib/src/resources/wbn-reader.d.ts +44 -0
  147. package/lib/src/resources/wbn-reader.js +302 -0
  148. package/lib/src/schema.d.ts +25 -0
  149. package/lib/src/schema.js +169 -0
  150. package/lib/src/secrets.d.ts +21 -0
  151. package/lib/src/secrets.js +103 -0
  152. package/lib/src/serve-rules.d.ts +29 -0
  153. package/lib/src/serve-rules.js +122 -0
  154. package/lib/src/server.d.ts +45 -0
  155. package/lib/src/server.js +246 -0
  156. package/lib/src/servers.d.ts +71 -0
  157. package/lib/src/servers.js +139 -0
  158. package/lib/src/site.d.ts +60 -0
  159. package/lib/src/site.js +169 -0
  160. package/lib/src/store.d.ts +151 -0
  161. package/lib/src/store.js +596 -0
  162. package/lib/src/tree.d.ts +50 -0
  163. package/lib/src/tree.js +134 -0
  164. package/lib/src/values.d.ts +7 -0
  165. package/lib/src/values.js +37 -0
  166. package/lib/src/vendor.d.ts +19 -0
  167. package/lib/src/vendor.js +145 -0
  168. package/lib/src/watch.d.ts +44 -0
  169. package/lib/src/watch.js +137 -0
  170. package/lib/src/wbn.d.ts +5 -0
  171. package/lib/src/wbn.js +147 -0
  172. package/lib/src/yaml.d.ts +34 -0
  173. package/lib/src/yaml.js +562 -0
  174. package/package.json +58 -4
@@ -0,0 +1,306 @@
1
+ import { gunzip as gunzipCb, inflateRaw as inflateRawCb } from 'node:zlib';
2
+ import { promisify } from 'node:util';
3
+ import { SitefileError } from '../errors.js';
4
+ import { normalizeVirtual } from '../paths.js';
5
+ import { makeFile, Tree } from '../tree.js';
6
+ import { DEFAULT_ARCHIVE_LIMITS } from './types.js';
7
+ import { isWebBundle, readWebBundle, webBundleToTree } from './wbn-reader.js';
8
+ /** Below this many output bytes the ratio limit is not enforced (tar block padding compresses extremely well). */
9
+ const RATIO_FLOOR = 1024 * 1024;
10
+ const gunzipAsync = promisify(gunzipCb);
11
+ const inflateRawAsync = promisify(inflateRawCb);
12
+ /** Tar framing per entry, worst case: header + padding + a pax/GNU long-name header and its padding. */
13
+ const TAR_OVERHEAD_PER_ENTRY = 4 * 512;
14
+ /**
15
+ * Extract a tar, tar.gz/tgz, zip or Web Bundle (b1/b2) archive into a Tree. The format is detected
16
+ * from magic bytes, never from the name. Nothing is inflated beyond the limits,
17
+ * and unsafe entries (absolute or `..` paths, backslashes, NUL, symlinks, hard
18
+ * links, devices) are rejected, not skipped. A single top-level directory is
19
+ * kept as-is.
20
+ */
21
+ export async function extractArchive(bytes, origin, limits = DEFAULT_ARCHIVE_LIMITS, span) {
22
+ if (isWebBundle(bytes))
23
+ return webBundleToTree(readWebBundle(bytes, origin, limits, span), origin, span);
24
+ const files = new Map();
25
+ const dirs = new Set();
26
+ let total = 0;
27
+ const add = (name, kind, data, mode) => {
28
+ const path = archivePath(name, span);
29
+ if (path === '/')
30
+ return;
31
+ if (files.has(path) || (kind === 'file' && dirs.has(path)) || (kind === 'dir' && files.has(path))) {
32
+ throw new SitefileError('E_CONFLICT', `${origin}: duplicate archive entry '${name}'`, span);
33
+ }
34
+ if (kind === 'dir') {
35
+ dirs.add(path);
36
+ return;
37
+ }
38
+ if (files.size + 1 > limits.maxEntries) {
39
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive has more than ${limits.maxEntries} entries`, span);
40
+ }
41
+ total += data.byteLength;
42
+ if (total > limits.maxTotalBytes) {
43
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive expands beyond ${limits.maxTotalBytes} bytes`, span);
44
+ }
45
+ files.set(path, makeFile(data, `${origin}:${name}`, mode));
46
+ };
47
+ if (isGzip(bytes)) {
48
+ const tar = await gunzip(bytes, origin, limits, span);
49
+ if (!isTar(tar))
50
+ throw new SitefileError('E_TYPE', `${origin}: gzip content is not a tar archive`, span);
51
+ readTar(tar, origin, limits, span, add);
52
+ }
53
+ else if (isZip(bytes)) {
54
+ await readZip(bytes, origin, limits, span, add);
55
+ }
56
+ else if (isTar(bytes)) {
57
+ readTar(bytes, origin, limits, span, add);
58
+ }
59
+ else {
60
+ throw new SitefileError('E_TYPE', `${origin}: not a recognized archive (expected tar, tar.gz, zip or web bundle)`, span);
61
+ }
62
+ return Tree.fromFiles(files);
63
+ }
64
+ const isGzip = (b) => b.length > 2 && b[0] === 0x1f && b[1] === 0x8b;
65
+ const isZip = (b) => b.length >= 4 && b[0] === 0x50 && b[1] === 0x4b && ((b[2] === 3 && b[3] === 4) || (b[2] === 5 && b[3] === 6));
66
+ const isTar = (b) => b.length >= 512 &&
67
+ (ascii(b, 257, 262) === 'ustar' ||
68
+ (tarChecksumOk(b, 0) && b.subarray(0, 512).some((x) => x !== 0)) ||
69
+ (b.length === 1024 && b.every((x) => x === 0))); // an empty tar
70
+ function ascii(b, from, to) {
71
+ return Buffer.from(b.subarray(from, to)).toString('latin1');
72
+ }
73
+ /** Validate an entry name and return its normalized virtual path. */
74
+ function archivePath(name, span) {
75
+ const bad = (why) => new SitefileError('E_ARCHIVE_UNSAFE', `unsafe archive entry '${name.replace(/\0/g, '\\0')}': ${why}`, span);
76
+ if (name.includes('\0'))
77
+ throw bad('NUL byte');
78
+ if (name.includes('\\'))
79
+ throw bad('backslash in path');
80
+ if (name.startsWith('/') || /^[A-Za-z]:/.test(name))
81
+ throw bad('absolute path');
82
+ if (name.split('/').includes('..'))
83
+ throw bad("'..' segment");
84
+ return normalizeVirtual('/' + name, span, 'archive entry').path;
85
+ }
86
+ // ------------------------------------------------------------------ gzip
87
+ async function gunzip(bytes, origin, limits, span) {
88
+ const byRatio = Math.max(bytes.length * limits.maxRatio, RATIO_FLOOR);
89
+ // The inflated stream is tar: file data plus headers and padding for every entry.
90
+ const byTotal = limits.maxTotalBytes + (limits.maxEntries + 2) * TAR_OVERHEAD_PER_ENTRY;
91
+ try {
92
+ // Async so a large archive doesn't stall other imports' sockets and timers.
93
+ return await gunzipAsync(bytes, { maxOutputLength: Math.min(byRatio, byTotal) });
94
+ }
95
+ catch (e) {
96
+ throw inflateError(e, origin, byRatio <= byTotal, limits, span);
97
+ }
98
+ }
99
+ function inflateError(e, origin, ratio, limits, span) {
100
+ if (e.code === 'ERR_BUFFER_TOO_LARGE') {
101
+ return ratio
102
+ ? new SitefileError('E_ARCHIVE_LIMIT', `${origin}: compression ratio exceeds ${limits.maxRatio}:1`, span)
103
+ : new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive expands beyond ${limits.maxTotalBytes} bytes`, span);
104
+ }
105
+ return new SitefileError('E_ARCHIVE', `${origin}: corrupt compressed data`, span, { cause: e });
106
+ }
107
+ // ------------------------------------------------------------------- tar
108
+ function tarChecksumOk(b, off) {
109
+ let sum = 0;
110
+ for (let i = 0; i < 512; i++)
111
+ sum += i >= 148 && i < 156 ? 32 : b[off + i];
112
+ const stored = parseInt(ascii(b, off + 148, off + 156).replace(/[\0 ]/g, ''), 8);
113
+ return stored === sum;
114
+ }
115
+ function tarNumber(b, from, to) {
116
+ if (b[from] & 0x80) {
117
+ let n = b[from] & 0x7f; // GNU base-256
118
+ for (let i = from + 1; i < to; i++)
119
+ n = n * 256 + b[i];
120
+ return n;
121
+ }
122
+ const s = ascii(b, from, to).replace(/[\0 ]/g, '');
123
+ return s === '' ? 0 : parseInt(s, 8);
124
+ }
125
+ function cString(b, from, to) {
126
+ let end = from;
127
+ while (end < to && b[end] !== 0)
128
+ end++;
129
+ return Buffer.from(b.subarray(from, end)).toString('utf8');
130
+ }
131
+ /** Supports ustar prefixes, GNU `L` long names and pax `path`/`size` records. */
132
+ function readTar(b, origin, limits, span, add) {
133
+ const bad = (why) => new SitefileError('E_ARCHIVE', `${origin}: ${why}`, span);
134
+ let off = 0;
135
+ let nextName;
136
+ let nextSize;
137
+ let entries = 0;
138
+ while (off + 512 <= b.length) {
139
+ const h = off;
140
+ if (b.subarray(h, h + 512).every((x) => x === 0))
141
+ return; // end-of-archive marker
142
+ if (!tarChecksumOk(b, h))
143
+ throw bad('invalid tar header checksum');
144
+ const type = String.fromCharCode(b[h + 156]);
145
+ const size = nextSize ?? tarNumber(b, h + 124, h + 136);
146
+ if (!Number.isSafeInteger(size) || size < 0 || size > limits.maxTotalBytes) {
147
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive entry larger than ${limits.maxTotalBytes} bytes`, span);
148
+ }
149
+ if (h + 512 + size > b.length)
150
+ throw bad('truncated tar archive');
151
+ const data = b.subarray(h + 512, h + 512 + size);
152
+ off = h + 512 + Math.ceil(size / 512) * 512;
153
+ if (type === 'L') {
154
+ nextName = cString(data, 0, data.length);
155
+ continue;
156
+ }
157
+ if (type === 'x') {
158
+ ({ path: nextName, size: nextSize } = parsePax(data, bad));
159
+ continue;
160
+ }
161
+ if (type === 'g' || type === 'K')
162
+ continue; // global pax header / GNU long link name: unused
163
+ if (++entries > limits.maxEntries) {
164
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive has more than ${limits.maxEntries} entries`, span);
165
+ }
166
+ let name = nextName;
167
+ if (name === undefined) {
168
+ name = cString(b, h, h + 100);
169
+ // Only POSIX ustar ('ustar\0') has a name prefix; old GNU ('ustar ') keeps atime/ctime there.
170
+ const prefix = ascii(b, h + 257, h + 263) === 'ustar\0' ? cString(b, h + 345, h + 500) : '';
171
+ if (prefix)
172
+ name = `${prefix}/${name}`;
173
+ }
174
+ nextName = nextSize = undefined;
175
+ if (type === '0' || type === '\0' || type === '7')
176
+ add(name, 'file', data, tarNumber(b, h + 100, h + 108));
177
+ else if (type === '5')
178
+ add(name, 'dir', data, 0o755);
179
+ else {
180
+ const what = type === '1' ? 'hard link' : type === '2' ? 'symlink' : 'special file';
181
+ throw new SitefileError('E_ARCHIVE_UNSAFE', `unsafe archive entry '${name}': ${what} entries are not allowed`, span);
182
+ }
183
+ }
184
+ }
185
+ function parsePax(data, bad) {
186
+ const out = { path: undefined, size: undefined };
187
+ const buf = Buffer.from(data);
188
+ let pos = 0;
189
+ while (pos < buf.length) {
190
+ const sp = buf.indexOf(0x20, pos);
191
+ // No length prefix left: stop (without this a record missing its space looped forever).
192
+ if (sp < 0)
193
+ break;
194
+ const len = parseInt(buf.toString('latin1', pos, sp), 10);
195
+ if (!(len > sp - pos) || pos + len > buf.length)
196
+ break;
197
+ const kv = buf.toString('utf8', sp + 1, pos + len - 1); // drop trailing newline
198
+ const eq = kv.indexOf('=');
199
+ if (eq <= 0)
200
+ throw bad('malformed pax header record');
201
+ const key = kv.slice(0, eq);
202
+ const value = kv.slice(eq + 1);
203
+ if (key === 'path')
204
+ out.path = value;
205
+ if (key === 'size') {
206
+ if (!/^\d+$/.test(value))
207
+ throw bad(`malformed pax size '${value}'`);
208
+ out.size = Number(value);
209
+ }
210
+ pos += len;
211
+ }
212
+ return out;
213
+ }
214
+ async function readZip(b, origin, limits, span, add) {
215
+ const bad = (why) => new SitefileError('E_ARCHIVE', `${origin}: ${why}`, span);
216
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
217
+ let eocd = -1;
218
+ for (let i = b.length - 22; i >= Math.max(0, b.length - 22 - 0xffff); i--) {
219
+ if (v.getUint32(i, true) === 0x06054b50) {
220
+ eocd = i;
221
+ break;
222
+ }
223
+ }
224
+ if (eocd < 0)
225
+ throw bad('zip end-of-central-directory record not found');
226
+ const count = v.getUint16(eocd + 10, true);
227
+ let p = v.getUint32(eocd + 16, true);
228
+ if (count === 0xffff || p === 0xffffffff)
229
+ throw bad('zip64 archives are not supported');
230
+ if (count > limits.maxEntries) {
231
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive has more than ${limits.maxEntries} entries`, span);
232
+ }
233
+ const list = [];
234
+ let declared = 0;
235
+ for (let i = 0; i < count; i++) {
236
+ if (p + 46 > b.length || v.getUint32(p, true) !== 0x02014b50)
237
+ throw bad('corrupt zip central directory');
238
+ const nameLen = v.getUint16(p + 28, true);
239
+ const advance = 46 + nameLen + v.getUint16(p + 30, true) + v.getUint16(p + 32, true);
240
+ if (p + advance > b.length)
241
+ throw bad('corrupt zip central directory');
242
+ const unix = v.getUint16(p + 4, true) >> 8 === 3;
243
+ const attr = v.getUint32(p + 38, true) >>> 16;
244
+ const fileType = attr & 0o170000;
245
+ const name = Buffer.from(b.subarray(p + 46, p + 46 + nameLen)).toString('utf8');
246
+ const e = {
247
+ name,
248
+ method: v.getUint16(p + 10, true),
249
+ csize: v.getUint32(p + 20, true),
250
+ usize: v.getUint32(p + 24, true),
251
+ local: v.getUint32(p + 42, true),
252
+ mode: unix ? attr & 0o7777 : 0o644,
253
+ // Some tools mark directories only by mode, without a trailing slash.
254
+ isDir: name.endsWith('/') || (unix && fileType === 0o040000),
255
+ };
256
+ if (v.getUint16(p + 8, true) & 1)
257
+ throw bad(`encrypted zip entry '${e.name}' is not supported`);
258
+ if (e.csize === 0xffffffff || e.usize === 0xffffffff)
259
+ throw bad('zip64 archives are not supported');
260
+ if (unix && fileType !== 0 && fileType !== 0o100000 && fileType !== 0o040000) {
261
+ throw new SitefileError('E_ARCHIVE_UNSAFE', `unsafe archive entry '${e.name}': symlink or special file entries are not allowed`, span);
262
+ }
263
+ declared += e.usize;
264
+ list.push(e);
265
+ p += advance;
266
+ }
267
+ if (declared > limits.maxTotalBytes) {
268
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive expands beyond ${limits.maxTotalBytes} bytes`, span);
269
+ }
270
+ if (declared > Math.max(b.length * limits.maxRatio, RATIO_FLOOR)) {
271
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: compression ratio exceeds ${limits.maxRatio}:1`, span);
272
+ }
273
+ for (const e of list) {
274
+ if (e.isDir) {
275
+ add(e.name, 'dir', new Uint8Array(0), 0o755);
276
+ continue;
277
+ }
278
+ const h = e.local;
279
+ if (h + 30 > b.length || v.getUint32(h, true) !== 0x04034b50)
280
+ throw bad(`corrupt zip local header for '${e.name}'`);
281
+ const start = h + 30 + v.getUint16(h + 26, true) + v.getUint16(h + 28, true);
282
+ if (start + e.csize > b.length)
283
+ throw bad(`truncated zip entry '${e.name}'`);
284
+ const raw = b.subarray(start, start + e.csize);
285
+ let data;
286
+ if (e.method === 0) {
287
+ data = raw;
288
+ }
289
+ else if (e.method === 8) {
290
+ try {
291
+ // Headers can lie: never inflate more than the declared size.
292
+ data = await inflateRawAsync(raw, { maxOutputLength: Math.max(e.usize, 1) });
293
+ }
294
+ catch (err) {
295
+ throw inflateError(err, origin, false, limits, span);
296
+ }
297
+ }
298
+ else {
299
+ throw bad(`zip entry '${e.name}' uses unsupported compression method ${e.method}`);
300
+ }
301
+ if (data.length !== e.usize)
302
+ throw bad(`zip entry '${e.name}' size does not match its header`);
303
+ add(e.name, 'file', data, e.mode);
304
+ }
305
+ }
306
+ //# sourceMappingURL=archive.js.map
@@ -0,0 +1,16 @@
1
+ import type { BundleEntry } from './wbn-reader.js';
2
+ /**
3
+ * A Web Bundle as template data (`IMPORT b FROM ./x.wbn AS wbn`):
4
+ *
5
+ * { version, primaryUrl, entries: [ { url, path, status, contentType,
6
+ * headers, size, content? } ] }
7
+ *
8
+ * `path` is the URL's pathname; `content` is set only for textual responses
9
+ * (text/*, JSON, XML, JavaScript, SVG) whose body is valid UTF-8. Binary
10
+ * bodies are not exposed as data: use `AS tree` and COPY to place them.
11
+ */
12
+ export declare function bundleValue(bundle: {
13
+ version: string;
14
+ primaryUrl: string | undefined;
15
+ entries: readonly BundleEntry[];
16
+ }): Record<string, unknown>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A Web Bundle as template data (`IMPORT b FROM ./x.wbn AS wbn`):
3
+ *
4
+ * { version, primaryUrl, entries: [ { url, path, status, contentType,
5
+ * headers, size, content? } ] }
6
+ *
7
+ * `path` is the URL's pathname; `content` is set only for textual responses
8
+ * (text/*, JSON, XML, JavaScript, SVG) whose body is valid UTF-8. Binary
9
+ * bodies are not exposed as data: use `AS tree` and COPY to place them.
10
+ */
11
+ export function bundleValue(bundle) {
12
+ return {
13
+ version: bundle.version,
14
+ primaryUrl: bundle.primaryUrl ?? null,
15
+ entries: bundle.entries.map((e) => {
16
+ const contentType = e.headers['content-type'] ?? '';
17
+ const content = isTextual(contentType) ? utf8(e.body) : undefined;
18
+ return {
19
+ url: e.url,
20
+ path: pathOf(e.url),
21
+ status: e.status,
22
+ contentType,
23
+ headers: { ...e.headers },
24
+ size: e.body.byteLength,
25
+ ...(content !== undefined ? { content } : {}),
26
+ };
27
+ }),
28
+ };
29
+ }
30
+ const TEXTUAL = /^(text\/|application\/(json|xml|javascript|ecmascript|manifest\+json|[a-z0-9.+-]*\+(json|xml))|image\/svg\+xml)/i;
31
+ function isTextual(contentType) {
32
+ return TEXTUAL.test(contentType.trim());
33
+ }
34
+ function utf8(bytes) {
35
+ try {
36
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ function pathOf(url) {
43
+ try {
44
+ return new URL(url).pathname;
45
+ }
46
+ catch {
47
+ return url;
48
+ }
49
+ }
50
+ //# sourceMappingURL=bundle-data.js.map
@@ -0,0 +1,12 @@
1
+ import { type SourceSpan } from '../errors.js';
2
+ import { type ArchiveLimits, type Resource } from './types.js';
3
+ export interface BytesInfo {
4
+ readonly uri: string;
5
+ readonly resolvedUrl?: string;
6
+ readonly basename: string;
7
+ readonly mode?: number;
8
+ }
9
+ /** Turn fetched or read bytes into a resource of the requested kind. */
10
+ export declare function bytesToResource(bytes: Uint8Array, kind: 'file' | 'text' | 'json' | 'tree' | 'markdown' | 'yaml' | 'csv' | 'wbn' | 'html', info: BytesInfo, span?: SourceSpan, archiveLimits?: ArchiveLimits): Promise<Resource>;
11
+ /** Throw E_INTEGRITY unless sha256(bytes) equals `expected` (`sha256:<hex>`). */
12
+ export declare function verifyIntegrity(bytes: Uint8Array, expected: string, uri: string, span?: SourceSpan): void;
@@ -0,0 +1,63 @@
1
+ import { SitefileError } from '../errors.js';
2
+ import { sha256 } from '../digest.js';
3
+ import { extractArchive } from './archive.js';
4
+ import { markdownDocument } from './documents.js';
5
+ import { bundleValue } from './bundle-data.js';
6
+ import { readWebBundle } from './wbn-reader.js';
7
+ import { parseYaml } from '../yaml.js';
8
+ import { parseCsvRecords } from '../csv.js';
9
+ import { DEFAULT_ARCHIVE_LIMITS } from './types.js';
10
+ function decodeUtf8(bytes, uri, span) {
11
+ try {
12
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
13
+ }
14
+ catch (e) {
15
+ throw new SitefileError('E_ENCODING', `${uri} is not valid UTF-8`, span, { cause: e });
16
+ }
17
+ }
18
+ /** Turn fetched or read bytes into a resource of the requested kind. */
19
+ export async function bytesToResource(bytes, kind, info, span, archiveLimits = DEFAULT_ARCHIVE_LIMITS) {
20
+ const digest = sha256(bytes);
21
+ const provenance = info.resolvedUrl === undefined ? { uri: info.uri, digest } : { uri: info.uri, resolvedUrl: info.resolvedUrl, digest };
22
+ if (kind === 'tree') {
23
+ // The provenance digest is that of the archive bytes, which is what pins and the lockfile cover.
24
+ return { kind: 'tree', tree: await extractArchive(bytes, info.uri, archiveLimits, span), provenance };
25
+ }
26
+ if (kind === 'markdown') {
27
+ return { kind: 'value', format: 'markdown', value: markdownDocument(bytes, '/' + info.basename, info.uri, span), provenance };
28
+ }
29
+ if (kind === 'wbn') {
30
+ return { kind: 'value', format: 'wbn', value: bundleValue(readWebBundle(bytes, info.uri, archiveLimits, span)), provenance };
31
+ }
32
+ if (kind === 'file') {
33
+ const mode = (info.mode ?? 0o644) & 0o111 ? 0o755 : 0o644;
34
+ return { kind: 'file', bytes, mode, basename: info.basename, provenance };
35
+ }
36
+ const text = decodeUtf8(bytes, info.uri, span);
37
+ if (kind === 'text')
38
+ return { kind: 'value', format: 'text', value: text, provenance };
39
+ // A file-based html fragment: its template is parsed where a RENDER uses it.
40
+ if (kind === 'html')
41
+ return { kind: 'value', format: 'html', value: text, provenance };
42
+ if (kind === 'yaml')
43
+ return { kind: 'value', format: 'yaml', value: parseYaml(text, info.uri), provenance };
44
+ if (kind === 'csv')
45
+ return { kind: 'value', format: 'csv', value: parseCsvRecords(text, info.uri), provenance };
46
+ let value;
47
+ try {
48
+ // TextDecoder already strips a leading BOM.
49
+ value = JSON.parse(text);
50
+ }
51
+ catch (e) {
52
+ throw new SitefileError('E_TYPE', `${info.uri} is not valid JSON: ${e.message}`, span, { cause: e });
53
+ }
54
+ return { kind: 'value', format: 'json', value, provenance };
55
+ }
56
+ /** Throw E_INTEGRITY unless sha256(bytes) equals `expected` (`sha256:<hex>`). */
57
+ export function verifyIntegrity(bytes, expected, uri, span) {
58
+ const actual = sha256(bytes);
59
+ if (actual !== expected.toLowerCase()) {
60
+ throw new SitefileError('E_INTEGRITY', `integrity mismatch for ${uri}: expected ${expected}, got ${actual}`, span);
61
+ }
62
+ }
63
+ //# sourceMappingURL=convert.js.map
@@ -0,0 +1,35 @@
1
+ import { type SourceSpan } from '../errors.js';
2
+ import type { Tree } from '../tree.js';
3
+ import type { DocumentMemo } from '../memo.js';
4
+ /**
5
+ * A Markdown document as templates see it: its frontmatter fields plus
6
+ *
7
+ * - `slug`: the file name without `.md` and without a leading `YYYY-MM-DD-`
8
+ * (frontmatter `slug` wins)
9
+ * - `date`: that leading date, if the frontmatter has no `date`
10
+ * - `path`: the file's path inside the import (`/2026-01-02-hello.md`)
11
+ * - `content`: the body rendered to HTML (insert it with `${post.content|raw}`)
12
+ * - `excerpt`: plain text of the first paragraph (up to ~200 characters)
13
+ *
14
+ * `path`, `content` and `excerpt` are always computed; frontmatter keys with
15
+ * those names are ignored.
16
+ */
17
+ export type MarkdownDocument = Record<string, unknown>;
18
+ export declare function markdownDocument(bytes: Uint8Array, path: string, label: string, span?: SourceSpan): MarkdownDocument;
19
+ /** Every `.md` / `.markdown` file in a tree, as documents, ordered by path. */
20
+ export declare function markdownCollection(tree: Tree, labelPrefix: string, span?: SourceSpan, memo?: DocumentMemo): MarkdownDocument[];
21
+ /**
22
+ * Every `.json` (AS json) or `.yaml`/`.yml` (AS yaml) file in a tree, as a list
23
+ * ordered by path. An object becomes `{ slug, date?, path, ...fields }` (slug and
24
+ * date from the file name as for Markdown, overridable by the data); any other
25
+ * value becomes `{ slug, path, value }`.
26
+ */
27
+ export declare function dataCollection(tree: Tree, kind: 'json' | 'yaml', labelPrefix: string, span?: SourceSpan, memo?: DocumentMemo): MarkdownDocument[];
28
+ /**
29
+ * Sort documents by a field. Numbers and numeric strings compare numerically,
30
+ * everything else as strings (ISO dates sort correctly). Documents missing the field go last in
31
+ * either direction; ties keep path order.
32
+ */
33
+ export declare function sortDocuments(docs: readonly MarkdownDocument[], key: string, descending: boolean): MarkdownDocument[];
34
+ /** SORT BY's order of two field values: numbers and numeric strings (such as CSV cells) numerically, else as strings. */
35
+ export declare function compareValues(a: unknown, b: unknown): number;
@@ -0,0 +1,122 @@
1
+ import { posix } from 'node:path';
2
+ import { SitefileError } from '../errors.js';
3
+ import { parseFrontmatter } from '../frontmatter.js';
4
+ import { renderMarkdown } from '../markdown.js';
5
+ import { parseYaml } from '../yaml.js';
6
+ import { decodeUtf8 } from '../render.js';
7
+ const DATED = /^(\d{4}-\d{2}-\d{2})-(.+)$/;
8
+ const NUMERIC = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/;
9
+ const SAFE_SLUG = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/;
10
+ export function markdownDocument(bytes, path, label, span) {
11
+ const text = decodeUtf8(bytes, `'${label}'`, span);
12
+ const { data, body } = parseFrontmatter(text, label);
13
+ const { html, excerpt } = renderMarkdown(body);
14
+ const stem = posix.basename(path).replace(/\.(md|markdown)$/i, '');
15
+ const dated = DATED.exec(stem);
16
+ const doc = {
17
+ slug: dated ? dated[2] : stem,
18
+ ...(dated ? { date: dated[1] } : {}),
19
+ ...data,
20
+ path,
21
+ content: html,
22
+ excerpt,
23
+ };
24
+ const slug = doc['slug'];
25
+ if (typeof slug !== 'string' || !SAFE_SLUG.test(slug)) {
26
+ throw new SitefileError('E_VALIDATION', `'${label}': slug ${JSON.stringify(slug)} must be letters, digits, '.', '_', '~' or '-' (it becomes part of a URL)`, span);
27
+ }
28
+ return doc;
29
+ }
30
+ /** Every `.md` / `.markdown` file in a tree, as documents, ordered by path. */
31
+ export function markdownCollection(tree, labelPrefix, span, memo) {
32
+ const docs = [];
33
+ for (const [p, node] of tree.entries()) {
34
+ if (!/\.(md|markdown)$/i.test(p))
35
+ continue;
36
+ const label = `${labelPrefix}${p}`;
37
+ const make = () => markdownDocument(node.bytes, p, label, span);
38
+ // Incremental rebuilds: a document is a function of its bytes, path and label.
39
+ docs.push(memo ? memo(['md', node.digest, p, label], make) : make());
40
+ }
41
+ return docs;
42
+ }
43
+ /**
44
+ * Every `.json` (AS json) or `.yaml`/`.yml` (AS yaml) file in a tree, as a list
45
+ * ordered by path. An object becomes `{ slug, date?, path, ...fields }` (slug and
46
+ * date from the file name as for Markdown, overridable by the data); any other
47
+ * value becomes `{ slug, path, value }`.
48
+ */
49
+ export function dataCollection(tree, kind, labelPrefix, span, memo) {
50
+ const ext = kind === 'json' ? /\.json$/i : /\.ya?ml$/i;
51
+ const docs = [];
52
+ for (const [p, node] of tree.entries()) {
53
+ if (!ext.test(p))
54
+ continue;
55
+ const label = `${labelPrefix}${p}`;
56
+ if (memo) {
57
+ docs.push(memo([kind, node.digest, p, label], () => dataDocument(node.bytes, kind, ext, p, label, span)));
58
+ continue;
59
+ }
60
+ docs.push(dataDocument(node.bytes, kind, ext, p, label, span));
61
+ }
62
+ return docs;
63
+ }
64
+ function dataDocument(bytes, kind, ext, p, label, span) {
65
+ const text = decodeUtf8(bytes, `'${label}'`, span);
66
+ let value;
67
+ if (kind === 'yaml')
68
+ value = parseYaml(text, label);
69
+ else {
70
+ try {
71
+ value = JSON.parse(text);
72
+ }
73
+ catch (e) {
74
+ throw new SitefileError('E_TYPE', `${label} is not valid JSON: ${e.message}`, span, { cause: e });
75
+ }
76
+ }
77
+ const stem = posix.basename(p).replace(ext, '');
78
+ const dated = DATED.exec(stem);
79
+ const base = { slug: dated ? dated[2] : stem, ...(dated ? { date: dated[1] } : {}) };
80
+ const fields = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : { value };
81
+ const doc = { ...base, ...fields, path: p };
82
+ const slug = doc['slug'];
83
+ if (typeof slug !== 'string' || !SAFE_SLUG.test(slug)) {
84
+ throw new SitefileError('E_VALIDATION', `'${label}': slug ${JSON.stringify(slug)} must be letters, digits, '.', '_', '~' or '-'`, span);
85
+ }
86
+ return doc;
87
+ }
88
+ /**
89
+ * Sort documents by a field. Numbers and numeric strings compare numerically,
90
+ * everything else as strings (ISO dates sort correctly). Documents missing the field go last in
91
+ * either direction; ties keep path order.
92
+ */
93
+ export function sortDocuments(docs, key, descending) {
94
+ const cmp = compareValues;
95
+ return [...docs].sort((a, b) => {
96
+ const va = a[key];
97
+ const vb = b[key];
98
+ const ma = va === undefined || va === null;
99
+ const mb = vb === undefined || vb === null;
100
+ if (ma || mb)
101
+ return ma === mb ? byPath(a, b) : ma ? 1 : -1;
102
+ const c = cmp(va, vb);
103
+ return c === 0 ? byPath(a, b) : descending ? -c : c;
104
+ });
105
+ }
106
+ /** SORT BY's order of two field values: numbers and numeric strings (such as CSV cells) numerically, else as strings. */
107
+ export function compareValues(a, b) {
108
+ const num = (v) => typeof v === 'number' ? v : typeof v === 'string' && NUMERIC.test(v.trim()) ? Number(v) : undefined;
109
+ const na = num(a);
110
+ const nb = num(b);
111
+ if (na !== undefined && nb !== undefined)
112
+ return na - nb;
113
+ const x = String(a);
114
+ const y = String(b);
115
+ return x < y ? -1 : x > y ? 1 : 0;
116
+ }
117
+ function byPath(a, b) {
118
+ const x = String(a['path']);
119
+ const y = String(b['path']);
120
+ return x < y ? -1 : x > y ? 1 : 0;
121
+ }
122
+ //# sourceMappingURL=documents.js.map