@davidsouther/jiffies 2026.24.0 → 2026.24.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 (226) hide show
  1. package/lib/esm/assert.d.ts +26 -0
  2. package/lib/esm/assert.js +38 -0
  3. package/lib/esm/awaitable.js +1 -0
  4. package/lib/esm/case.d.ts +1 -0
  5. package/lib/esm/case.js +5 -0
  6. package/lib/esm/components/accordion.d.ts +5 -0
  7. package/lib/esm/components/accordion.js +9 -0
  8. package/lib/esm/components/alert.d.ts +7 -0
  9. package/lib/esm/components/alert.js +31 -0
  10. package/lib/esm/components/button_bar.d.ts +8 -0
  11. package/lib/esm/components/button_bar.js +25 -0
  12. package/lib/esm/components/card.d.ts +8 -0
  13. package/lib/esm/components/card.js +31 -0
  14. package/lib/esm/components/children.d.ts +2 -0
  15. package/{src/components/children.ts → lib/esm/components/children.js} +2 -6
  16. package/lib/esm/components/form.d.ts +5 -0
  17. package/lib/esm/components/form.js +13 -0
  18. package/{src/components/index.ts → lib/esm/components/index.d.ts} +2 -15
  19. package/lib/esm/components/index.js +10 -0
  20. package/lib/esm/components/inline_edit.d.ts +12 -0
  21. package/lib/esm/components/inline_edit.js +48 -0
  22. package/lib/esm/components/link.d.ts +5 -0
  23. package/lib/esm/components/link.js +11 -0
  24. package/lib/esm/components/logger.d.ts +6 -0
  25. package/lib/esm/components/logger.js +22 -0
  26. package/lib/esm/components/modal.d.ts +2 -0
  27. package/{src/components/modal.ts → lib/esm/components/modal.js} +3 -8
  28. package/lib/esm/components/nav.d.ts +11 -0
  29. package/lib/esm/components/nav.js +27 -0
  30. package/lib/esm/components/property.d.ts +9 -0
  31. package/lib/esm/components/property.js +16 -0
  32. package/lib/esm/components/select.d.ts +10 -0
  33. package/lib/esm/components/select.js +3 -0
  34. package/lib/esm/components/tabs.d.ts +20 -0
  35. package/lib/esm/components/tabs.js +45 -0
  36. package/lib/esm/components/virtual_scroll.d.ts +42 -0
  37. package/lib/esm/components/virtual_scroll.js +94 -0
  38. package/lib/esm/debounce.d.ts +1 -0
  39. package/lib/esm/debounce.js +11 -0
  40. package/lib/esm/diff.d.ts +15 -0
  41. package/lib/esm/diff.js +50 -0
  42. package/lib/esm/display.d.ts +5 -0
  43. package/lib/esm/display.js +11 -0
  44. package/lib/esm/dom/css/border.d.ts +11 -0
  45. package/lib/esm/dom/css/border.js +27 -0
  46. package/lib/esm/dom/css/constants.d.ts +31 -0
  47. package/lib/esm/dom/css/constants.js +28 -0
  48. package/lib/esm/dom/css/core.d.ts +5 -0
  49. package/lib/esm/dom/css/core.js +24 -0
  50. package/lib/esm/dom/css/fstyle.d.ts +5 -0
  51. package/lib/esm/dom/css/fstyle.js +32 -0
  52. package/lib/esm/dom/css/sizing.d.ts +5 -0
  53. package/lib/esm/dom/css/sizing.js +10 -0
  54. package/lib/esm/dom/dom.d.ts +36 -0
  55. package/lib/esm/dom/dom.js +217 -0
  56. package/lib/esm/dom/fc.d.ts +10 -0
  57. package/lib/esm/dom/fc.js +32 -0
  58. package/lib/esm/dom/form/form.app.d.ts +1 -0
  59. package/lib/esm/dom/form/form.app.js +19 -0
  60. package/lib/esm/dom/form/form.d.ts +27 -0
  61. package/lib/esm/dom/form/form.js +65 -0
  62. package/lib/esm/dom/html.d.ts +112 -0
  63. package/{src/dom/html.ts → lib/esm/dom/html.js} +2 -14
  64. package/lib/esm/dom/hydrate.d.ts +39 -0
  65. package/lib/esm/dom/hydrate.js +187 -0
  66. package/lib/esm/dom/index.js +2 -0
  67. package/lib/esm/dom/navigation/index.d.ts +76 -0
  68. package/lib/esm/dom/navigation/index.js +292 -0
  69. package/lib/esm/dom/observable.d.ts +2 -0
  70. package/lib/esm/dom/observable.js +6 -0
  71. package/lib/esm/dom/provide.d.ts +3 -0
  72. package/lib/esm/dom/provide.js +7 -0
  73. package/lib/esm/dom/render.d.ts +8 -0
  74. package/lib/esm/dom/render.js +28 -0
  75. package/lib/esm/dom/router/link.d.ts +6 -0
  76. package/lib/esm/dom/router/link.js +3 -0
  77. package/lib/esm/dom/router/router.d.ts +13 -0
  78. package/lib/esm/dom/router/router.js +52 -0
  79. package/lib/esm/dom/svg.d.ts +64 -0
  80. package/{src/dom/svg.ts → lib/esm/dom/svg.js} +2 -19
  81. package/lib/esm/dom/types/css.d.ts +6590 -0
  82. package/lib/esm/dom/types/css.js +1 -0
  83. package/lib/esm/dom/types/dom.js +1 -0
  84. package/lib/esm/dom/types/html.d.ts +614 -0
  85. package/lib/esm/dom/types/html.js +1 -0
  86. package/lib/esm/dom/xml.d.ts +1 -0
  87. package/lib/esm/dom/xml.js +4 -0
  88. package/lib/esm/equal.d.ts +11 -0
  89. package/lib/esm/equal.js +43 -0
  90. package/lib/esm/fs.d.ts +72 -0
  91. package/lib/esm/fs.js +227 -0
  92. package/lib/esm/fs_node.d.ts +15 -0
  93. package/lib/esm/fs_node.js +45 -0
  94. package/lib/esm/generator.d.ts +1 -0
  95. package/lib/esm/generator.js +10 -0
  96. package/lib/esm/lock.d.ts +1 -0
  97. package/lib/esm/lock.js +23 -0
  98. package/lib/esm/log.d.ts +69 -0
  99. package/lib/esm/log.js +211 -0
  100. package/lib/esm/observable/event.d.ts +35 -0
  101. package/lib/esm/observable/event.js +46 -0
  102. package/lib/esm/observable/observable.d.ts +134 -0
  103. package/lib/esm/observable/observable.js +349 -0
  104. package/lib/esm/range.d.ts +1 -0
  105. package/lib/esm/range.js +7 -0
  106. package/lib/esm/result.d.ts +31 -0
  107. package/lib/esm/result.js +66 -0
  108. package/lib/esm/safe.d.ts +1 -0
  109. package/lib/esm/safe.js +10 -0
  110. package/lib/esm/server/http/apps.d.ts +5 -0
  111. package/lib/esm/server/http/apps.js +23 -0
  112. package/lib/esm/server/http/css.d.ts +5 -0
  113. package/lib/esm/server/http/css.js +43 -0
  114. package/lib/esm/server/http/index.d.ts +16 -0
  115. package/lib/esm/server/http/index.js +78 -0
  116. package/lib/esm/server/http/response.d.ts +4 -0
  117. package/lib/esm/server/http/response.js +43 -0
  118. package/lib/esm/server/http/sitemap.d.ts +2 -0
  119. package/lib/esm/server/http/sitemap.js +22 -0
  120. package/lib/esm/server/http/static.d.ts +2 -0
  121. package/lib/esm/server/http/static.js +22 -0
  122. package/lib/esm/server/http/typescript.d.ts +5 -0
  123. package/lib/esm/server/http/typescript.js +40 -0
  124. package/lib/esm/server/live-reload.d.ts +46 -0
  125. package/lib/esm/server/live-reload.js +161 -0
  126. package/lib/esm/server/main.d.ts +2 -0
  127. package/{src/server/main.ts → lib/esm/server/main.js} +8 -15
  128. package/lib/esm/server/ws/frame.d.ts +2 -0
  129. package/lib/esm/server/ws/frame.js +35 -0
  130. package/lib/esm/server/ws/handshake.d.ts +4 -0
  131. package/lib/esm/server/ws/handshake.js +32 -0
  132. package/lib/esm/server/ws/index.d.ts +14 -0
  133. package/lib/esm/server/ws/index.js +68 -0
  134. package/lib/esm/ssg/bundle.d.ts +14 -0
  135. package/lib/esm/ssg/bundle.js +73 -0
  136. package/lib/esm/ssg/copy-public.d.ts +6 -0
  137. package/lib/esm/ssg/copy-public.js +34 -0
  138. package/lib/esm/ssg/discover.d.ts +15 -0
  139. package/lib/esm/ssg/discover.js +117 -0
  140. package/lib/esm/ssg/main.d.ts +2 -0
  141. package/lib/esm/ssg/main.js +122 -0
  142. package/lib/esm/ssg/rewrite.d.ts +9 -0
  143. package/{src/ssg/rewrite.ts → lib/esm/ssg/rewrite.js} +6 -9
  144. package/lib/esm/ssg/ssg.d.ts +26 -0
  145. package/lib/esm/ssg/ssg.js +84 -0
  146. package/lib/esm/transpile.d.mts +3 -0
  147. package/lib/esm/transpile.mjs +12 -0
  148. package/package.json +11 -7
  149. package/src/404.html +0 -14
  150. package/src/assert.ts +0 -56
  151. package/src/case.ts +0 -5
  152. package/src/components/_notes +0 -33
  153. package/src/components/accordion.ts +0 -25
  154. package/src/components/alert.ts +0 -47
  155. package/src/components/button_bar.ts +0 -42
  156. package/src/components/card.ts +0 -54
  157. package/src/components/form.ts +0 -25
  158. package/src/components/inline_edit.ts +0 -78
  159. package/src/components/link.ts +0 -22
  160. package/src/components/logger.ts +0 -35
  161. package/src/components/nav.ts +0 -42
  162. package/src/components/property.ts +0 -32
  163. package/src/components/select.ts +0 -22
  164. package/src/components/tabs.ts +0 -82
  165. package/src/components/virtual_scroll.ts +0 -199
  166. package/src/debounce.ts +0 -14
  167. package/src/diff.ts +0 -82
  168. package/src/display.ts +0 -18
  169. package/src/dom/README.md +0 -107
  170. package/src/dom/SKILL.md +0 -201
  171. package/src/dom/css/border.ts +0 -47
  172. package/src/dom/css/constants.ts +0 -34
  173. package/src/dom/css/core.ts +0 -28
  174. package/src/dom/css/fstyle.ts +0 -42
  175. package/src/dom/css/sizing.ts +0 -11
  176. package/src/dom/dom.ts +0 -327
  177. package/src/dom/fc.ts +0 -81
  178. package/src/dom/form/form.app.ts +0 -44
  179. package/src/dom/form/form.ts +0 -151
  180. package/src/dom/form/index.html +0 -15
  181. package/src/dom/hydrate.ts +0 -206
  182. package/src/dom/navigation/index.ts +0 -349
  183. package/src/dom/observable.ts +0 -11
  184. package/src/dom/provide.ts +0 -11
  185. package/src/dom/render.ts +0 -41
  186. package/src/dom/router/link.ts +0 -14
  187. package/src/dom/router/router.ts +0 -72
  188. package/src/dom/types/css.ts +0 -10088
  189. package/src/dom/types/html.ts +0 -629
  190. package/src/dom/xml.ts +0 -11
  191. package/src/equal.ts +0 -66
  192. package/src/favicon.ico +0 -0
  193. package/src/fs.ts +0 -300
  194. package/src/fs_node.ts +0 -57
  195. package/src/generator.ts +0 -12
  196. package/src/hooks/_notes +0 -6
  197. package/src/lock.ts +0 -23
  198. package/src/log.ts +0 -307
  199. package/src/observable/_notes +0 -26
  200. package/src/observable/event.ts +0 -93
  201. package/src/observable/observable.ts +0 -484
  202. package/src/range.ts +0 -7
  203. package/src/result.ts +0 -107
  204. package/src/safe.ts +0 -12
  205. package/src/server/http/apps.ts +0 -26
  206. package/src/server/http/css.ts +0 -49
  207. package/src/server/http/index.ts +0 -127
  208. package/src/server/http/response.ts +0 -60
  209. package/src/server/http/sitemap.ts +0 -24
  210. package/src/server/http/static.ts +0 -28
  211. package/src/server/http/typescript.ts +0 -46
  212. package/src/server/live-reload.ts +0 -208
  213. package/src/server/ws/frame.ts +0 -36
  214. package/src/server/ws/handshake.ts +0 -42
  215. package/src/server/ws/index.ts +0 -100
  216. package/src/ssg/bundle.ts +0 -85
  217. package/src/ssg/copy-public.ts +0 -44
  218. package/src/ssg/discover.ts +0 -143
  219. package/src/ssg/main.ts +0 -168
  220. package/src/ssg/ssg.ts +0 -134
  221. package/src/transpile.mjs +0 -16
  222. package/src/zip/spec.txt +0 -3260
  223. package/tsconfig.json +0 -34
  224. /package/{src/awaitable.ts → lib/esm/awaitable.d.ts} +0 -0
  225. /package/{src/dom/index.ts → lib/esm/dom/index.d.ts} +0 -0
  226. /package/{src/dom/types/dom.ts → lib/esm/dom/types/dom.d.ts} +0 -0
