@inkandswitch/patchwork 0.0.1

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 (70) hide show
  1. package/build-head.js +10 -0
  2. package/dist/client.d.ts +38 -0
  3. package/dist/global.css +1 -0
  4. package/dist/head.d.ts +1 -0
  5. package/dist/head.js +52 -0
  6. package/dist/index.d.ts +37 -0
  7. package/dist/index.js +294 -0
  8. package/dist/loading.d.ts +5 -0
  9. package/dist/loading.js +125 -0
  10. package/dist/repo.d.ts +25 -0
  11. package/dist/repo.js +108 -0
  12. package/dist/router.d.ts +18 -0
  13. package/dist/router.js +132 -0
  14. package/dist/site-kit/html.d.ts +4 -0
  15. package/dist/site-kit/html.js +70 -0
  16. package/dist/site-kit/icons.d.ts +9 -0
  17. package/dist/site-kit/icons.js +36 -0
  18. package/dist/site-kit/index.d.ts +15 -0
  19. package/dist/site-kit/index.js +5 -0
  20. package/dist/site-kit/manifest.d.ts +3 -0
  21. package/dist/site-kit/manifest.js +29 -0
  22. package/dist/site-kit/netlify.d.ts +5 -0
  23. package/dist/site-kit/netlify.js +27 -0
  24. package/dist/site-kit/options.d.ts +67 -0
  25. package/dist/site-kit/options.js +9 -0
  26. package/dist/site-kit/sync-servers.d.ts +11 -0
  27. package/dist/site-kit/sync-servers.js +36 -0
  28. package/dist/types.d.ts +104 -0
  29. package/dist/types.js +1 -0
  30. package/dist/vite/config-plugin.d.ts +12 -0
  31. package/dist/vite/config-plugin.js +70 -0
  32. package/dist/vite/html-plugin.d.ts +11 -0
  33. package/dist/vite/html-plugin.js +51 -0
  34. package/dist/vite/icons.d.ts +4 -0
  35. package/dist/vite/icons.js +42 -0
  36. package/dist/vite/importmap-plugin.d.ts +4 -0
  37. package/dist/vite/importmap-plugin.js +83 -0
  38. package/dist/vite/manifest-plugin.d.ts +4 -0
  39. package/dist/vite/manifest-plugin.js +34 -0
  40. package/dist/vite/netlify-plugin.d.ts +9 -0
  41. package/dist/vite/netlify-plugin.js +29 -0
  42. package/dist/vite/patchwork-plugin.d.ts +43 -0
  43. package/dist/vite/patchwork-plugin.js +40 -0
  44. package/dist/vite/service-worker-plugin.d.ts +2 -0
  45. package/dist/vite/service-worker-plugin.js +49 -0
  46. package/package.json +55 -0
  47. package/src/client.d.ts +38 -0
  48. package/src/global.css +1 -0
  49. package/src/head.ts +14 -0
  50. package/src/index.ts +469 -0
  51. package/src/loading.ts +137 -0
  52. package/src/repo.ts +146 -0
  53. package/src/router.ts +195 -0
  54. package/src/site-kit/html.ts +100 -0
  55. package/src/site-kit/icons.ts +49 -0
  56. package/src/site-kit/index.ts +22 -0
  57. package/src/site-kit/manifest.ts +39 -0
  58. package/src/site-kit/netlify.ts +35 -0
  59. package/src/site-kit/options.ts +73 -0
  60. package/src/site-kit/sync-servers.ts +39 -0
  61. package/src/types.ts +135 -0
  62. package/src/vite/config-plugin.ts +88 -0
  63. package/src/vite/html-plugin.ts +60 -0
  64. package/src/vite/icons.ts +47 -0
  65. package/src/vite/importmap-plugin.ts +112 -0
  66. package/src/vite/manifest-plugin.ts +39 -0
  67. package/src/vite/netlify-plugin.ts +34 -0
  68. package/src/vite/patchwork-plugin.ts +67 -0
  69. package/src/vite/service-worker-plugin.ts +54 -0
  70. package/tsconfig.json +14 -0
