@johnhenry/sitefile 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +62 -24
  3. package/lib/src/checklinks.js +2 -0
  4. package/lib/src/cli/build-image.js +2 -2
  5. package/lib/src/cli/cache.js +15 -5
  6. package/lib/src/cli/images.js +3 -3
  7. package/lib/src/cli/index.d.ts +1 -1
  8. package/lib/src/cli/index.js +1 -1
  9. package/lib/src/cli/network.d.ts +34 -0
  10. package/lib/src/cli/network.js +56 -0
  11. package/lib/src/cli/outdated.js +5 -0
  12. package/lib/src/cli/registry.js +6 -1
  13. package/lib/src/cli/servers.js +23 -7
  14. package/lib/src/cli/vendor.js +5 -0
  15. package/lib/src/cli.js +44 -8
  16. package/lib/src/errors.d.ts +25 -3
  17. package/lib/src/errors.js +9 -0
  18. package/lib/src/executor.d.ts +7 -0
  19. package/lib/src/executor.js +18 -10
  20. package/lib/src/export-formats.d.ts +10 -3
  21. package/lib/src/export-formats.js +17 -6
  22. package/lib/src/index.d.ts +2 -1
  23. package/lib/src/index.js +2 -1
  24. package/lib/src/language/ast.d.ts +9 -2
  25. package/lib/src/outdated.js +1 -1
  26. package/lib/src/planner.d.ts +1 -1
  27. package/lib/src/planner.js +2 -2
  28. package/lib/src/plugins/cache.d.ts +29 -3
  29. package/lib/src/plugins/cache.js +104 -5
  30. package/lib/src/plugins/host.js +3 -2
  31. package/lib/src/registry.d.ts +6 -0
  32. package/lib/src/registry.js +38 -19
  33. package/lib/src/resources/https.d.ts +35 -1
  34. package/lib/src/resources/https.js +175 -18
  35. package/lib/src/resources/network.d.ts +70 -0
  36. package/lib/src/resources/network.js +304 -0
  37. package/lib/src/resources/types.d.ts +6 -0
  38. package/lib/src/secrets.js +1 -1
  39. package/lib/src/serve-rules.js +2 -1
  40. package/lib/src/servers.d.ts +14 -0
  41. package/lib/src/servers.js +89 -3
  42. package/lib/src/vendor.js +1 -1
  43. package/lib/src/wbn.d.ts +21 -0
  44. package/lib/src/wbn.js +111 -17
  45. package/package.json +2 -1
package/lib/src/wbn.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { SitefileError } from './errors.js';
2
2
  import { cborArray, cborBytes, cborMap, cborText, cborUint } from './cbor.js';
3
+ import { headersFor, matchRedirect, parseHeaderRules, parseRedirectRules } from './serve-rules.js';
3
4
  /**
4
5
  * Unsigned Web Bundle, format version "b2"
5
6
  * (draft-ietf-wpack-bundled-responses). Layout, as one CBOR array:
@@ -19,6 +20,10 @@ import { cborArray, cborBytes, cborMap, cborText, cborUint } from './cbor.js';
19
20
  * responses = [ [ headers (bstr holding { h':status' => h'200', h'content-type' => ... }), payload bstr ] ... ]
20
21
  *
21
22
  * primary = text URL, present only when /index.html exists. No signatures.
23
+ *
24
+ * The tree's own `/_headers` and `/_redirects` (what HEADER and REDIRECT
25
+ * write) are applied the way `sitefile serve` applies them (serve-rules.ts):
26
+ * see {@link bundleResponses}.
22
27
  */
23
28
  const MAGIC = Buffer.from('f09f8c90f09f93a6', 'hex');
24
29
  const VERSION = Buffer.from([0x62, 0x32, 0x00, 0x00]);
@@ -89,26 +94,18 @@ const enc = new TextEncoder();
89
94
  const bstr = (s) => cborBytes(enc.encode(s));