@@ -0,0 +1,68 @@
1
+ import { encodeTextFrame, readOpcode } from "./frame.js";
2
+ import { completeHandshake } from "./handshake.js";
3
+ // A pong control frame: FIN + opcode 0xA, empty unmasked payload.
4
+ const PONG_FRAME = Buffer.from([0x8a, 0x00]);
5
+ // Inbound control-frame opcodes the hub answers (RFC 6455 section 5.5).
6
+ const OPCODE_CLOSE = 0x8;
7
+ const OPCODE_PING = 0x9;
8
+ // Attaches a WebSocket hub to an existing http.Server via its 'upgrade' event.
9
+ // Does NOT create or own the server.
10
+ export function attachWebSocketServer(server, options) {
11
+ const sockets = new Set();
12
+ const onUpgrade = (req, socket) => {
13
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
14
+ if (pathname !== options.path) {
15
+ socket.destroy();
16
+ return;
17
+ }
18
+ if (!completeHandshake(req, socket)) {
19
+ socket.destroy();
20
+ return;
21
+ }
22
+ sockets.add(socket);
23
+ options.onConnection?.(socket);
24
+ socket.on("data", (chunk) => {
25
+ const opcode = readOpcode(chunk);
26
+ if (opcode === OPCODE_PING) {
27
+ // A pong keeps the connection alive; an unanswered ping drops it.
28
+ socket.write(PONG_FRAME);
29
+ }
30
+ else if (opcode === OPCODE_CLOSE) {
31
+ sockets.delete(socket);
32
+ socket.end();
33
+ }
34
+ });
35
+ const remove = () => {
36
+ sockets.delete(socket);
37
+ };
38
+ socket.on("error", remove);
39
+ socket.on("end", remove);
40
+ socket.on("close", remove);
41
+ };
42
+ server.on("upgrade", onUpgrade);
43
+ return {
44
+ broadcast(text) {
45
+ const frame = encodeTextFrame(text);
46
+ for (const socket of sockets) {
47
+ try {
48
+ socket.write(frame);
49
+ }
50
+ catch {
51
+ sockets.delete(socket);
52
+ }
53
+ }
54
+ },
55
+ get size() {
56
+ return sockets.size;
57
+ },
58
+ close() {
59
+ for (const socket of sockets) {
60
+ socket.end();
61
+ }
62
+ sockets.clear();
63
+ server.off("upgrade", onUpgrade);
64
+ },
65
+ };
66
+ }
67
+ export { encodeTextFrame, readOpcode } from "./frame.js";
68
+ export { acceptKey, completeHandshake } from "./handshake.js";
@@ -0,0 +1,14 @@
1
+ import type { PageDescriptor } from "./ssg.ts";
2
+ /**
3
+ * Bundle all distinct clientModules specifiers from `pages` using Rollup.
4
+ *
5
+ * Specifier resolution: a leading "/" is treated as relative to `rootDir`
6
+ * (not the filesystem root), so "/client.ts" resolves to "<rootDir>/client.ts".
7
+ * Relative specifiers resolve from `rootDir`. Absolute non-root paths are used
8
+ * as-is.
9
+ *
10
+ * Returns a map from original specifier string to the hashed asset URL
11
+ * ("/assets/<entry-name>-<hash>.js") so the HTML rewrite step can substitute
12
+ * in place.
13
+ */
14
+ export declare function bundleClientModules(pages: PageDescriptor[], rootDir: string, outDir: string): Promise<Map<string, string>>;
@@ -0,0 +1,73 @@
1
+ import { join, relative } from "node:path";
2
+ import nodeResolve from "@rollup/plugin-node-resolve";
3
+ import { rollup } from "rollup";
4
+ import tsBlankSpace from "ts-blank-space";
5
+ /**
6
+ * Bundle all distinct clientModules specifiers from `pages` using Rollup.
7
+ *
8
+ * Specifier resolution: a leading "/" is treated as relative to `rootDir`
9
+ * (not the filesystem root), so "/client.ts" resolves to "<rootDir>/client.ts".
10
+ * Relative specifiers resolve from `rootDir`. Absolute non-root paths are used
11
+ * as-is.
12
+ *
13
+ * Returns a map from original specifier string to the hashed asset URL
14
+ * ("/assets/<entry-name>-<hash>.js") so the HTML rewrite step can substitute
15
+ * in place.
16
+ */
17
+ export async function bundleClientModules(pages, rootDir, outDir) {
18
+ const allSpecs = new Set();
19
+ for (const { module } of pages) {
20
+ for (const spec of module.clientModules ?? []) {
21
+ allSpecs.add(spec);
22
+ }
23
+ }
24
+ if (allSpecs.size === 0)
25
+ return new Map();
26
+ // Resolve each specifier to an absolute disk path under rootDir.
27
+ // A leading "/" means "relative to rootDir", not the filesystem root.
28
+ const specToPath = new Map();
29
+ for (const spec of allSpecs) {
30
+ specToPath.set(spec, join(rootDir, spec));
31
+ }
32
+ // Build slug -> spec mapping and Rollup input record.
33
+ const slugToSpec = new Map();
34
+ const input = {};
35
+ for (const [spec, diskPath] of specToPath) {
36
+ const rel = relative(rootDir, diskPath);
37
+ const slug = rel.replace(/\//g, "-").replace(/\.[^.]+$/, "");
38
+ slugToSpec.set(slug, spec);
39
+ input[slug] = diskPath;
40
+ }
41
+ const bundle = await rollup({
42
+ input,
43
+ plugins: [
44
+ {
45
+ name: "ts-strip",
46
+ transform(code, id) {
47
+ if (id.endsWith(".ts")) {
48
+ return { code: tsBlankSpace(code), map: null };
49
+ }
50
+ return null;
51
+ },
52
+ },
53
+ nodeResolve({ extensions: [".ts", ".js"] }),
54
+ ],
55
+ });
56
+ const assetsDir = join(outDir, "assets");
57
+ const { output } = await bundle.write({
58
+ dir: assetsDir,
59
+ format: "es",
60
+ entryFileNames: "[name]-[hash].js",
61
+ chunkFileNames: "[name]-[hash].js",
62
+ });
63
+ const specToUrl = new Map();
64
+ for (const chunk of output) {
65
+ if (chunk.type === "chunk" && chunk.isEntry) {
66
+ const spec = slugToSpec.get(chunk.name);
67
+ if (spec !== undefined) {
68
+ specToUrl.set(spec, `/assets/${chunk.fileName}`);
69
+ }
70
+ }
71
+ }
72
+ return specToUrl;
73
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copy every file under `<rootDir>/<publicDir>` verbatim into `<outDir>`.
3
+ * Recurses into subdirectories. A missing `publicDir` is a no-op.
4
+ * Ensures target directory exists (recursive mkdir) before each copy.
5
+ */
6
+ export declare function copyPublic(rootDir: string, publicDir: string, outDir: string): Promise<string[]>;
@@ -0,0 +1,34 @@
1
+ import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Copy every file under `<rootDir>/<publicDir>` verbatim into `<outDir>`.
5
+ * Recurses into subdirectories. A missing `publicDir` is a no-op.
6
+ * Ensures target directory exists (recursive mkdir) before each copy.
7
+ */
8
+ export async function copyPublic(rootDir, publicDir, outDir) {
9
+ const src = join(rootDir, publicDir);
10
+ try {
11
+ await stat(src);
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ const copied = [];
17
+ await copyDir(src, outDir, copied);
18
+ return copied;
19
+ }
20
+ async function copyDir(src, dest, copied) {
21
+ await mkdir(dest, { recursive: true });
22
+ const entries = await readdir(src, { withFileTypes: true });
23
+ for (const entry of entries) {
24
+ const srcPath = join(src, entry.name);
25
+ const destPath = join(dest, entry.name);
26
+ if (entry.isDirectory()) {
27
+ await copyDir(srcPath, destPath, copied);
28
+ }
29
+ else {
30
+ await copyFile(srcPath, destPath);
31
+ copied.push(destPath);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,15 @@
1
+ import type { PageDescriptor } from "./ssg.ts";
2
+ /**
3
+ * Scan `<rootDir>/<pagesDir>` recursively for `page.ts` sentinels.
4
+ * Each sentinel becomes one PageDescriptor whose route is derived from the
5
+ * folder path by stripping (group) segments (any folder whose name is wrapped
6
+ * in parentheses) and treating the pages root as "/".
7
+ *
8
+ * Dynamic segments ([bracket] folders) are expanded by calling
9
+ * `generateStaticParams` on the module; each param set produces one concrete
10
+ * PageDescriptor whose `default`/`head` wrappers forward the resolved params.
11
+ *
12
+ * Throws with a message naming `pagesDir` if it does not exist or contains no
13
+ * sentinels. Caller should catch and exit 1.
14
+ */
15
+ export declare function discoverPages(rootDir: string, pagesDir: string): Promise<PageDescriptor[]>;
@@ -0,0 +1,117 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { readdir } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ const SENTINEL = "page.ts";
12
+ const SENTINEL_SUFFIX = `/${SENTINEL}`;
13
+ /**
14
+ * Scan `<rootDir>/<pagesDir>` recursively for `page.ts` sentinels.
15
+ * Each sentinel becomes one PageDescriptor whose route is derived from the
16
+ * folder path by stripping (group) segments (any folder whose name is wrapped
17
+ * in parentheses) and treating the pages root as "/".
18
+ *
19
+ * Dynamic segments ([bracket] folders) are expanded by calling
20
+ * `generateStaticParams` on the module; each param set produces one concrete
21
+ * PageDescriptor whose `default`/`head` wrappers forward the resolved params.
22
+ *
23
+ * Throws with a message naming `pagesDir` if it does not exist or contains no
24
+ * sentinels. Caller should catch and exit 1.
25
+ */
26
+ export async function discoverPages(rootDir, pagesDir) {
27
+ const pagesRoot = join(rootDir, pagesDir);
28
+ const sentinels = [];
29
+ await scan(pagesRoot, sentinels);
30
+ if (sentinels.length === 0) {
31
+ throw new Error(`No page.ts sentinels found in ${pagesDir}`);
32
+ }
33
+ const staticSentinels = [];
34
+ const dynamicSentinels = [];
35
+ for (const path of sentinels) {
36
+ const relDir = path.slice(pagesRoot.length, -SENTINEL_SUFFIX.length);
37
+ (isDynamic(relDir) ? dynamicSentinels : staticSentinels).push({
38
+ path,
39
+ relDir,
40
+ });
41
+ }
42
+ const routeToPath = new Map();
43
+ const staticDescriptors = await Promise.all(staticSentinels.map(async ({ path: sentinelPath, relDir }) => {
44
+ const route = deriveRoute(relDir);
45
+ const prev = routeToPath.get(route);
46
+ if (prev !== undefined) {
47
+ throw new Error(`Route collision at "${route}": ${prev} and ${sentinelPath} both derive the same route`);
48
+ }
49
+ routeToPath.set(route, sentinelPath);
50
+ const imported = (await import(__rewriteRelativeImportExtension(sentinelPath)));
51
+ return { route, module: imported.default };
52
+ }));
53
+ const dynamicDescriptors = [];
54
+ for (const { path: sentinelPath, relDir } of dynamicSentinels) {
55
+ const routeTemplate = deriveRoute(relDir);
56
+ const imported = (await import(__rewriteRelativeImportExtension(sentinelPath)));
57
+ const originalModule = imported.default;
58
+ if (!originalModule.generateStaticParams) {
59
+ throw new Error(`Dynamic route "${sentinelPath}" has no generateStaticParams export`);
60
+ }
61
+ const paramSets = await originalModule.generateStaticParams();
62
+ if (!Array.isArray(paramSets)) {
63
+ throw new Error(`generateStaticParams for "${sentinelPath}" must return an array`);
64
+ }
65
+ for (const params of paramSets) {
66
+ const route = fillTemplate(routeTemplate, params);
67
+ const label = `${sentinelPath} (${Object.entries(params)
68
+ .map(([k, v]) => `${k}=${v}`)
69
+ .join(", ")})`;
70
+ const prev = routeToPath.get(route);
71
+ if (prev !== undefined) {
72
+ throw new Error(`Route collision at "${route}": ${prev} and ${label} both derive the same route`);
73
+ }
74
+ routeToPath.set(route, label);
75
+ const originalHead = originalModule.head;
76
+ const wrappedModule = {
77
+ ...originalModule,
78
+ default: () => originalModule.default(params),
79
+ ...(originalHead ? { head: () => originalHead(params) } : {}),
80
+ };
81
+ dynamicDescriptors.push({ route, module: wrappedModule });
82
+ }
83
+ }
84
+ return [...staticDescriptors, ...dynamicDescriptors];
85
+ }
86
+ function isDynamic(relDir) {
87
+ return relDir.split("/").some((s) => /^\[.*\]$/.test(s));
88
+ }
89
+ function fillTemplate(template, params) {
90
+ return template.replace(/\[([^\]]+)\]/g, (_match, name) => {
91
+ const value = params[name];
92
+ if (value === undefined) {
93
+ throw new Error(`Missing param "${name}" for template "${template}"`);
94
+ }
95
+ if (value.includes("/")) {
96
+ throw new Error(`Param "${name}" value "${value}" must not contain "/"`);
97
+ }
98
+ return value;
99
+ });
100
+ }
101
+ function deriveRoute(relDir) {
102
+ const segments = relDir.split("/").filter(Boolean);
103
+ const stripped = segments.filter((s) => !/^\(.*\)$/.test(s));
104
+ return `/${stripped.join("/")}`;
105
+ }
106
+ async function scan(dir, results) {
107
+ const entries = await readdir(dir, { withFileTypes: true });
108
+ for (const entry of entries) {
109
+ const fullPath = join(dir, entry.name);
110
+ if (entry.isDirectory()) {
111
+ await scan(fullPath, results);
112
+ }
113
+ else if (entry.name === SENTINEL) {
114
+ results.push(fullPath);
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // CLI entry point for `ssg build`. All diagnostics go to stderr.
3
+ // stdout is reserved exclusively for --json output.
4
+ import { readdir, readFile, writeFile } from "node:fs/promises";
5
+ import { join, relative, resolve } from "node:path";
6
+ import * as process from "node:process";
7
+ import { parseArgs } from "node:util";
8
+ import { gzipSync } from "node:zlib";
9
+ import { NodeFileSystem } from "../fs_node.js";
10
+ import { bundleClientModules } from "./bundle.js";
11
+ import { copyPublic } from "./copy-public.js";
12
+ import { discoverPages } from "./discover.js";
13
+ import { rewriteClientSpecifiers } from "./rewrite.js";
14
+ import { build } from "./ssg.js";
15
+ function htmlPathForRoute(route, outDir) {
16
+ const segment = route.replace(/^\//, "");
17
+ return segment ? `${outDir}/${segment}/index.html` : `${outDir}/index.html`;
18
+ }
19
+ async function sizeEntry(absPath, outDir) {
20
+ const content = await readFile(absPath);
21
+ return {
22
+ path: relative(outDir, absPath),
23
+ rawBytes: content.byteLength,
24
+ gzipBytes: gzipSync(content).byteLength,
25
+ };
26
+ }
27
+ function fmtBytes(n) {
28
+ return n >= 1024 ? `${(n / 1024).toFixed(1)} kB` : `${n} B`;
29
+ }
30
+ async function runBuild(values) {
31
+ const start = Date.now();
32
+ const rootDir = resolve(values.root);
33
+ const outDir = resolve(values.out);
34
+ let pages;
35
+ try {
36
+ pages = await discoverPages(rootDir, values.pages);
37
+ }
38
+ catch (e) {
39
+ process.stderr.write(`Error: ${e.message}\n`);
40
+ process.exit(1);
41
+ }
42
+ const fs = new NodeFileSystem();
43
+ await build({ pages, out: outDir, fs });
44
+ const publicPaths = await copyPublic(rootDir, values.public, outDir);
45
+ const specToUrl = await bundleClientModules(pages, rootDir, outDir);
46
+ if (specToUrl.size > 0) {
47
+ for (const { route, module } of pages) {
48
+ if (!module.clientModules?.length)
49
+ continue;
50
+ const htmlPath = htmlPathForRoute(route, outDir);
51
+ const original = await readFile(htmlPath, "utf-8");
52
+ await writeFile(htmlPath, rewriteClientSpecifiers(original, specToUrl), "utf-8");
53
+ }
54
+ }
55
+ // Collect sizes.
56
+ const htmlPaths = pages.map(({ route }) => htmlPathForRoute(route, outDir));
57
+ const assetsDir = join(outDir, "assets");
58
+ let assetFiles = [];
59
+ try {
60
+ assetFiles = (await readdir(assetsDir)).map((f) => join(assetsDir, f));
61
+ }
62
+ catch {
63
+ // no assets directory when no clientModules
64
+ }
65
+ const [pageEntries, assetEntries, publicEntries] = await Promise.all([
66
+ Promise.all(htmlPaths.map((p) => sizeEntry(p, outDir))),
67
+ Promise.all(assetFiles.map((p) => sizeEntry(p, outDir))),
68
+ Promise.all(publicPaths.map((p) => sizeEntry(p, outDir))),
69
+ ]);
70
+ const manifest = {
71
+ pages: pageEntries,
72
+ assets: assetEntries,
73
+ public: publicEntries,
74
+ durationMs: Date.now() - start,
75
+ };
76
+ if (values.json) {
77
+ process.stdout.write(JSON.stringify(manifest, null, 2));
78
+ }
79
+ else {
80
+ const all = [...pageEntries, ...assetEntries, ...publicEntries];
81
+ const maxPath = Math.max(...all.map((e) => e.path.length));
82
+ for (const entry of all) {
83
+ const padded = entry.path.padEnd(maxPath);
84
+ process.stderr.write(`${padded} ${fmtBytes(entry.rawBytes)} │ gzip: ${fmtBytes(entry.gzipBytes)}\n`);
85
+ }
86
+ process.stderr.write(`✓ built ${pageEntries.length} pages, ${assetEntries.length} assets in ${manifest.durationMs}ms\n`);
87
+ }
88
+ }
89
+ try {
90
+ const { values, positionals } = parseArgs({
91
+ strict: true,
92
+ allowPositionals: true,
93
+ options: {
94
+ help: { type: "boolean", short: "h" },
95
+ version: { type: "boolean", short: "v" },
96
+ root: { type: "string", default: "." },
97
+ out: { type: "string", default: "dist" },
98
+ pages: { type: "string", default: "pages" },
99
+ public: { type: "string", default: "public" },
100
+ json: { type: "boolean", default: false },
101
+ "no-clean": { type: "boolean", default: false },
102
+ },
103
+ });
104
+ if (values.help) {
105
+ process.stderr.write("Usage: ssg build [--root <dir>] [--out <dir>]\n");
106
+ process.exit(0);
107
+ }
108
+ if (values.version) {
109
+ process.stderr.write("ssg 0.1.0\n");
110
+ process.exit(0);
111
+ }
112
+ const cmd = positionals[0];
113
+ if (cmd !== undefined && cmd !== "build") {
114
+ process.stderr.write(`Unknown command: ${cmd}\n`);
115
+ process.exit(1);
116
+ }
117
+ await runBuild(values);
118
+ }
119
+ catch (e) {
120
+ process.stderr.write(`${e.message ?? String(e)}\n`);
121
+ process.exit(1);
122
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Rewrite `import "<S>";` lines inside <script type="module" defer> blocks in
3
+ * `htmlContent`, replacing each original specifier `S` with the hashed asset URL
4
+ * from `specToUrl`. Lines whose specifier is not in the map are left unchanged.
5
+ *
6
+ * This is a string substitution, not DOM parsing — the exact format emitted by
7
+ * ssg.ts is `import "<S>";` on its own line inside the script block.
8
+ */
9
+ export declare function rewriteClientSpecifiers(htmlContent: string, specToUrl: Map<string, string>): string;
@@ -6,13 +6,10 @@
6
6
  * This is a string substitution, not DOM parsing — the exact format emitted by
7
7
  * ssg.ts is `import "<S>";` on its own line inside the script block.
8
8
  */
9
- export function rewriteClientSpecifiers(
10
- htmlContent: string,
11
- specToUrl: Map<string, string>,
12
- ): string {
13
- let result = htmlContent;
14
- for (const [spec, url] of specToUrl) {
15
- result = result.replaceAll(`import "${spec}";`, `import "${url}";`);
16
- }
17
- return result;
9
+ export function rewriteClientSpecifiers(htmlContent, specToUrl) {
10
+ let result = htmlContent;
11
+ for (const [spec, url] of specToUrl) {
12
+ result = result.replaceAll(`import "${spec}";`, `import "${url}";`);
13
+ }
14
+ return result;
18
15
  }
@@ -0,0 +1,26 @@
1
+ import type { FileSystem } from "../fs.ts";
2
+ export type HtmlAttributes = Partial<Record<"lang" | "dir" | "class" | `data-${string}`, string>>;
3
+ /** Describes a page's default render function and optional metadata for the SSG build. */
4
+ export interface PageModule {
5
+ default: (params?: Record<string, string>) => Node | Node[] | Promise<Node | Node[]>;
6
+ head?: (params?: Record<string, string>) => Node | Node[] | Promise<Node | Node[]>;
7
+ htmlAttributes?: HtmlAttributes;
8
+ clientModules?: string[];
9
+ /**
10
+ * Enumerate concrete param sets for dynamic route segments.
11
+ * Required when any path segment is a [bracket] folder.
12
+ */
13
+ generateStaticParams?: () => Promise<Record<string, string>[]>;
14
+ }
15
+ /** Associates a URL route with its `PageModule` for the SSG build. */
16
+ export interface PageDescriptor {
17
+ route: string;
18
+ module: PageModule;
19
+ }
20
+ /** Options for a full SSG build: the set of pages to render, the output directory, and the filesystem adapter. */
21
+ export interface BuildOptions {
22
+ pages: PageDescriptor[];
23
+ out: string;
24
+ fs: FileSystem;
25
+ }
26
+ export declare function build({ pages, out, fs }: BuildOptions): Promise<void>;
@@ -0,0 +1,84 @@
1
+ import { buildPayload, captureStubSource } from "../dom/hydrate.js";
2
+ import { renderToString } from "../dom/render.js";
3
+ /**
4
+ * Walk `root` depth-first and return every element whose localName is a
5
+ * defined custom element, in document order including nested ones. Descends
6
+ * into matched elements so the full set of custom elements is returned.
7
+ */
8
+ function scanAllUnits(root) {
9
+ const results = [];
10
+ const stack = [...root.children].reverse();
11
+ while (stack.length > 0) {
12
+ const el = stack.pop();
13
+ if (customElements.get(el.localName)) {
14
+ results.push(el);
15
+ }
16
+ // Always descend — nested custom elements are included in the full set.
17
+ for (let i = el.children.length - 1; i >= 0; i--) {
18
+ stack.push(el.children[i]);
19
+ }
20
+ }
21
+ return results;
22
+ }
23
+ /**
24
+ * Return true if el has a custom-element ancestor in the given units list.
25
+ */
26
+ function isNested(el, allUnits) {
27
+ let parent = el.parentElement;
28
+ while (parent !== null) {
29
+ if (allUnits.includes(parent))
30
+ return true;
31
+ parent = parent.parentElement;
32
+ }
33
+ return false;
34
+ }
35
+ /**
36
+ * Extract a Record<string,unknown> from an element's attribute list.
37
+ */
38
+ function attrsToProps(el) {
39
+ const props = {};
40
+ for (const attr of el.attributes) {
41
+ props[attr.name] = attr.value;
42
+ }
43
+ return props;
44
+ }
45
+ export async function build({ pages, out, fs }) {
46
+ for (const { route, module } of pages) {
47
+ const body = await module.default();
48
+ const head = module.head ? await module.head() : undefined;
49
+ let bodyStr = renderToString(body);
50
+ let headStr = head != null ? renderToString(head) : "";
51
+ const template = window.document.createElement("template");
52
+ template.innerHTML = bodyStr;
53
+ const allUnits = scanAllUnits(template.content);
54
+ const props = allUnits.map(attrsToProps);
55
+ if (props.length > 0) {
56
+ const payload = buildPayload(props);
57
+ headStr += `<script type="application/json" id="__hydration">${payload}</script>`;
58
+ }
59
+ const nestedUnits = allUnits.filter((el) => isNested(el, allUnits));
60
+ const nestedTagNames = [...new Set(nestedUnits.map((el) => el.localName))];
61
+ for (const tag of nestedTagNames) {
62
+ // Replace opening tags: <tag> and <tag ...> with defer-hydration inserted.
63
+ bodyStr = bodyStr.replace(new RegExp(`<(${tag})( |>)`, "g"), (_match, t, after) => after === ">" ? `<${t} defer-hydration>` : `<${t} defer-hydration `);
64
+ }
65
+ const clientModules = module.clientModules ?? [];
66
+ if (allUnits.length > 0 || clientModules.length > 0) {
67
+ bodyStr = `${bodyStr}<script>${captureStubSource}</script>`;
68
+ }
69
+ if (clientModules.length > 0) {
70
+ const imports = clientModules.map((m) => `import "${m}";`).join("\n");
71
+ bodyStr = `${bodyStr}<script type="module" defer>\n${imports}\n</script>`;
72
+ }
73
+ const attrs = { lang: "en", ...module.htmlAttributes };
74
+ const attrsStr = Object.entries(attrs)
75
+ .map(([k, v]) => ` ${k}="${v}"`)
76
+ .join("");
77
+ const html = `<!doctype html><html${attrsStr}><head>${headStr}</head><body>${bodyStr}</body></html>`;
78
+ const segment = route.replace(/^\//, "");
79
+ const dir = segment ? `${out}/${segment}` : out;
80
+ const path = `${dir}/index.html`;
81
+ await fs.mkdir(dir);
82
+ await fs.writeFile(path, html);
83
+ }
84
+ }
@@ -0,0 +1,3 @@
1
+ export function transpile(url: string, get: () => Promise<{
2
+ toString(): string;
3
+ }>): Promise<any>;
@@ -0,0 +1,12 @@
1
+ import tsBlankSpace from "ts-blank-space";
2
+ const tsmap = new Map();
3
+ export async function transpile(
4
+ /** @type string */ url,
5
+ /** @type {() => Promise<{toString(): string}>} */ get) {
6
+ if (!tsmap.has(url)) {
7
+ const source = (await get()).toString();
8
+ const js = tsBlankSpace(source);
9
+ tsmap.set(url, js);
10
+ }
11
+ return tsmap.get(url);
12
+ }
package/package.json CHANGED
@@ -1,25 +1,28 @@
1
1
  {
2
2
  "name": "@davidsouther/jiffies",
3
- "version": "2026.24.0",
3
+ "version": "2026.24.2",
4
4
  "private": false,
5
5
  "displayName": "JEFRi Jiffies",
6
6
  "type": "module",
7
7
  "exports": {
8
- "./*.ts": "./src/*.ts"
8
+ "./*.ts": {
9
+ "types": "./lib/esm/*.d.ts",
10
+ "default": "./lib/esm/*.js"
11
+ }
9
12
  },
10
13
  "files": [
11
- "src",
12
- "!src/**/*.test.ts",
14
+ "lib",
13
15
  "LICENSE",
14
16
  "package.json",
15
- "README.md",
16
- "tsconfig.json"
17
+ "README.md"
17
18
  ],
18
19
  "engines": {
19
20
  "node": ">=22.18.0"
20
21
  },
21
22
  "scripts": {
22
23
  "start": "node ./src/server/main.ts",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepublishOnly": "npm run build",
23
26
  "test": "node --test --test-reporter=tap",
24
27
  "ci": "node --test --test-reporter=junit",
25
28
  "format": "biome format --write",
@@ -27,7 +30,8 @@
27
30
  "check:format": "biome format",
28
31
  "check:lint": "biome check",
29
32
  "all": "npm run check:lint && npm run test",
30
- "stamp": "node scripts/stamp.ts"
33
+ "prepublish": "npm run build",
34
+ "publish": "node scripts/stamp.ts"
31
35
  },
32
36
  "devDependencies": {
33
37
  "@biomejs/biome": "^2.3.11",