package/dist/repo.js ADDED
@@ -0,0 +1,108 @@
1
+ import { initializeWasm, Repo, } from "@automerge/vanillajs/slim";
2
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
3
+ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@automerge/automerge-repo-keyhive";
4
+ // eslint-disable-next-line
5
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
6
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
7
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
8
+ import debug from "debug";
9
+ const log = debug("patchwork:setup:repo");
10
+ const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
11
+ // Fetch and initialize automerge + subduction wasm. Memoized: the fetches start
12
+ // on the first call and every later caller awaits the same init. Skipped
13
+ // entirely when the site brings its own Repo (it did this itself).
14
+ let wasmReady;
15
+ export function initWasm() {
16
+ if (!wasmReady) {
17
+ wasmReady = (async () => {
18
+ const [automergeWasm, subductionWasm] = await Promise.all([
19
+ fetch("/automerge.wasm").then((r) => r.bytes()),
20
+ fetch("/subduction.wasm").then((r) => r.bytes()),
21
+ ]);
22
+ await initializeWasm(automergeWasm);
23
+ initSubductionSync(subductionWasm);
24
+ })();
25
+ }
26
+ return wasmReady;
27
+ }
28
+ export async function createRepo(config, siteName, workerAdapter) {
29
+ if (config.keyhive) {
30
+ log("setting up keyhive");
31
+ initKeyhiveWasm();
32
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
33
+ createRepo: (repoConfig) => new Repo(repoConfig),
34
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
35
+ peerIdSuffix: siteName + Math.random().toString(36).slice(2),
36
+ networkAdapter: workerAdapter,
37
+ automaticArchiveIngestion: true,
38
+ cachingMode: "periodic",
39
+ onlyShareWithHardcodedServerPeerId: false,
40
+ // ARK selects the relay via `syncServer`, defaulting to "subduction".
41
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
42
+ repo: {
43
+ storage: new IndexedDBWorkerStorageAdapter(),
44
+ enableRemoteHeadsGossiping: true,
45
+ },
46
+ });
47
+ log("keyhive setup complete");
48
+ return { repo, hive };
49
+ }
50
+ // An explicit signer, rather than the Repo's internal default, so the tab's
51
+ // identity can be exposed on window.patchwork. The tab never connects via
52
+ // Subduction, so this id never goes on the wire.
53
+ const signer = new MemorySigner();
54
+ const repo = new Repo({
55
+ network: [workerAdapter],
56
+ storage: new IndexedDBWorkerStorageAdapter(),
57
+ signer,
58
+ async sharePolicy(peerId) {
59
+ return peerId.includes("automerge-worker");
60
+ },
61
+ enableRemoteHeadsGossiping: true,
62
+ peerId: `${siteName}-tab-${crypto.randomUUID()}`,
63
+ });
64
+ const signerIdentity = {
65
+ peerId: signer.peerId().toString(),
66
+ verifyingKey: signer.verifyingKey().toHex(),
67
+ };
68
+ log("repo created, tab subduction identity:", signerIdentity);
69
+ return { repo, signerIdentity };
70
+ }
71
+ /**
72
+ * Resolve with the first repo port the worker delivers, calling `onRenewed` for
73
+ * every later one.
74
+ *
75
+ * subscribeToRepoChannel is deliberately not awaited: it resolves only after
76
+ * the boot channel's port-ready handshake, which can take its full 30s timeout
77
+ * against a stranded worker connection. Boot blocks on the first *delivered*
78
+ * port instead — if the boot channel stalls, worker recovery hands the listener
79
+ * a good port long before that timeout.
80
+ */
81
+ export function firstRepoPort(sw, onRenewed) {
82
+ return new Promise((resolve) => {
83
+ let seen = false;
84
+ void sw.subscribeToRepoChannel((port) => {
85
+ if (seen)
86
+ return onRenewed(port);
87
+ seen = true;
88
+ resolve(port);
89
+ });
90
+ });
91
+ }
92
+ /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
93
+ export function removeAdapterFor(repo, stale, keep) {
94
+ for (const adapter of [...repo.networkSubsystem.adapters]) {
95
+ if (adapter === keep)
96
+ continue;
97
+ // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
98
+ const base = adapter.networkAdapter ?? adapter;
99
+ if (base !== stale)
100
+ continue;
101
+ try {
102
+ repo.networkSubsystem.removeNetworkAdapter(adapter);
103
+ }
104
+ catch (err) {
105
+ console.error("failed to remove stale worker network adapter", err);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,18 @@
1
+ import { type AutomergeUrl, type DocHandle, type Repo } from "@automerge/vanillajs/slim";
2
+ import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
3
+ /**
4
+ * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
5
+ * (`automerge:<id>[#heads]`) or a bare document id, for older links.
6
+ */
7
+ export declare function docParamToUrl(docParam: string | null): AutomergeUrl | undefined;
8
+ export interface RouterParams {
9
+ rootElement: HTMLElement;
10
+ repo: Repo;
11
+ accountDocHandle: DocHandle<AccountDoc>;
12
+ siteName: string;
13
+ }
14
+ export interface Router {
15
+ /** Apply `location.hash` to the view. */
16
+ route(): Promise<void>;
17
+ }
18
+ export declare function createRouter({ rootElement, repo, accountDocHandle, siteName, }: RouterParams): Router;
package/dist/router.js ADDED
@@ -0,0 +1,132 @@
1
+ import { isValidAutomergeUrl, isValidDocumentId, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
2
+ import { openDocument } from "@inkandswitch/patchwork-elements";
3
+ import { getRegistry, } from "@inkandswitch/patchwork-plugins";
4
+ // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
5
+ // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
6
+ // anchor on the `--` before the base58 document id rather than a strict slug
7
+ // charset.
8
+ const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
9
+ // The `doc=` value is an automerge URL, kept literal rather than
10
+ // percent-encoded so links stay readable.
11
+ const RAW_HASH_KEYS = new Set(["doc"]);
12
+ // A stable order means re-serializing the same logical params is
13
+ // byte-identical, avoiding spurious `hashchange` round-trips.
14
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
15
+ function serializeHashParams(params) {
16
+ const keys = [...HASH_KEY_ORDER, ...params.keys()];
17
+ const parts = [];
18
+ const emitted = new Set();
19
+ for (const key of keys) {
20
+ if (emitted.has(key))
21
+ continue;
22
+ const value = params.get(key);
23
+ if (!value)
24
+ continue;
25
+ emitted.add(key);
26
+ parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
27
+ }
28
+ return parts.join("&");
29
+ }
30
+ /**
31
+ * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
32
+ * (`automerge:<id>[#heads]`) or a bare document id, for older links.
33
+ */
34
+ export function docParamToUrl(docParam) {
35
+ if (!docParam)
36
+ return undefined;
37
+ if (isValidAutomergeUrl(docParam)) {
38
+ return docParam;
39
+ }
40
+ const documentId = docParam.replace(/^automerge:/, "");
41
+ if (!isValidDocumentId(documentId))
42
+ return undefined;
43
+ return stringifyAutomergeUrl({ documentId: documentId });
44
+ }
45
+ export function createRouter({ rootElement, repo, accountDocHandle, siteName, }) {
46
+ const route = async () => {
47
+ // The first call seeds the root view's tool/doc so it can mount; later
48
+ // calls reconcile the mounted view with the hash.
49
+ if (!rootElement.hasAttribute("tool-id")) {
50
+ const params = new URLSearchParams(location.hash.slice(1));
51
+ const frame = params.get("frame");
52
+ rootElement.setAttribute("tool-id", frame ?? accountDocHandle.doc().frameToolId);
53
+ rootElement.setAttribute("doc-url", (frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url);
54
+ return;
55
+ }
56
+ const hash = window.location.hash.slice(1);
57
+ // Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
58
+ // routing re-run on the resulting hashchange.
59
+ const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
60
+ if (legacyDocId && isValidDocumentId(legacyDocId)) {
61
+ window.location.hash = serializeHashParams(new URLSearchParams({
62
+ doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
63
+ }));
64
+ return;
65
+ }
66
+ // Bare automerge URL: /#automerge:<documentId>
67
+ if (isValidAutomergeUrl(hash)) {
68
+ window.location.hash = "";
69
+ openDocument(rootElement, hash);
70
+ return;
71
+ }
72
+ const params = new URLSearchParams(hash);
73
+ const docUrl = docParamToUrl(params.get("doc"));
74
+ const frame = params.get("frame");
75
+ if (frame) {
76
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
77
+ if (rootElement.getAttribute("tool-id") !== frame ||
78
+ rootElement.getAttribute("doc-url") !== frameDocUrl) {
79
+ rootElement.setAttribute("tool-id", frame);
80
+ rootElement.setAttribute("doc-url", frameDocUrl);
81
+ }
82
+ }
83
+ if (docUrl) {
84
+ rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
85
+ detail: {
86
+ url: docUrl,
87
+ toolId: params.get("tool"),
88
+ title: params.get("title"),
89
+ type: params.get("type"),
90
+ },
91
+ }));
92
+ }
93
+ };
94
+ rootElement.addEventListener("patchwork:open-document", async (event) => {
95
+ const { url, toolId, type, title } = event.detail;
96
+ const params = new URLSearchParams(window.location.hash.slice(1));
97
+ // `doc` is the full automerge URL, so heads live inside it and the separate
98
+ // `heads=` param is gone.
99
+ params.delete("heads");
100
+ params.set("doc", url);
101
+ for (const [key, value] of [
102
+ ["tool", toolId],
103
+ ["title", title],
104
+ ["type", type],
105
+ ]) {
106
+ if (value)
107
+ params.set(key, value);
108
+ else
109
+ params.delete(key);
110
+ }
111
+ window.location.hash = serializeHashParams(params);
112
+ try {
113
+ const docHandle = await repo.find(url);
114
+ const doc = docHandle.doc();
115
+ const docType = type || doc?.["@patchwork"]?.type;
116
+ if (!docType)
117
+ return;
118
+ const datatype = await getRegistry("patchwork:datatype").load(docType);
119
+ if (!datatype)
120
+ return;
121
+ const docTitle = datatype.module.getTitle(doc);
122
+ if (docTitle)
123
+ document.title = `${docTitle} | ${siteName}`;
124
+ }
125
+ catch (e) {
126
+ console.error("Failed to update document title", e);
127
+ }
128
+ });
129
+ window.addEventListener("hashchange", route);
130
+ void route();
131
+ return { route };
132
+ }
@@ -0,0 +1,4 @@
1
+ import type { PatchworkSiteOptions } from "./options.js";
2
+ export declare function escapeHtml(value: string): string;
3
+ /** Builds the generated index.html as a plain string — no bundler involved. */
4
+ export declare function buildHtml(options: PatchworkSiteOptions): string;
@@ -0,0 +1,70 @@
1
+ import { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
2
+ import { ICON_SPECS } from "./icons.js";
3
+ const HTML_ESCAPES = {
4
+ "&": "&amp;",
5
+ "<": "&lt;",
6
+ ">": "&gt;",
7
+ '"': "&quot;",
8
+ "'": "&#39;",
9
+ };
10
+ export function escapeHtml(value) {
11
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
12
+ }
13
+ /** Builds the generated index.html as a plain string — no bundler involved. */
14
+ export function buildHtml(options) {
15
+ const title = options.title ?? options.siteName ?? "Patchwork";
16
+ const lang = (options.html && options.html.lang) || "en";
17
+ const entry = options.entry ?? "/src/main.ts";
18
+ const syncServers = resolveSyncServers(options);
19
+ const head = [
20
+ `<meta charset="UTF-8" />`,
21
+ `<meta name="viewport" content="width=device-width, initial-scale=1.0" />`,
22
+ `<title>${escapeHtml(title)}</title>`,
23
+ `<link rel="stylesheet" href="@inkandswitch/patchwork/global.css" />`,
24
+ ];
25
+ if (options.description) {
26
+ head.push(`<meta name="description" content="${escapeHtml(options.description)}" />`);
27
+ }
28
+ if (options.icons) {
29
+ for (const spec of ICON_SPECS) {
30
+ if (spec.fileName === "apple-touch-icon.png") {
31
+ head.push(`<link rel="apple-touch-icon" sizes="${spec.size}x${spec.size}" href="/${spec.fileName}" />`);
32
+ }
33
+ else if (spec.fileName.startsWith("favicon-")) {
34
+ head.push(`<link rel="icon" type="image/png" sizes="${spec.size}x${spec.size}" href="/${spec.fileName}" />`);
35
+ }
36
+ }
37
+ if (options.icons?.maskIcon) {
38
+ head.push(`<link rel="mask-icon" href="${options.icons.maskIcon}" color="${options.icons.maskIconColor ?? "#000000"}" />`);
39
+ }
40
+ }
41
+ if (typeof options.themeColor === "string") {
42
+ head.push(`<meta name="theme-color" content="${options.themeColor}" />`);
43
+ }
44
+ else if (options.themeColor) {
45
+ head.push(`<meta name="theme-color" content="${options.themeColor.light}" media="(prefers-color-scheme: light)" />`, `<meta name="theme-color" content="${options.themeColor.dark}" media="(prefers-color-scheme: dark)" />`);
46
+ }
47
+ head.push(`<meta name="apple-mobile-web-app-capable" content="yes" />`, `<meta name="apple-mobile-web-app-status-bar-style" content="default" />`, `<meta name="apple-mobile-web-app-title" content="${escapeHtml(title)}" />`);
48
+ if (options.manifest !== false) {
49
+ head.push(`<link rel="manifest" href="/manifest.webmanifest" />`);
50
+ }
51
+ for (const server of syncServers) {
52
+ head.push(`<link rel="preconnect" href="${server}" />`);
53
+ }
54
+ for (const server of syncServers) {
55
+ head.push(`<link rel="dns-prefetch" href="${server}" />`);
56
+ }
57
+ for (const asset of PRELOAD_WASM_ASSETS) {
58
+ head.push(`<link rel="preload" href="/${asset}" as="fetch" crossorigin />`);
59
+ }
60
+ if (options.html && options.html.extraHead) {
61
+ head.push(options.html.extraHead);
62
+ }
63
+ return `<!doctype html>
64
+ <html lang="${lang}">
65
+ ${head.join("\n")}
66
+ <repo-provider><patchwork-view id="root"></patchwork-view></repo-provider>
67
+ <script type="module" src="${entry}"></script>
68
+ </html>
69
+ `;
70
+ }
@@ -0,0 +1,9 @@
1
+ export interface IconSpec {
2
+ fileName: string;
3
+ size: number;
4
+ /** Included in the generated manifest.webmanifest icons array when set. */
5
+ manifestPurpose?: string;
6
+ }
7
+ export declare const ICON_SPECS: IconSpec[];
8
+ /** Renders every icon size from a single source image (svg or raster), cached by source path. */
9
+ export declare function getIcons(source: string): Promise<Map<string, Buffer>>;
@@ -0,0 +1,36 @@
1
+ import sharp from "sharp";
2
+ import { readFile } from "node:fs/promises";
3
+ // apple-touch-icon at 180 matches iOS's expected size; 192/512 are the
4
+ // standard PWA manifest sizes; 16/32 cover browser tab favicons.
5
+ export const ICON_SPECS = [
6
+ { fileName: "favicon-16x16.png", size: 16 },
7
+ { fileName: "favicon-32x32.png", size: 32 },
8
+ { fileName: "apple-touch-icon.png", size: 180 },
9
+ { fileName: "icon-192x192.png", size: 192, manifestPurpose: "any maskable" },
10
+ { fileName: "icon-512x512.png", size: 512, manifestPurpose: "any maskable" },
11
+ ];
12
+ const cache = new Map();
13
+ async function renderIcons(source) {
14
+ const input = await readFile(source);
15
+ const rendered = new Map();
16
+ await Promise.all(ICON_SPECS.map(async (spec) => {
17
+ const buffer = await sharp(input)
18
+ .resize(spec.size, spec.size, { fit: "cover" })
19
+ .png()
20
+ .toBuffer();
21
+ rendered.set(spec.fileName, buffer);
22
+ }));
23
+ return rendered;
24
+ }
25
+ /** Renders every icon size from a single source image (svg or raster), cached by source path. */
26
+ export function getIcons(source) {
27
+ let promise = cache.get(source);
28
+ if (!promise) {
29
+ promise = renderIcons(source).catch((error) => {
30
+ cache.delete(source);
31
+ throw error;
32
+ });
33
+ cache.set(source, promise);
34
+ }
35
+ return promise;
36
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The bundler-agnostic parts of building a Patchwork site's static assets —
3
+ * icon rendering, and the index.html/manifest.webmanifest/Netlify _headers
4
+ * content builders. No `vite` import anywhere in this directory: a
5
+ * different bundler adapter (esbuild, webpack, or a plain pre-build script)
6
+ * can reuse these directly. `../vite/*` is the vite-specific adapter that
7
+ * wires these into vite's plugin hooks (dev middleware, emitFile, virtual
8
+ * modules, config()).
9
+ */
10
+ export type { PatchworkSiteOptions, PatchworkIconsOptions, PatchworkHtmlOptions, PatchworkNetlifyOptions, PatchworkSyncServersOptions, } from "./options.js";
11
+ export { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
12
+ export { getIcons, ICON_SPECS, type IconSpec } from "./icons.js";
13
+ export { buildHtml, escapeHtml } from "./html.js";
14
+ export { buildManifest } from "./manifest.js";
15
+ export { buildHeaders, REDIRECTS } from "./netlify.js";
@@ -0,0 +1,5 @@
1
+ export { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
2
+ export { getIcons, ICON_SPECS } from "./icons.js";
3
+ export { buildHtml, escapeHtml } from "./html.js";
4
+ export { buildManifest } from "./manifest.js";
5
+ export { buildHeaders, REDIRECTS } from "./netlify.js";
@@ -0,0 +1,3 @@
1
+ import type { PatchworkSiteOptions } from "./options.js";
2
+ /** Builds the generated manifest.webmanifest object — no bundler involved. */
3
+ export declare function buildManifest(options: PatchworkSiteOptions): Record<string, unknown>;
@@ -0,0 +1,29 @@
1
+ import { ICON_SPECS } from "./icons.js";
2
+ /** Builds the generated manifest.webmanifest object — no bundler involved. */
3
+ export function buildManifest(options) {
4
+ const title = options.title ?? options.siteName ?? "Patchwork";
5
+ const icons = !options.icons
6
+ ? []
7
+ : ICON_SPECS.filter((spec) => spec.fileName === "apple-touch-icon.png" || spec.manifestPurpose).map((spec) => ({
8
+ src: `/${spec.fileName}`,
9
+ sizes: `${spec.size}x${spec.size}`,
10
+ type: "image/png",
11
+ ...(spec.manifestPurpose ? { purpose: spec.manifestPurpose } : {}),
12
+ }));
13
+ const manifest = {
14
+ name: title,
15
+ short_name: options.shortName ?? title,
16
+ description: options.description,
17
+ start_url: "/",
18
+ display: "standalone",
19
+ display_override: ["window-controls-overlay", "standalone"],
20
+ background_color: options.backgroundColor ?? "#ffffff",
21
+ theme_color: (typeof options.themeColor === "string"
22
+ ? options.themeColor
23
+ : options.themeColor?.light) ??
24
+ options.backgroundColor ??
25
+ "#ffffff",
26
+ icons,
27
+ };
28
+ return { ...manifest, ...options.manifest };
29
+ }
@@ -0,0 +1,5 @@
1
+ import type { PatchworkSiteOptions } from "./options.js";
2
+ /** Builds the generated Netlify _headers file content — no bundler involved. */
3
+ export declare function buildHeaders(options: PatchworkSiteOptions): string;
4
+ /** The generated Netlify _redirects file content — always the same SPA fallback. */
5
+ export declare const REDIRECTS = "/* /index.html 200\n";
@@ -0,0 +1,27 @@
1
+ import { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
2
+ function buildLinkHeader(syncServers) {
3
+ const parts = PRELOAD_WASM_ASSETS.map((asset) => `</${asset}>; rel=preload; as=fetch; crossorigin`);
4
+ for (const server of syncServers) {
5
+ parts.push(`<${server}>; rel=preconnect`);
6
+ }
7
+ for (const server of syncServers) {
8
+ parts.push(`<${server}>; rel=dns-prefetch`);
9
+ }
10
+ return parts.join(", ");
11
+ }
12
+ /** Builds the generated Netlify _headers file content — no bundler involved. */
13
+ export function buildHeaders(options) {
14
+ const syncServers = resolveSyncServers(options);
15
+ const immutableAssets = options.netlify === false || options.netlify?.immutableAssets === false
16
+ ? null
17
+ : "/assets/*\n Cache-Control: public, max-age=31536000, immutable\n";
18
+ return [
19
+ "/*\n Access-Control-Allow-Origin: *",
20
+ immutableAssets,
21
+ `/\n Link: ${buildLinkHeader(syncServers)}`,
22
+ ]
23
+ .filter(Boolean)
24
+ .join("\n");
25
+ }
26
+ /** The generated Netlify _redirects file content — always the same SPA fallback. */
27
+ export const REDIRECTS = "/* /index.html 200\n";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The bundler-agnostic option shape for a Patchwork site's generated
3
+ * static assets (icons, index.html, manifest.webmanifest, Netlify config).
4
+ * `../vite/patchwork-plugin.ts` extends this with vite-only config
5
+ * (importmap, server/preview/worker/build) — a different bundler adapter
6
+ * can reuse this same shape and the pure builders in this directory without
7
+ * pulling in anything vite-specific.
8
+ */
9
+ export interface PatchworkIconsOptions {
10
+ /** Path (relative to the site root) to a source svg or raster image. Every icon size is rendered from it via sharp. */
11
+ source: string;
12
+ /** Path to a monochrome svg for Safari's pinned-tab mask icon. Omitted entirely if not set. */
13
+ maskIcon?: string;
14
+ maskIconColor?: string;
15
+ }
16
+ export interface PatchworkHtmlOptions {
17
+ lang?: string;
18
+ /** Raw HTML appended just before </html> — e.g. extra <link>/<meta> tags. */
19
+ extraHead?: string;
20
+ }
21
+ export interface PatchworkNetlifyOptions {
22
+ /** Cache-Control: immutable on /assets/*. Default true. */
23
+ immutableAssets?: boolean;
24
+ }
25
+ export interface PatchworkSyncServersOptions {
26
+ /** wss:// URL for the legacy automerge-repo sync-server channel (connected on demand via connectClassicSync). Default: wss://sync3.automerge.org. Pass false to skip its preconnect hint. */
27
+ classic?: string | false;
28
+ /** wss:// URL for subduction sync — live when keyhiveSyncServer is false. Default: wss://subduction.sync.inkandswitch.com */
29
+ subduction?: string;
30
+ /** wss:// URL for keyhive sync — live when keyhiveSyncServer is true. Default: wss://keyhive.sync.automerge.org */
31
+ keyhive?: string;
32
+ }
33
+ export interface PatchworkSiteOptions {
34
+ /** -> __SITE_NAME__ define */
35
+ siteName?: string;
36
+ /** <title>, apple-mobile-web-app-title, manifest name */
37
+ title?: string;
38
+ /** manifest short_name (defaults to title) */
39
+ shortName?: string;
40
+ /** manifest description, <meta name=description> */
41
+ description?: string;
42
+ /** default "/src/main.ts" — must be root-absolute, since the generated index.html doesn't live at the project root */
43
+ entry?: string;
44
+ /** -> __KEYHIVE__ define */
45
+ keyhive?: boolean;
46
+ /** -> __KEYHIVE_SYNC_SERVER__ define */
47
+ keyhiveSyncServer?: boolean;
48
+ themeColor?: string | {
49
+ light: string;
50
+ dark: string;
51
+ };
52
+ /** manifest background_color */
53
+ backgroundColor?: string;
54
+ /**
55
+ * Which sync-server hosts to preconnect/dns-prefetch from the html and
56
+ * list in the Netlify Link header. Only the channel actually live for
57
+ * this build is included — subduction xor keyhive, picked by
58
+ * `keyhiveSyncServer`, matching automerge-worker.ts's own selection —
59
+ * plus `classic` (on-demand, but cheap to hint) unless set to `false`.
60
+ * Pass `false` to skip all sync-server hints.
61
+ */
62
+ syncServers?: false | PatchworkSyncServersOptions;
63
+ icons?: false | PatchworkIconsOptions;
64
+ html?: false | PatchworkHtmlOptions;
65
+ manifest?: false | Record<string, unknown>;
66
+ netlify?: false | PatchworkNetlifyOptions;
67
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The bundler-agnostic option shape for a Patchwork site's generated
3
+ * static assets (icons, index.html, manifest.webmanifest, Netlify config).
4
+ * `../vite/patchwork-plugin.ts` extends this with vite-only config
5
+ * (importmap, server/preview/worker/build) — a different bundler adapter
6
+ * can reuse this same shape and the pure builders in this directory without
7
+ * pulling in anything vite-specific.
8
+ */
9
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { PatchworkSiteOptions } from "./options.js";
2
+ /**
3
+ * Resolves which sync-server origins are actually live for this build: the
4
+ * channel that's live is subduction xor keyhive (picked by
5
+ * `keyhiveSyncServer`, matching automerge-worker.ts's own selection) plus
6
+ * classic (on-demand, but still worth a preconnect hint) unless disabled.
7
+ * A flat list would drift from `keyhiveSyncServer` — e.g. always hinting
8
+ * subduction even on a build that actually connects to keyhive.
9
+ */
10
+ export declare function resolveSyncServers(options: PatchworkSiteOptions): string[];
11
+ export declare const PRELOAD_WASM_ASSETS: string[];
@@ -0,0 +1,36 @@
1
+ // Mirrors core/bootloader/src/sync-config.ts's DEFAULT_CLASSIC_SYNC_SERVER
2
+ // and automerge-worker.ts's SUBDUCTION_SYNC_URL selection — kept here as
3
+ // plain constants (rather than importing those runtime modules) since this
4
+ // only needs the hostnames, not the browser-only logic that reads them.
5
+ const DEFAULT_SYNC_SERVERS = {
6
+ classic: "wss://sync3.automerge.org",
7
+ subduction: "wss://subduction.sync.inkandswitch.com",
8
+ keyhive: "wss://keyhive.sync.automerge.org",
9
+ };
10
+ function wsToHttpOrigin(wsUrl) {
11
+ return wsUrl.replace(/^ws/, "http");
12
+ }
13
+ /**
14
+ * Resolves which sync-server origins are actually live for this build: the
15
+ * channel that's live is subduction xor keyhive (picked by
16
+ * `keyhiveSyncServer`, matching automerge-worker.ts's own selection) plus
17
+ * classic (on-demand, but still worth a preconnect hint) unless disabled.
18
+ * A flat list would drift from `keyhiveSyncServer` — e.g. always hinting
19
+ * subduction even on a build that actually connects to keyhive.
20
+ */
21
+ export function resolveSyncServers(options) {
22
+ if (options.syncServers === false)
23
+ return [];
24
+ const servers = { ...DEFAULT_SYNC_SERVERS, ...options.syncServers };
25
+ const origins = [];
26
+ const primary = options.keyhiveSyncServer
27
+ ? servers.keyhive
28
+ : servers.subduction;
29
+ origins.push(primary);
30
+ if (servers.classic)
31
+ origins.push(servers.classic);
32
+ return origins.map(wsToHttpOrigin);
33
+ }
34
+ // Emitted by importmap-plugin.ts. keyhive_wasm.wasm is loaded lazily (only
35
+ // when keyhive is actually enabled), so it isn't worth an eager preload.
36
+ export const PRELOAD_WASM_ASSETS = ["automerge.wasm", "subduction.wasm"];