90
95
  export function writeWebBundle(tree, baseUrl) {
91
96
  const base = validateBaseUrl(baseUrl);
92
- const entries = tree.entries();
93
- const responses = [];
97
+ const { responses, urls } = bundleResponses(tree);
94
98
  const index = [];
95
99
  // Offset of the first response = size of the responses array head.
96
- let offset = headSize(entries.length);
97
- for (const [p, node] of entries) {
98
- const headers = cborMap([
99
- [bstr(':status'), bstr('200')],
100
- [bstr('content-type'), bstr(mimeTypeFor(p))],
101
- ]);
102
- const response = cborArray([cborBytes(headers), cborBytes(node.bytes)]);
100
+ const offsets = [];
101
+ let offset = headSize(responses.length);
102
+ for (const r of responses) {
103
+ offsets.push(offset);
104
+ offset += r.length;
105
+ }
106
+ for (const [path, i] of urls) {
103
107
  // b2 index entries are [offset, length]; b1's leading variants value is gone.
104
- const loc = [cborUint(offset), cborUint(response.length)];
105
- const urls = [base + encodePath(p)];
106
- if (p === '/index.html' || p.endsWith('/index.html'))
107
- urls.push(base + encodePath(p.slice(0, -'index.html'.length)));
108
- for (const url of urls)
109
- index.push([cborText(url), cborArray(loc)]);
110
- responses.push(response);
111
- offset += response.length;
108
+ index.push([cborText(base + encodePath(path)), cborArray([cborUint(offsets[i]), cborUint(responses[i].length)])]);
112
109
  }
113
110
  const responsesSection = cborArray(responses);
114
111
  const indexSection = cborMap(index);
@@ -133,6 +130,103 @@ export function writeWebBundle(tree, baseUrl) {
133
130
  len.writeBigUInt64BE(BigInt(total));
134
131
  return Buffer.concat([...parts, cborBytes(len)]);
135
132
  }
133
+ /** One encoded response: `[headers bstr, payload bstr]`, header names in CBOR map (bytewise) order. */
134
+ function encodeResponse(status, headers, body) {
135
+ const map = cborMap([[bstr(':status'), bstr(String(status))], ...[...headers].map(([k, v]) => [bstr(k), bstr(v)])]);
136
+ return cborArray([cborBytes(map), cborBytes(body)]);
137
+ }
138
+ const isRedirectStatus = (s) => s >= 301 && s <= 308;
139
+ /** The rule `serve` would apply first in one pass (forced or not), when it is an exact-path 3xx redirect. */
140
+ function exactRedirect(rules, forced, path) {
141
+ const i = rules.findIndex((r) => r.force === forced && matchRedirect(r, path) !== undefined);
142
+ return i >= 0 && !rules[i].splat && isRedirectStatus(rules[i].status) ? i : undefined;
143
+ }
144
+ /**
145
+ * The responses section and its URLs (tree paths, percent-encoded by the caller), as `sitefile
146
+ * serve` would answer them:
147
+ *
148
+ * - Each file is a 200 with `content-type` and the `/_headers` blocks whose pattern matches its
149
+ * URL path (`headersFor`; a `content-type` there replaces the inferred one). A directory URL
150
+ * (`/docs/` for `/docs/index.html`) matches with its trailing slash, so it gets its own
151
+ * response when its headers differ from the file URL's.
152
+ * - An exact-path `/_redirects` rule with a 3xx status becomes a response with that `:status`, a
153
+ * `location` of the rule's target as written, and an empty body: at its path when no file
154
+ * would be served there, and over the file when forced (`301!`). Like `serve`, the first rule
155
+ * matching the path decides; when that is a splat or a 200/404 rewrite, the bundle leaves the
156
+ * path to `/_redirects`. Splat rules and rewrites are not baked in.
157
+ *
158
+ * `/_headers` and `/_redirects` stay in the bundle as files. Responses are deduplicated (both URLs
159
+ * of an index page share one when their headers agree; identical redirects share one).
160
+ */
161
+ export function bundleResponses(tree) {
162
+ const text = (p) => {
163
+ const f = tree.get(p);
164
+ return f ? new TextDecoder().decode(f.bytes) : '';
165
+ };
166
+ const redirects = parseRedirectRules(text('/_redirects'));
167
+ const headerRules = parseHeaderRules(text('/_headers'));
168
+ const responses = [];
169
+ const urls = [];
170
+ const taken = new Set();
171
+ const redirectResponse = new Map();
172
+ const redirectTo = (rule) => {
173
+ const r = redirects[rule];
174
+ const key = `${r.status} ${r.to}`;
175
+ let i = redirectResponse.get(key);
176
+ if (i === undefined) {
177
+ i = responses.push(encodeResponse(r.status, [['location', r.to]], new Uint8Array(0))) - 1;
178
+ redirectResponse.set(key, i);
179
+ }
180
+ return i;
181
+ };
182
+ const fileResponse = (p, bytes, urlPath) => {
183
+ const headers = new Map([['content-type', mimeTypeFor(p)]]);
184
+ for (const [k, v] of Object.entries(headersFor(headerRules, urlPath)))
185
+ headers.set(k, v);
186
+ return encodeResponse(200, headers, bytes);
187
+ };
188
+ for (const [p, node] of tree.entries()) {
189
+ // [URL path, the path `serve` matches redirects against (no trailing slash)]
190
+ const targets = [[p, p]];
191
+ if (p === '/index.html' || p.endsWith('/index.html')) {
192
+ const dir = p.slice(0, -'index.html'.length);
193
+ targets.push([dir, dir === '/' ? '/' : dir.slice(0, -1)]);
194
+ }
195
+ let last;
196
+ for (const [urlPath, matchPath] of targets) {
197
+ taken.add(urlPath);
198
+ const forced = exactRedirect(redirects, true, matchPath);
199
+ if (forced !== undefined) {
200
+ urls.push([urlPath, redirectTo(forced)]);
201
+ continue;
202
+ }
203
+ const r = fileResponse(p, node.bytes, urlPath);
204
+ if (last === undefined || !Buffer.from(last.bytes).equals(r))
205
+ last = { bytes: r, i: responses.push(r) - 1 };
206
+ urls.push([urlPath, last.i]);
207
+ }
208
+ }
209
+ // Redirects at paths without a file: `serve` applies a forced rule first, then (when nothing
210
+ // is served there: no file, no `<path>.html`, no directory) the first other matching rule.
211
+ const servable = (p) => tree.isFile(p) && p !== '/_redirects' && p !== '/_headers';
212
+ for (const r of redirects) {
213
+ if (r.splat || !isRedirectStatus(r.status) || taken.has(r.from))
214
+ continue;
215
+ const path = r.from;
216
+ taken.add(path);
217
+ let rule = exactRedirect(redirects, true, path);
218
+ if (rule === undefined) {
219
+ const firstForced = redirects.some((x) => x.force && matchRedirect(x, path) !== undefined);
220
+ const served = path === '/' ? servable('/index.html') : servable(path) || servable(`${path}.html`) || tree.isDirectory(path);
221
+ if (firstForced || served)
222
+ continue;
223
+ rule = exactRedirect(redirects, false, path);
224
+ }
225
+ if (rule !== undefined)
226
+ urls.push([path, redirectTo(rule)]);
227
+ }
228
+ return { responses, urls };
229
+ }
136
230
  function headSize(n) {
137
231
  if (n < 24)
138
232
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/sitefile",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "description": "Docker-inspired static artifact builder: compose immutable file trees from local and remote sources, render templates, and export a deployable site. No container runtime, no shell, no runtime dependencies.",
5
5
  "keywords": [
6
6
  "static-site",
@@ -53,6 +53,7 @@
53
53
  "prepack": "npm run build"
54
54
  },
55
55
  "devDependencies": {
56
+ "@johnhenry/fileable": "^0.1.1",
56
57
  "@johnhenry/packfile": "^0.2.0",
57
58
  "@types/node": "^26.6.6",
58
59
  "typescript": "^7.0.2"