@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,302 @@
1
+ import { SitefileError } from '../errors.js';
2
+ import { normalizeVirtual } from '../paths.js';
3
+ import { makeFile, Tree } from '../tree.js';
4
+ import { CborError, decodeCbor, readCborHead } from '../cbor.js';
5
+ const MAGIC = [0xf0, 0x9f, 0x8c, 0x90, 0xf0, 0x9f, 0x93, 0xa6];
6
+ /** Magic prefix: array head, bstr(8) head, then the 8 magic bytes. */
7
+ const HEAD_LEN = 10;
8
+ /** The trailing length field: bstr(8) head + 8 bytes. */
9
+ const TAIL_LEN = 9;
10
+ const MAX_SECTION_LENGTHS = 8192;
11
+ function hasMagic(b) {
12
+ if (b.length < HEAD_LEN || (b[0] !== 0x85 && b[0] !== 0x86) || b[1] !== 0x48)
13
+ return false;
14
+ return MAGIC.every((x, i) => b[2 + i] === x);
15
+ }
16
+ export function isWebBundle(bytes) {
17
+ return hasMagic(bytes);
18
+ }
19
+ export function readWebBundle(bytes, origin, limits, span) {
20
+ const bad = (why) => new SitefileError('E_ARCHIVE', `${origin}: web bundle: ${why}`, span);
21
+ try {
22
+ return read(bytes, origin, limits, span, bad);
23
+ }
24
+ catch (e) {
25
+ if (e instanceof CborError)
26
+ throw bad(`malformed CBOR: ${e.message}`);
27
+ throw e;
28
+ }
29
+ }
30
+ function read(bytes, origin, limits, span, bad) {
31
+ if (!hasMagic(bytes))
32
+ throw bad('bad magic bytes');
33
+ if (bytes.length < HEAD_LEN + 5)
34
+ throw bad('truncated header');
35
+ const isB1 = bytes[0] === 0x86;
36
+ if (bytes[HEAD_LEN] !== 0x44 || bytes[HEAD_LEN + 1] !== 0x62 || bytes[HEAD_LEN + 3] !== 0 || bytes[HEAD_LEN + 4] !== 0) {
37
+ throw bad('unrecognized version field');
38
+ }
39
+ const verDigit = bytes[HEAD_LEN + 2];
40
+ if (verDigit !== (isB1 ? 0x31 : 0x32)) {
41
+ throw bad(verDigit === 0x31 || verDigit === 0x32 ? 'header does not match its version' : 'unsupported version (expected b1 or b2)');
42
+ }
43
+ const version = isB1 ? 'b1' : 'b2';
44
+ let pos = HEAD_LEN + 5;
45
+ let primaryUrl;
46
+ if (isB1) {
47
+ const p = decodeCbor(bytes.subarray(pos), { maxItems: 1 });
48
+ if (typeof p.value !== 'string')
49
+ throw bad('primary URL is not a text string');
50
+ primaryUrl = p.value;
51
+ pos += p.end;
52
+ }
53
+ // section-lengths
54
+ const slHead = readCborHead(bytes, pos);
55
+ if (slHead.major !== 2 || typeof slHead.arg !== 'number')
56
+ throw bad('section-lengths is not a byte string');
57
+ if (slHead.arg >= MAX_SECTION_LENGTHS)
58
+ throw bad('section-lengths is too long');
59
+ if (slHead.next + slHead.arg > bytes.length)
60
+ throw bad('truncated section-lengths');
61
+ const sl = decodeCbor(bytes.subarray(slHead.next, slHead.next + slHead.arg), { maxItems: MAX_SECTION_LENGTHS });
62
+ if (!Array.isArray(sl.value) || sl.end !== slHead.arg || sl.value.length % 2 !== 0) {
63
+ throw bad('malformed section-lengths');
64
+ }
65
+ const sections = [];
66
+ pos = slHead.next + slHead.arg;
67
+ const secHead = readCborHead(bytes, pos);
68
+ if (secHead.major !== 4)
69
+ throw bad('sections is not an array');
70
+ if (secHead.arg !== sl.value.length / 2)
71
+ throw bad(`section-lengths declares ${sl.value.length / 2} sections but ${secHead.arg} are present`);
72
+ let cursor = secHead.next;
73
+ const seen = new Set();
74
+ for (let i = 0; i < sl.value.length; i += 2) {
75
+ const name = sl.value[i];
76
+ const length = sl.value[i + 1];
77
+ // CBOR unsigned integers only: a negative length would move the cursor backwards.
78
+ if (typeof name !== 'string' || typeof length !== 'number' || length < 0)
79
+ throw bad('malformed section-lengths entry');
80
+ if (seen.has(name))
81
+ throw bad(`duplicate section '${name}'`);
82
+ seen.add(name);
83
+ sections.push({ name, length, start: cursor });
84
+ cursor += length;
85
+ if (cursor > bytes.length - TAIL_LEN)
86
+ throw bad(`section '${name}' extends past the end of the bundle`);
87
+ }
88
+ const last = sections[sections.length - 1];
89
+ if (!last || last.name !== 'responses')
90
+ throw bad("the last section must be 'responses'");
91
+ // trailing length
92
+ if (cursor + TAIL_LEN !== bytes.length)
93
+ throw bad('section lengths do not add up to the bundle size');
94
+ if (bytes[cursor] !== 0x48)
95
+ throw bad('malformed trailing length field');
96
+ const declared = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(cursor + 1);
97
+ if (declared !== BigInt(bytes.length))
98
+ throw bad(`length field says ${declared} bytes but the bundle is ${bytes.length}`);
99
+ const section = (name) => {
100
+ const s = sections.find((x) => x.name === name);
101
+ return s && bytes.subarray(s.start, s.start + s.length);
102
+ };
103
+ const primarySection = section('primary');
104
+ if (primarySection) {
105
+ const p = decodeCbor(primarySection, { maxItems: 1 });
106
+ if (typeof p.value !== 'string' || p.end !== primarySection.length)
107
+ throw bad("malformed 'primary' section");
108
+ primaryUrl = p.value;
109
+ }
110
+ if (primaryUrl !== undefined) {
111
+ try {
112
+ new URL(primaryUrl);
113
+ }
114
+ catch {
115
+ throw bad(`primary URL '${primaryUrl}' is not an absolute URL`);
116
+ }
117
+ }
118
+ const indexSection = section('index');
119
+ if (!indexSection)
120
+ throw bad("missing 'index' section");
121
+ const responses = last;
122
+ const indexHead = readCborHead(indexSection, 0);
123
+ if (indexHead.major !== 5)
124
+ throw bad('index is not a map');
125
+ if (typeof indexHead.arg !== 'number' || indexHead.arg > limits.maxEntries) {
126
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: web bundle has more than ${limits.maxEntries} entries`, span);
127
+ }
128
+ const index = decodeCbor(indexSection);
129
+ if (!(index.value instanceof Map) || index.end !== indexSection.length)
130
+ throw bad('malformed index section');
131
+ const parsed = new Map();
132
+ const entries = [];
133
+ let total = 0;
134
+ for (const [url, value] of index.value) {
135
+ if (typeof url !== 'string')
136
+ throw bad('index key is not a text string');
137
+ if (!Array.isArray(value))
138
+ throw bad(`index value for '${url}' is not an array`);
139
+ let loc;
140
+ if (isB1) {
141
+ const variants = value[0];
142
+ if (!(variants instanceof Uint8Array))
143
+ throw bad(`index value for '${url}' has no variants-value`);
144
+ if (value.length !== 3) {
145
+ throw bad(value.length % 2 === 1 && value.length > 3
146
+ ? `'${url}' has multiple variants, which are not supported`
147
+ : `index value for '${url}' has an invalid size`);
148
+ }
149
+ loc = value.slice(1);
150
+ }
151
+ else {
152
+ if (value.length !== 2)
153
+ throw bad(`index value for '${url}' must be [offset, length]`);
154
+ loc = value;
155
+ }
156
+ const [off, len] = loc;
157
+ // A negative offset or length (CBOR major type 1) would read outside the responses section.
158
+ if (typeof off !== 'number' || typeof len !== 'number' || off < 0 || len < 0) {
159
+ throw bad(`index offset/length for '${url}' is not an unsigned integer`);
160
+ }
161
+ if (off + len > responses.length)
162
+ throw bad(`response for '${url}' lies outside the responses section`);
163
+ const key = `${off}:${len}`;
164
+ let r = parsed.get(key);
165
+ if (!r) {
166
+ r = parseResponse(bytes.subarray(responses.start + off, responses.start + off + len), url, bad);
167
+ parsed.set(key, r);
168
+ }
169
+ total += r.body.byteLength;
170
+ if (total > limits.maxTotalBytes) {
171
+ throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: web bundle expands beyond ${limits.maxTotalBytes} bytes`, span);
172
+ }
173
+ entries.push({ url, status: r.status, headers: r.headers, body: r.body });
174
+ }
175
+ entries.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
176
+ return { version, primaryUrl, entries };
177
+ }
178
+ const asciiOf = (b) => {
179
+ for (const x of b)
180
+ if (x > 0x7e || x < 0x20)
181
+ return undefined;
182
+ return Buffer.from(b).toString('latin1');
183
+ };
184
+ function parseResponse(b, url, bad) {
185
+ const r = decodeCbor(b, { maxItems: 16 });
186
+ if (!Array.isArray(r.value) || r.end !== b.length || r.value.length !== 2)
187
+ throw bad(`response for '${url}' is not [headers, body]`);
188
+ const [hdrBytes, body] = r.value;
189
+ if (!(hdrBytes instanceof Uint8Array) || !(body instanceof Uint8Array))
190
+ throw bad(`response for '${url}' must hold two byte strings`);
191
+ const h = decodeCbor(hdrBytes, { maxItems: 100_000 });
192
+ if (!(h.value instanceof Map) || h.end !== hdrBytes.length)
193
+ throw bad(`response headers for '${url}' are not a CBOR map`);
194
+ let status;
195
+ const headers = [];
196
+ for (const [k, v] of h.value) {
197
+ if (!(k instanceof Uint8Array) || !(v instanceof Uint8Array))
198
+ throw bad(`response headers for '${url}' must map byte strings to byte strings`);
199
+ const name = asciiOf(k);
200
+ const value = asciiOf(v);
201
+ if (name === undefined || value === undefined)
202
+ throw bad(`non-ASCII response header for '${url}'`);
203
+ if (name !== name.toLowerCase())
204
+ throw bad(`response header '${name}' for '${url}' is not lower-case`);
205
+ if (name.startsWith(':')) {
206
+ if (name !== ':status')
207
+ throw bad(`unexpected pseudo-header '${name}' for '${url}'`);
208
+ if (!/^\d{3}$/.test(value))
209
+ throw bad(`invalid :status '${value}' for '${url}'`);
210
+ status = Number(value);
211
+ }
212
+ else {
213
+ headers.push([name, value]);
214
+ }
215
+ }
216
+ if (status === undefined)
217
+ throw bad(`response for '${url}' has no :status`);
218
+ return { status, headers: Object.fromEntries(headers), body };
219
+ }
220
+ // --------------------------------------------------------------- to a Tree
221
+ /**
222
+ * Turn a bundle into a Tree. Only status-200 entries become files. When all
223
+ * such URLs share one origin the artifact path is the URL path; otherwise each
224
+ * path is prefixed with `/<host[:port]>`. A path ending in `/` maps to
225
+ * `<path>index.html`; segments are percent-decoded.
226
+ */
227
+ export function webBundleToTree(bundle, origin, span) {
228
+ const unsafe = (url, why) => new SitefileError('E_ARCHIVE_UNSAFE', `${origin}: unsafe web bundle URL '${url.replace(/\0/g, '\\0')}': ${why}`, span);
229
+ const items = [];
230
+ for (const entry of bundle.entries) {
231
+ if (entry.status !== 200)
232
+ continue;
233
+ const m = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/.exec(entry.url);
234
+ if (!m)
235
+ throw unsafe(entry.url, 'not an absolute http(s) URL');
236
+ const scheme = m[1].toLowerCase();
237
+ if (scheme !== 'http' && scheme !== 'https')
238
+ throw unsafe(entry.url, `scheme '${scheme}:' is not allowed`);
239
+ if (m[5] !== undefined)
240
+ throw unsafe(entry.url, 'URL has a fragment');
241
+ if (m[2].includes('@'))
242
+ throw unsafe(entry.url, 'URL has credentials');
243
+ let u;
244
+ try {
245
+ u = new URL(`${scheme}://${m[2]}/`);
246
+ }
247
+ catch {
248
+ throw unsafe(entry.url, 'invalid host');
249
+ }
250
+ const rawPath = m[3] === '' ? '/' : m[3];
251
+ if (rawPath.includes('\\'))
252
+ throw unsafe(entry.url, 'backslash in path');
253
+ if (!rawPath.startsWith('/'))
254
+ throw unsafe(entry.url, 'invalid path');
255
+ const segs = rawPath.slice(1).split('/');
256
+ const trailing = segs[segs.length - 1] === '';
257
+ if (trailing)
258
+ segs.pop();
259
+ const decoded = [];
260
+ for (const s of segs) {
261
+ let d;
262
+ try {
263
+ d = decodeURIComponent(s);
264
+ }
265
+ catch {
266
+ throw unsafe(entry.url, 'invalid percent-encoding in path');
267
+ }
268
+ if (d === '')
269
+ throw unsafe(entry.url, 'empty path segment');
270
+ if (d === '.' || d === '..')
271
+ throw unsafe(entry.url, `'${d}' path segment`);
272
+ if (d.includes('\0'))
273
+ throw unsafe(entry.url, 'NUL byte in path');
274
+ if (d.includes('\\'))
275
+ throw unsafe(entry.url, 'backslash in path');
276
+ if (d.includes('/'))
277
+ throw unsafe(entry.url, 'encoded slash in path');
278
+ decoded.push(d);
279
+ }
280
+ if (trailing || decoded.length === 0)
281
+ decoded.push('index.html');
282
+ items.push({ entry, origin: `${scheme}://${u.host}`, host: u.host, path: '/' + decoded.join('/'), query: m[4] !== undefined });
283
+ }
284
+ const multi = new Set(items.map((i) => i.origin)).size > 1;
285
+ const files = new Map();
286
+ for (const it of items) {
287
+ const path = normalizeVirtual((multi ? `/${it.host}` : '') + it.path, span, 'web bundle URL path').path;
288
+ const prev = files.get(path);
289
+ if (prev && prev.url !== it.entry.url && (prev.query || it.query)) {
290
+ throw unsafe(it.entry.url, `its query string makes it collide with '${prev.url}' at '${path}'`);
291
+ }
292
+ if (prev) {
293
+ if (prev.node.digest !== makeFile(it.entry.body, '').digest) {
294
+ throw new SitefileError('E_CONFLICT', `${origin}: '${prev.url}' and '${it.entry.url}' both map to '${path}' with different content`, span);
295
+ }
296
+ continue;
297
+ }
298
+ files.set(path, { node: makeFile(it.entry.body, `${origin}:${it.entry.url}`), url: it.entry.url, query: it.query });
299
+ }
300
+ return Tree.fromFiles([...files].map(([p, f]) => [p, f.node]));
301
+ }
302
+ //# sourceMappingURL=wbn-reader.js.map
@@ -0,0 +1,25 @@
1
+ import { type SourceSpan } from './errors.js';
2
+ export type SchemaType = 'string' | 'number' | 'boolean' | 'date' | 'list';
3
+ type ItemType = Exclude<SchemaType, 'list'>;
4
+ type EnumValue = string | number | boolean;
5
+ export interface SchemaRule {
6
+ readonly key: string;
7
+ readonly type?: SchemaType;
8
+ readonly items?: ItemType;
9
+ readonly enum?: readonly EnumValue[];
10
+ }
11
+ /** A compiled schema; `label` names its file in messages. */
12
+ export interface Schema {
13
+ readonly label: string;
14
+ readonly required: readonly string[];
15
+ readonly properties: readonly SchemaRule[];
16
+ }
17
+ /** Compile a parsed schema file. Errors are E_SCHEMA at `span` (the SCHEMA clause), prefixed with `what`. */
18
+ export declare function compileSchema(value: unknown, label: string, what: string, span: SourceSpan): Schema;
19
+ /**
20
+ * Check an import's value against a schema: each element of a list, or a single document. Every element must be
21
+ * an object (E_TYPE otherwise). The first mismatch fails with E_SCHEMA at `span`, naming the document (its `path`,
22
+ * or `#n` for the n-th element), the key, what was expected and what was found.
23
+ */
24
+ export declare function checkSchema(value: unknown, schema: Schema, what: string, csv: boolean, span: SourceSpan): void;
25
+ export {};
@@ -0,0 +1,169 @@
1
+ // Frontmatter and data schemas: `IMPORT posts FROM ./posts AS markdown SCHEMA ./post.schema.yaml`.
2
+ //
3
+ // A schema is a small, deterministic subset of JSON Schema, written as a YAML or JSON mapping:
4
+ //
5
+ // required: [title, date] # keys every document must have (null counts as missing)
6
+ // properties: # key -> rule; keys not listed are not checked
7
+ // title: string # a type name ...
8
+ // tags: { type: list, items: string }
9
+ // status: { enum: [draft, published] }
10
+ //
11
+ // Types: string, number, boolean, date (YYYY-MM-DD or an RFC 3339 date-time, as FEED reads it) and list
12
+ // (`items` gives the type of its elements). `enum` lists the allowed strings, numbers or booleans (for a list,
13
+ // the allowed elements). In a CSV import every cell is a string, so `number` also accepts a numeric string and
14
+ // `boolean` the strings `true` and `false`, enum entries match their text, and an empty cell counts as missing. docs/LANGUAGE.md has the rules.
15
+ import { SitefileError } from './errors.js';
16
+ import { parseFeedDate } from './site.js';
17
+ const TYPES = ['string', 'number', 'boolean', 'date', 'list'];
18
+ const ITEM_TYPES = ['string', 'number', 'boolean', 'date'];
19
+ const NUMERIC = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/;
20
+ /** What a value of each type looks like, for messages. */
21
+ const EXPECTED = {
22
+ string: 'a string',
23
+ number: 'a number',
24
+ boolean: 'true or false',
25
+ date: 'a date (YYYY-MM-DD or an RFC 3339 date-time)',
26
+ list: 'a list',
27
+ };
28
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
+ /** Compile a parsed schema file. Errors are E_SCHEMA at `span` (the SCHEMA clause), prefixed with `what`. */
30
+ export function compileSchema(value, label, what, span) {
31
+ const fail = (m) => {
32
+ throw new SitefileError('E_SCHEMA', `${what}: schema '${label}' ${m}`, span);
33
+ };
34
+ if (!isObject(value))
35
+ fail('must be a mapping (with required and/or properties)');
36
+ const root = value;
37
+ for (const k of Object.keys(root)) {
38
+ if (k !== 'required' && k !== 'properties')
39
+ fail(`has an unknown key '${k}' (expected required or properties)`);
40
+ }
41
+ const required = [];
42
+ if (root['required'] !== undefined) {
43
+ const r = root['required'];
44
+ if (!Array.isArray(r) || !r.every((k) => typeof k === 'string' && k !== ''))
45
+ fail("needs 'required' to be a list of key names");
46
+ for (const k of r)
47
+ if (!required.includes(k))
48
+ required.push(k);
49
+ }
50
+ const properties = [];
51
+ if (root['properties'] !== undefined) {
52
+ const p = root['properties'];
53
+ if (!isObject(p))
54
+ fail("needs 'properties' to be a mapping of key names to rules");
55
+ for (const [key, rule] of Object.entries(p)) {
56
+ const at = `property '${key}':`;
57
+ const typeName = (t, allowed, kind) => {
58
+ if (typeof t !== 'string' || !allowed.includes(t)) {
59
+ const shown = typeof t === 'string' ? ` '${t}'` : '';
60
+ fail(`${at} unknown ${kind}${shown} (expected ${allowed.slice(0, -1).join(', ')} or ${allowed[allowed.length - 1]})`);
61
+ }
62
+ return t;
63
+ };
64
+ if (typeof rule === 'string') {
65
+ properties.push({ key, type: typeName(rule, TYPES, 'type') });
66
+ continue;
67
+ }
68
+ if (!isObject(rule))
69
+ fail(`${at} expected a type name or a mapping with type, items or enum`);
70
+ const r = rule;
71
+ for (const k of Object.keys(r)) {
72
+ if (k !== 'type' && k !== 'items' && k !== 'enum')
73
+ fail(`${at} unknown key '${k}' (expected type, items or enum)`);
74
+ }
75
+ const type = r['type'] !== undefined ? typeName(r['type'], TYPES, 'type') : undefined;
76
+ let items;
77
+ if (r['items'] !== undefined) {
78
+ if (type !== 'list')
79
+ fail(`${at} items needs type list`);
80
+ items = typeName(r['items'], ITEM_TYPES, 'items type');
81
+ }
82
+ let values;
83
+ if (r['enum'] !== undefined) {
84
+ const e = r['enum'];
85
+ if (!Array.isArray(e) || e.length === 0 || !e.every((x) => typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean')) {
86
+ fail(`${at} enum must be a non-empty list of strings, numbers or booleans`);
87
+ }
88
+ values = e;
89
+ }
90
+ properties.push({ key, ...(type ? { type } : {}), ...(items ? { items } : {}), ...(values ? { enum: values } : {}) });
91
+ }
92
+ }
93
+ return { label, required, properties };
94
+ }
95
+ /** How a value is shown in a mismatch message. */
96
+ function describe(v) {
97
+ if (v === null)
98
+ return 'null';
99
+ if (typeof v === 'string')
100
+ return `string '${v}'`;
101
+ if (typeof v === 'number')
102
+ return `number ${String(v)}`;
103
+ if (typeof v === 'boolean')
104
+ return `boolean ${String(v)}`;
105
+ if (Array.isArray(v))
106
+ return 'a list';
107
+ return 'an object';
108
+ }
109
+ function hasType(v, t, csv) {
110
+ switch (t) {
111
+ case 'string':
112
+ return typeof v === 'string';
113
+ case 'number':
114
+ return typeof v === 'number' || (csv && typeof v === 'string' && NUMERIC.test(v.trim()));
115
+ case 'boolean':
116
+ return typeof v === 'boolean' || (csv && (v === 'true' || v === 'false'));
117
+ case 'date':
118
+ return typeof v === 'string' && parseFeedDate(v) !== undefined;
119
+ case 'list':
120
+ return Array.isArray(v);
121
+ }
122
+ }
123
+ function inEnum(v, values, csv) {
124
+ return values.some((e) => e === v || (csv && typeof v === 'string' && v === String(e)));
125
+ }
126
+ /**
127
+ * Check an import's value against a schema: each element of a list, or a single document. Every element must be
128
+ * an object (E_TYPE otherwise). The first mismatch fails with E_SCHEMA at `span`, naming the document (its `path`,
129
+ * or `#n` for the n-th element), the key, what was expected and what was found.
130
+ */
131
+ export function checkSchema(value, schema, what, csv, span) {
132
+ const docs = Array.isArray(value) ? value : [value];
133
+ docs.forEach((doc, i) => {
134
+ if (!isObject(doc)) {
135
+ throw new SitefileError('E_TYPE', `${what}: SCHEMA checks documents (objects), but element ${i + 1} is ${describe(doc)}`, span);
136
+ }
137
+ const name = typeof doc['path'] === 'string' ? doc['path'] : `#${i + 1}`;
138
+ const fail = (m) => {
139
+ throw new SitefileError('E_SCHEMA', `${what}: document '${name}' ${m}`, span);
140
+ };
141
+ // A CSV cell is always there: an empty one counts as missing.
142
+ const missing = (v) => v === undefined || v === null || (csv && v === '');
143
+ for (const key of schema.required) {
144
+ if (missing(Object.hasOwn(doc, key) ? doc[key] : undefined))
145
+ fail(`has no '${key}' (required by schema '${schema.label}')`);
146
+ }
147
+ for (const rule of schema.properties) {
148
+ const v = Object.hasOwn(doc, rule.key) ? doc[rule.key] : undefined;
149
+ if (missing(v))
150
+ continue;
151
+ const enumText = (values) => values.map((e) => String(e)).join(', ');
152
+ if (rule.type !== undefined && !hasType(v, rule.type, csv))
153
+ fail(`has '${rule.key}' ${describe(v)}; it should be ${EXPECTED[rule.type]}`);
154
+ if (rule.type === 'list') {
155
+ v.forEach((item, j) => {
156
+ if (rule.items !== undefined && !hasType(item, rule.items, csv)) {
157
+ fail(`has '${rule.key}' item ${j + 1} ${describe(item)}; it should be ${EXPECTED[rule.items]}`);
158
+ }
159
+ if (rule.enum !== undefined && !inEnum(item, rule.enum, csv))
160
+ fail(`has '${rule.key}' item ${j + 1} ${describe(item)}; it should be one of ${enumText(rule.enum)}`);
161
+ });
162
+ }
163
+ else if (rule.enum !== undefined && !inEnum(v, rule.enum, csv)) {
164
+ fail(`has '${rule.key}' ${describe(v)}; it should be one of ${enumText(rule.enum)}`);
165
+ }
166
+ }
167
+ });
168
+ }
169
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,21 @@
1
+ export type Secrets = ReadonlyMap<string, string>;
2
+ /**
3
+ * Parse `--secret` flags: `id=<id>,env=<VAR>` (an environment variable) or `id=<id>,src=<file>` (a
4
+ * file, relative to the working directory; one trailing newline is dropped). Values are checked to
5
+ * be usable as an HTTP header value. Messages never include a value.
6
+ */
7
+ export declare function loadSecrets(flags: readonly string[], env?: NodeJS.ProcessEnv): Promise<Map<string, string>>;
8
+ /** Replace every occurrence of a secret value (longest first) with `***`. */
9
+ export declare function redactSecrets(text: string, secrets: Secrets | undefined): string;
10
+ /** A SitefileError whose message has the secrets redacted (the same error when nothing changes). */
11
+ export declare function redactError(e: unknown, secrets: Secrets | undefined): unknown;
12
+ /** The request headers of an import's SECRET clauses; a secret that was not given is E_SECRET. */
13
+ export declare function secretHeaders(spec: {
14
+ readonly name: string;
15
+ readonly secrets?: readonly {
16
+ readonly id: string;
17
+ readonly header: string;
18
+ readonly bearer: boolean;
19
+ }[];
20
+ readonly span: import('./errors.js').SourceSpan;
21
+ }, secrets: Secrets | undefined): Record<string, string> | undefined;
@@ -0,0 +1,103 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { SitefileError, isSitefileError } from './errors.js';
3
+ /**
4
+ * Build secrets (issue #85), modelled on Docker's `--secret id=…,env=…|src=…`: values that remote
5
+ * IMPORTs send as HTTP request headers (`IMPORT … SECRET <id> AS HEADER <Name>` / `AS BEARER`).
6
+ *
7
+ * A secret value lives only in memory: it is sent to the IMPORT URL's own origin (never after a
8
+ * redirect to another one) and never becomes part of the output, a cache or memo key, Sitefile.lock,
9
+ * a vendor manifest, provenance, a --profile trace, a warning or an error message. As a last line of
10
+ * defence, warnings and errors of a build that has secrets are passed through {@link redactSecrets}.
11
+ */
12
+ const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ /** Visible ASCII, space and tab: what every HTTP client sends unchanged in a header value. */
14
+ const HEADER_VALUE_RE = /^[\t\x20-\x7e]+$/;
15
+ function usage(message) {
16
+ return new SitefileError('E_USAGE', message);
17
+ }
18
+ /**
19
+ * Parse `--secret` flags: `id=<id>,env=<VAR>` (an environment variable) or `id=<id>,src=<file>` (a
20
+ * file, relative to the working directory; one trailing newline is dropped). Values are checked to
21
+ * be usable as an HTTP header value. Messages never include a value.
22
+ */
23
+ export async function loadSecrets(flags, env = process.env) {
24
+ const out = new Map();
25
+ for (const flag of flags) {
26
+ const fields = new Map();
27
+ for (const part of flag.split(',')) {
28
+ const eq = part.indexOf('=');
29
+ const key = eq < 0 ? part : part.slice(0, eq);
30
+ if (eq < 0 || (key !== 'id' && key !== 'env' && key !== 'src')) {
31
+ throw usage('--secret expects id=<id>,env=<VAR> or id=<id>,src=<file>'); // never echo the flag: it might be the secret itself
32
+ }
33
+ if (fields.has(key))
34
+ throw usage(`--secret: '${key}' given twice`);
35
+ fields.set(key, part.slice(eq + 1));
36
+ }
37
+ const id = fields.get('id');
38
+ if (id === undefined || !ID_RE.test(id))
39
+ throw usage(`--secret needs id=<id> (letters, digits and _, not starting with a digit)`);
40
+ if (out.has(id))
41
+ throw usage(`--secret id=${id} given twice`);
42
+ const name = fields.get('env');
43
+ const src = fields.get('src');
44
+ if ((name === undefined) === (src === undefined))
45
+ throw usage(`--secret id=${id}: give exactly one of env=<VAR> or src=<file>`);
46
+ let value;
47
+ if (name !== undefined) {
48
+ const v = env[name];
49
+ if (v === undefined)
50
+ throw new SitefileError('E_SECRET', `--secret id=${id}: environment variable ${name} is not set`);
51
+ value = v;
52
+ }
53
+ else {
54
+ let bytes;
55
+ try {
56
+ bytes = await readFile(src);
57
+ }
58
+ catch (e) {
59
+ throw new SitefileError('E_SECRET', `--secret id=${id}: cannot read '${src}'`, undefined, { cause: isSitefileError(e) ? e : undefined });
60
+ }
61
+ value = bytes.toString('latin1').replace(/\r?\n$/, '');
62
+ }
63
+ if (value === '')
64
+ throw new SitefileError('E_SECRET', `--secret id=${id} is empty`);
65
+ if (!HEADER_VALUE_RE.test(value)) {
66
+ throw new SitefileError('E_SECRET', `--secret id=${id} cannot be sent in an HTTP header (only printable ASCII, spaces and tabs)`);
67
+ }
68
+ out.set(id, value);
69
+ }
70
+ return out;
71
+ }
72
+ /** Replace every occurrence of a secret value (longest first) with `***`. */
73
+ export function redactSecrets(text, secrets) {
74
+ if (!secrets || secrets.size === 0)
75
+ return text;
76
+ let out = text;
77
+ for (const v of [...secrets.values()].sort((a, b) => b.length - a.length))
78
+ if (v.length > 0)
79
+ out = out.split(v).join('***');
80
+ return out;
81
+ }
82
+ /** A SitefileError whose message has the secrets redacted (the same error when nothing changes). */
83
+ export function redactError(e, secrets) {
84
+ if (!isSitefileError(e) || !secrets || secrets.size === 0)
85
+ return e;
86
+ const detail = redactSecrets(e.detail, secrets);
87
+ return detail === e.detail ? e : new SitefileError(e.code, detail, e.span);
88
+ }
89
+ /** The request headers of an import's SECRET clauses; a secret that was not given is E_SECRET. */
90
+ export function secretHeaders(spec, secrets) {
91
+ if (!spec.secrets?.length)
92
+ return undefined;
93
+ const headers = {};
94
+ for (const s of spec.secrets) {
95
+ const v = secrets?.get(s.id);
96
+ if (v === undefined) {
97
+ throw new SitefileError('E_SECRET', `IMPORT ${spec.name}: secret '${s.id}' was not given; pass --secret id=${s.id},env=<VAR> or --secret id=${s.id},src=<file>`, spec.span);
98
+ }
99
+ headers[s.header.toLowerCase()] = s.bearer ? `Bearer ${v}` : v;
100
+ }
101
+ return headers;
102
+ }
103
+ //# sourceMappingURL=secrets.js.map
@@ -0,0 +1,29 @@
1
+ /** A `_redirects` rule `serve` understands. */
2
+ export interface ServeRedirect {
3
+ /** Decoded path without a trailing slash (`/` for the root); for a splat rule, the prefix before `/*` (`''` for `/*`). */
4
+ readonly from: string;
5
+ readonly splat: boolean;
6
+ readonly to: string;
7
+ readonly status: number;
8
+ /** `301!`: applies even when a file exists at the path. */
9
+ readonly force: boolean;
10
+ }
11
+ export interface ServeHeaders {
12
+ readonly pattern: string;
13
+ /** Lowercase names. */
14
+ readonly headers: ReadonlyArray<readonly [string, string]>;
15
+ }
16
+ /**
17
+ * Parse `_redirects`: `from to [status[!]]` per line, `#` comments. Supported: exact paths and a
18
+ * trailing `/*` with `:splat` in the target; statuses 301, 302, 303, 307, 308 (redirect), 200
19
+ * (rewrite) and 404. Other lines (placeholders, conditions, query matching) are ignored.
20
+ */
21
+ export declare function parseRedirectRules(text: string): ServeRedirect[];
22
+ /** Parse `_headers`: a path pattern line (`*` matches anything), then indented `Name: value` lines. */
23
+ export declare function parseHeaderRules(text: string): ServeHeaders[];
24
+ /** Glob match where `*` matches any run of characters (including `/`). */
25
+ export declare function globMatch(pattern: string, s: string): boolean;
26
+ /** Every matching block's headers; a name set by several blocks gets its values joined with `, `. */
27
+ export declare function headersFor(rules: readonly ServeHeaders[], path: string): Record<string, string>;
28
+ /** The remainder a rule matches for `path` (`''` for the prefix itself), or undefined. */
29
+ export declare function matchRedirect(r: ServeRedirect, path: string): string | undefined;