@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/src/loading.ts ADDED
@@ -0,0 +1,137 @@
1
+ const LOADING_ELEMENT_ID = "pw-loading";
2
+ const LOADING_STYLE_ID = "pw-loading-styles";
3
+
4
+ const LOADING_CSS = `
5
+ @keyframes pw-loading-pulse {
6
+ 0%, 100% { opacity: 0.25; }
7
+ 50% { opacity: 0.95; }
8
+ }
9
+ #${LOADING_ELEMENT_ID} {
10
+ position: fixed;
11
+ inset: 0;
12
+ z-index: 0;
13
+ pointer-events: none;
14
+ background-color: #fff;
15
+ background-image:
16
+ radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
17
+ radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
18
+ radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
19
+ animation: pw-loading-pulse 3.5s ease-in-out infinite;
20
+ transition: opacity 0.6s ease-out;
21
+ }
22
+ @media (prefers-color-scheme: dark) {
23
+ #${LOADING_ELEMENT_ID} {
24
+ background-color: #000;
25
+ background-image:
26
+ radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
27
+ radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
28
+ radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
29
+ }
30
+ }
31
+ #${LOADING_ELEMENT_ID}.pw-loading-fading {
32
+ opacity: 0;
33
+ animation: none;
34
+ }
35
+ `;
36
+
37
+ export function showLoadingAnimation(): void {
38
+ if (!document.getElementById(LOADING_STYLE_ID)) {
39
+ const style = document.createElement("style");
40
+ style.id = LOADING_STYLE_ID;
41
+ style.textContent = LOADING_CSS;
42
+ document.head.appendChild(style);
43
+ }
44
+ if (document.getElementById(LOADING_ELEMENT_ID)) return;
45
+ const el = document.createElement("div");
46
+ el.id = LOADING_ELEMENT_ID;
47
+ document.body.appendChild(el);
48
+ }
49
+
50
+ export function hideLoadingAnimation(): void {
51
+ const el = document.getElementById(LOADING_ELEMENT_ID);
52
+ if (!el) return;
53
+ el.classList.add("pw-loading-fading");
54
+ setTimeout(() => el.remove(), 700);
55
+ }
56
+
57
+ const ERROR_ELEMENT_ID = "pw-error";
58
+
59
+ const ERROR_CSS = `
60
+ #${ERROR_ELEMENT_ID} {
61
+ position: fixed;
62
+ inset: 0;
63
+ z-index: 1;
64
+ overflow: auto;
65
+ padding: 3rem 1.5rem;
66
+ background: #fff;
67
+ color: #1a1a1a;
68
+ font: 1rem/1.5 system-ui, sans-serif;
69
+ }
70
+ #${ERROR_ELEMENT_ID} > div {
71
+ max-width: 38rem;
72
+ margin: 0 auto;
73
+ }
74
+ #${ERROR_ELEMENT_ID} h1 {
75
+ font-size: 1.25rem;
76
+ margin: 0 0 1rem;
77
+ }
78
+ #${ERROR_ELEMENT_ID} pre {
79
+ padding: 1rem;
80
+ border-radius: 0.5rem;
81
+ background: #f4f0f2;
82
+ overflow: auto;
83
+ white-space: pre-wrap;
84
+ font-size: 0.8125rem;
85
+ }
86
+ @media (prefers-color-scheme: dark) {
87
+ #${ERROR_ELEMENT_ID} {
88
+ background: #111;
89
+ color: #eee;
90
+ }
91
+ #${ERROR_ELEMENT_ID} pre {
92
+ background: #221a2e;
93
+ }
94
+ #${ERROR_ELEMENT_ID} a {
95
+ color: #9cc8f0;
96
+ }
97
+ }
98
+ `;
99
+
100
+ export function showErrorScreen(
101
+ error: unknown,
102
+ options: { contact?: string } = {}
103
+ ): void {
104
+ hideLoadingAnimation();
105
+ if (document.getElementById(ERROR_ELEMENT_ID)) return;
106
+
107
+ const style = document.createElement("style");
108
+ style.textContent = ERROR_CSS;
109
+ document.head.appendChild(style);
110
+
111
+ const screen = document.createElement("div");
112
+ screen.id = ERROR_ELEMENT_ID;
113
+ const inner = document.createElement("div");
114
+ screen.appendChild(inner);
115
+
116
+ const heading = document.createElement("h1");
117
+ heading.textContent = "Something went wrong starting this site";
118
+ inner.appendChild(heading);
119
+
120
+ const detail = document.createElement("pre");
121
+ detail.textContent =
122
+ error instanceof Error ? (error.stack ?? error.message) : String(error);
123
+ inner.appendChild(detail);
124
+
125
+ const advice = document.createElement("p");
126
+ advice.append("Reloading the page may help. If it keeps happening");
127
+ if (options.contact) {
128
+ const link = document.createElement("a");
129
+ link.href = `mailto:${options.contact}`;
130
+ link.textContent = options.contact;
131
+ advice.append(", email ", link);
132
+ }
133
+ advice.append(".");
134
+ inner.appendChild(advice);
135
+
136
+ document.body.appendChild(screen);
137
+ }
package/src/repo.ts ADDED
@@ -0,0 +1,146 @@
1
+ import {
2
+ initializeWasm,
3
+ MessageChannelNetworkAdapter,
4
+ Repo,
5
+ type AutomergeUrl,
6
+ } from "@automerge/vanillajs/slim";
7
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
8
+ import * as AutomergeRepo from "@automerge/automerge-repo/slim";
9
+ import {
10
+ initKeyhiveWasm,
11
+ initializeAutomergeRepoKeyhiveWithRepo,
12
+ type AutomergeRepoKeyhive,
13
+ } from "@automerge/automerge-repo-keyhive";
14
+ // eslint-disable-next-line
15
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
16
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
17
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
18
+ import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
19
+ import type { SignerIdentity } from "./types.js";
20
+ import debug from "debug";
21
+
22
+ const log = debug("patchwork:setup:repo");
23
+
24
+ // Must match the automerge-worker's selection, or the tab and the SW grant
25
+ // relay access to different servers.
26
+ declare const __KEYHIVE_SYNC_SERVER__: boolean;
27
+ const useKeyhiveSyncServer =
28
+ typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
29
+
30
+ // Fetch and initialize automerge + subduction wasm. Memoized: the fetches start
31
+ // on the first call and every later caller awaits the same init. Skipped
32
+ // entirely when the site brings its own Repo (it did this itself).
33
+ let wasmReady: Promise<void> | undefined;
34
+ export function initWasm(): Promise<void> {
35
+ if (!wasmReady) {
36
+ wasmReady = (async () => {
37
+ const [automergeWasm, subductionWasm] = await Promise.all([
38
+ fetch("/automerge.wasm").then((r) => r.bytes()),
39
+ fetch("/subduction.wasm").then((r) => r.bytes()),
40
+ ]);
41
+ await initializeWasm(automergeWasm);
42
+ initSubductionSync(subductionWasm);
43
+ })();
44
+ }
45
+ return wasmReady;
46
+ }
47
+
48
+ export async function createRepo(
49
+ config: { keyhive?: boolean },
50
+ siteName: string,
51
+ workerAdapter: MessageChannelNetworkAdapter
52
+ ): Promise<{
53
+ repo: Repo;
54
+ hive?: AutomergeRepoKeyhive;
55
+ signerIdentity?: SignerIdentity;
56
+ }> {
57
+ if (config.keyhive) {
58
+ log("setting up keyhive");
59
+ initKeyhiveWasm();
60
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
61
+ createRepo: (repoConfig) => new Repo(repoConfig),
62
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
63
+ peerIdSuffix: siteName + Math.random().toString(36).slice(2),
64
+ networkAdapter: workerAdapter,
65
+ automaticArchiveIngestion: true,
66
+ cachingMode: "periodic",
67
+ onlyShareWithHardcodedServerPeerId: false,
68
+ // ARK selects the relay via `syncServer`, defaulting to "subduction".
69
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
70
+ repo: {
71
+ storage: new IndexedDBWorkerStorageAdapter(),
72
+ enableRemoteHeadsGossiping: true,
73
+ },
74
+ });
75
+ log("keyhive setup complete");
76
+ return { repo, hive };
77
+ }
78
+
79
+ // An explicit signer, rather than the Repo's internal default, so the tab's
80
+ // identity can be exposed on window.patchwork. The tab never connects via
81
+ // Subduction, so this id never goes on the wire.
82
+ const signer = new MemorySigner();
83
+ const repo = new Repo({
84
+ network: [workerAdapter],
85
+ storage: new IndexedDBWorkerStorageAdapter(),
86
+ signer,
87
+ async sharePolicy(peerId) {
88
+ return peerId.includes("automerge-worker");
89
+ },
90
+ enableRemoteHeadsGossiping: true,
91
+ peerId: `${siteName}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
92
+ });
93
+ const signerIdentity = {
94
+ peerId: signer.peerId().toString(),
95
+ verifyingKey: (
96
+ signer.verifyingKey() as Uint8Array<ArrayBufferLike> & {
97
+ toHex(): string;
98
+ }
99
+ ).toHex(),
100
+ };
101
+ log("repo created, tab subduction identity:", signerIdentity);
102
+ return { repo, signerIdentity };
103
+ }
104
+
105
+ /**
106
+ * Resolve with the first repo port the worker delivers, calling `onRenewed` for
107
+ * every later one.
108
+ *
109
+ * subscribeToRepoChannel is deliberately not awaited: it resolves only after
110
+ * the boot channel's port-ready handshake, which can take its full 30s timeout
111
+ * against a stranded worker connection. Boot blocks on the first *delivered*
112
+ * port instead — if the boot channel stalls, worker recovery hands the listener
113
+ * a good port long before that timeout.
114
+ */
115
+ export function firstRepoPort(
116
+ sw: Awaited<ReturnType<typeof setupServiceWorker>>,
117
+ onRenewed: (port: MessagePort) => void
118
+ ): Promise<MessagePort> {
119
+ return new Promise<MessagePort>((resolve) => {
120
+ let seen = false;
121
+ void sw.subscribeToRepoChannel((port) => {
122
+ if (seen) return onRenewed(port);
123
+ seen = true;
124
+ resolve(port);
125
+ });
126
+ });
127
+ }
128
+
129
+ /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
130
+ export function removeAdapterFor(
131
+ repo: Repo,
132
+ stale: MessageChannelNetworkAdapter,
133
+ keep: unknown
134
+ ): void {
135
+ for (const adapter of [...repo.networkSubsystem.adapters]) {
136
+ if (adapter === keep) continue;
137
+ // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
138
+ const base = (adapter as any).networkAdapter ?? adapter;
139
+ if (base !== stale) continue;
140
+ try {
141
+ repo.networkSubsystem.removeNetworkAdapter(adapter as any);
142
+ } catch (err) {
143
+ console.error("failed to remove stale worker network adapter", err);
144
+ }
145
+ }
146
+ }
package/src/router.ts ADDED
@@ -0,0 +1,195 @@
1
+ import {
2
+ isValidAutomergeUrl,
3
+ isValidDocumentId,
4
+ stringifyAutomergeUrl,
5
+ type AutomergeUrl,
6
+ type DocHandle,
7
+ type DocumentId,
8
+ type Repo,
9
+ } from "@automerge/vanillajs/slim";
10
+ import { openDocument } from "@inkandswitch/patchwork-elements";
11
+ import {
12
+ type AccountDoc,
13
+ type DatatypeDescription,
14
+ type DatatypeImplementation,
15
+ getRegistry,
16
+ } from "@inkandswitch/patchwork-plugins";
17
+
18
+ // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
19
+ // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
20
+ // anchor on the `--` before the base58 document id rather than a strict slug
21
+ // charset.
22
+ const BIG_PATCHWORK_HASH_REGEX =
23
+ /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
24
+
25
+ // The `doc=` value is an automerge URL, kept literal rather than
26
+ // percent-encoded so links stay readable.
27
+ const RAW_HASH_KEYS = new Set(["doc"]);
28
+ // A stable order means re-serializing the same logical params is
29
+ // byte-identical, avoiding spurious `hashchange` round-trips.
30
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
31
+
32
+ function serializeHashParams(params: URLSearchParams): string {
33
+ const keys = [...HASH_KEY_ORDER, ...params.keys()];
34
+ const parts: string[] = [];
35
+ const emitted = new Set<string>();
36
+ for (const key of keys) {
37
+ if (emitted.has(key)) continue;
38
+ const value = params.get(key);
39
+ if (!value) continue;
40
+ emitted.add(key);
41
+ parts.push(
42
+ `${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
43
+ );
44
+ }
45
+ return parts.join("&");
46
+ }
47
+
48
+ /**
49
+ * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
50
+ * (`automerge:<id>[#heads]`) or a bare document id, for older links.
51
+ */
52
+ export function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
53
+ if (!docParam) return undefined;
54
+ if (isValidAutomergeUrl(docParam as AutomergeUrl)) {
55
+ return docParam as AutomergeUrl;
56
+ }
57
+ const documentId = docParam.replace(/^automerge:/, "");
58
+ if (!isValidDocumentId(documentId)) return undefined;
59
+ return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
60
+ }
61
+
62
+ export interface RouterParams {
63
+ rootElement: HTMLElement;
64
+ repo: Repo;
65
+ accountDocHandle: DocHandle<AccountDoc>;
66
+ siteName: string;
67
+ }
68
+
69
+ export interface Router {
70
+ /** Apply `location.hash` to the view. */
71
+ route(): Promise<void>;
72
+ }
73
+
74
+ export function createRouter({
75
+ rootElement,
76
+ repo,
77
+ accountDocHandle,
78
+ siteName,
79
+ }: RouterParams): Router {
80
+ const route = async () => {
81
+ // The first call seeds the root view's tool/doc so it can mount; later
82
+ // calls reconcile the mounted view with the hash.
83
+ if (!rootElement.hasAttribute("tool-id")) {
84
+ const params = new URLSearchParams(location.hash.slice(1));
85
+ const frame = params.get("frame");
86
+ rootElement.setAttribute(
87
+ "tool-id",
88
+ frame ?? accountDocHandle.doc().frameToolId
89
+ );
90
+ rootElement.setAttribute(
91
+ "doc-url",
92
+ (frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url
93
+ );
94
+ return;
95
+ }
96
+
97
+ const hash = window.location.hash.slice(1);
98
+
99
+ // Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
100
+ // routing re-run on the resulting hashchange.
101
+ const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
102
+ if (legacyDocId && isValidDocumentId(legacyDocId)) {
103
+ window.location.hash = serializeHashParams(
104
+ new URLSearchParams({
105
+ doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
106
+ })
107
+ );
108
+ return;
109
+ }
110
+
111
+ // Bare automerge URL: /#automerge:<documentId>
112
+ if (isValidAutomergeUrl(hash as AutomergeUrl)) {
113
+ window.location.hash = "";
114
+ openDocument(rootElement, hash as AutomergeUrl);
115
+ return;
116
+ }
117
+
118
+ const params = new URLSearchParams(hash);
119
+ const docUrl = docParamToUrl(params.get("doc"));
120
+ const frame = params.get("frame");
121
+
122
+ if (frame) {
123
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
124
+ if (
125
+ rootElement.getAttribute("tool-id") !== frame ||
126
+ rootElement.getAttribute("doc-url") !== frameDocUrl
127
+ ) {
128
+ rootElement.setAttribute("tool-id", frame);
129
+ rootElement.setAttribute("doc-url", frameDocUrl);
130
+ }
131
+ }
132
+
133
+ if (docUrl) {
134
+ rootElement.dispatchEvent(
135
+ new CustomEvent("patchwork:open-document", {
136
+ detail: {
137
+ url: docUrl,
138
+ toolId: params.get("tool"),
139
+ title: params.get("title"),
140
+ type: params.get("type"),
141
+ },
142
+ })
143
+ );
144
+ }
145
+ };
146
+
147
+ rootElement.addEventListener("patchwork:open-document", async (event) => {
148
+ const { url, toolId, type, title } = event.detail as {
149
+ url: AutomergeUrl;
150
+ toolId?: string;
151
+ type?: string;
152
+ title?: string;
153
+ };
154
+
155
+ const params = new URLSearchParams(window.location.hash.slice(1));
156
+ // `doc` is the full automerge URL, so heads live inside it and the separate
157
+ // `heads=` param is gone.
158
+ params.delete("heads");
159
+ params.set("doc", url);
160
+ for (const [key, value] of [
161
+ ["tool", toolId],
162
+ ["title", title],
163
+ ["type", type],
164
+ ] as const) {
165
+ if (value) params.set(key, value);
166
+ else params.delete(key);
167
+ }
168
+ window.location.hash = serializeHashParams(params);
169
+
170
+ try {
171
+ const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
172
+ url
173
+ );
174
+ const doc = docHandle.doc();
175
+ const docType = type || doc?.["@patchwork"]?.type;
176
+ if (!docType) return;
177
+ const datatype =
178
+ await getRegistry<DatatypeDescription>("patchwork:datatype").load(
179
+ docType
180
+ );
181
+ if (!datatype) return;
182
+ const docTitle = (datatype.module as DatatypeImplementation).getTitle(
183
+ doc
184
+ );
185
+ if (docTitle) document.title = `${docTitle} | ${siteName}`;
186
+ } catch (e) {
187
+ console.error("Failed to update document title", e);
188
+ }
189
+ });
190
+
191
+ window.addEventListener("hashchange", route);
192
+ void route();
193
+
194
+ return { route };
195
+ }
@@ -0,0 +1,100 @@
1
+ import type { PatchworkSiteOptions } from "./options.js";
2
+ import { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
3
+ import { ICON_SPECS } from "./icons.js";
4
+
5
+ const HTML_ESCAPES: Record<string, string> = {
6
+ "&": "&amp;",
7
+ "<": "&lt;",
8
+ ">": "&gt;",
9
+ '"': "&quot;",
10
+ "'": "&#39;",
11
+ };
12
+
13
+ export function escapeHtml(value: string): string {
14
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]!);
15
+ }
16
+
17
+ /** Builds the generated index.html as a plain string — no bundler involved. */
18
+ export function buildHtml(options: PatchworkSiteOptions): string {
19
+ const title = options.title ?? options.siteName ?? "Patchwork";
20
+ const lang = (options.html && options.html.lang) || "en";
21
+ const entry = options.entry ?? "/src/main.ts";
22
+ const syncServers = resolveSyncServers(options);
23
+
24
+ const head: string[] = [
25
+ `<meta charset="UTF-8" />`,
26
+ `<meta name="viewport" content="width=device-width, initial-scale=1.0" />`,
27
+ `<title>${escapeHtml(title)}</title>`,
28
+ `<link rel="stylesheet" href="@inkandswitch/patchwork/global.css" />`,
29
+ ];
30
+
31
+ if (options.description) {
32
+ head.push(
33
+ `<meta name="description" content="${escapeHtml(options.description)}" />`
34
+ );
35
+ }
36
+
37
+ if (options.icons) {
38
+ for (const spec of ICON_SPECS) {
39
+ if (spec.fileName === "apple-touch-icon.png") {
40
+ head.push(
41
+ `<link rel="apple-touch-icon" sizes="${spec.size}x${spec.size}" href="/${spec.fileName}" />`
42
+ );
43
+ } else if (spec.fileName.startsWith("favicon-")) {
44
+ head.push(
45
+ `<link rel="icon" type="image/png" sizes="${spec.size}x${spec.size}" href="/${spec.fileName}" />`
46
+ );
47
+ }
48
+ }
49
+ if (options.icons?.maskIcon) {
50
+ head.push(
51
+ `<link rel="mask-icon" href="${options.icons.maskIcon}" color="${
52
+ options.icons.maskIconColor ?? "#000000"
53
+ }" />`
54
+ );
55
+ }
56
+ }
57
+
58
+ if (typeof options.themeColor === "string") {
59
+ head.push(`<meta name="theme-color" content="${options.themeColor}" />`);
60
+ } else if (options.themeColor) {
61
+ head.push(
62
+ `<meta name="theme-color" content="${options.themeColor.light}" media="(prefers-color-scheme: light)" />`,
63
+ `<meta name="theme-color" content="${options.themeColor.dark}" media="(prefers-color-scheme: dark)" />`
64
+ );
65
+ }
66
+
67
+ head.push(
68
+ `<meta name="apple-mobile-web-app-capable" content="yes" />`,
69
+ `<meta name="apple-mobile-web-app-status-bar-style" content="default" />`,
70
+ `<meta name="apple-mobile-web-app-title" content="${escapeHtml(title)}" />`
71
+ );
72
+
73
+ if (options.manifest !== false) {
74
+ head.push(`<link rel="manifest" href="/manifest.webmanifest" />`);
75
+ }
76
+
77
+ for (const server of syncServers) {
78
+ head.push(`<link rel="preconnect" href="${server}" />`);
79
+ }
80
+ for (const server of syncServers) {
81
+ head.push(`<link rel="dns-prefetch" href="${server}" />`);
82
+ }
83
+ for (const asset of PRELOAD_WASM_ASSETS) {
84
+ head.push(
85
+ `<link rel="preload" href="/${asset}" as="fetch" crossorigin />`
86
+ );
87
+ }
88
+
89
+ if (options.html && options.html.extraHead) {
90
+ head.push(options.html.extraHead);
91
+ }
92
+
93
+ return `<!doctype html>
94
+ <html lang="${lang}">
95
+ ${head.join("\n")}
96
+ <repo-provider><patchwork-view id="root"></patchwork-view></repo-provider>
97
+ <script type="module" src="${entry}"></script>
98
+ </html>
99
+ `;
100
+ }
@@ -0,0 +1,49 @@
1
+ import sharp from "sharp";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ export interface IconSpec {
5
+ fileName: string;
6
+ size: number;
7
+ /** Included in the generated manifest.webmanifest icons array when set. */
8
+ manifestPurpose?: string;
9
+ }
10
+
11
+ // apple-touch-icon at 180 matches iOS's expected size; 192/512 are the
12
+ // standard PWA manifest sizes; 16/32 cover browser tab favicons.
13
+ export const ICON_SPECS: IconSpec[] = [
14
+ { fileName: "favicon-16x16.png", size: 16 },
15
+ { fileName: "favicon-32x32.png", size: 32 },
16
+ { fileName: "apple-touch-icon.png", size: 180 },
17
+ { fileName: "icon-192x192.png", size: 192, manifestPurpose: "any maskable" },
18
+ { fileName: "icon-512x512.png", size: 512, manifestPurpose: "any maskable" },
19
+ ];
20
+
21
+ const cache = new Map<string, Promise<Map<string, Buffer>>>();
22
+
23
+ async function renderIcons(source: string): Promise<Map<string, Buffer>> {
24
+ const input = await readFile(source);
25
+ const rendered = new Map<string, Buffer>();
26
+ await Promise.all(
27
+ ICON_SPECS.map(async (spec) => {
28
+ const buffer = await sharp(input)
29
+ .resize(spec.size, spec.size, { fit: "cover" })
30
+ .png()
31
+ .toBuffer();
32
+ rendered.set(spec.fileName, buffer);
33
+ })
34
+ );
35
+ return rendered;
36
+ }
37
+
38
+ /** Renders every icon size from a single source image (svg or raster), cached by source path. */
39
+ export function getIcons(source: string): Promise<Map<string, Buffer>> {
40
+ let promise = cache.get(source);
41
+ if (!promise) {
42
+ promise = renderIcons(source).catch((error) => {
43
+ cache.delete(source);
44
+ throw error;
45
+ });
46
+ cache.set(source, promise);
47
+ }
48
+ return promise;
49
+ }
@@ -0,0 +1,22 @@
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 {
11
+ PatchworkSiteOptions,
12
+ PatchworkIconsOptions,
13
+ PatchworkHtmlOptions,
14
+ PatchworkNetlifyOptions,
15
+ PatchworkSyncServersOptions,
16
+ } from "./options.js";
17
+
18
+ export { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
19
+ export { getIcons, ICON_SPECS, type IconSpec } from "./icons.js";
20
+ export { buildHtml, escapeHtml } from "./html.js";
21
+ export { buildManifest } from "./manifest.js";
22
+ export { buildHeaders, REDIRECTS } from "./netlify.js";
@@ -0,0 +1,39 @@
1
+ import type { PatchworkSiteOptions } from "./options.js";
2
+ import { ICON_SPECS } from "./icons.js";
3
+
4
+ /** Builds the generated manifest.webmanifest object — no bundler involved. */
5
+ export function buildManifest(
6
+ options: PatchworkSiteOptions
7
+ ): Record<string, unknown> {
8
+ const title = options.title ?? options.siteName ?? "Patchwork";
9
+ const icons = !options.icons
10
+ ? []
11
+ : ICON_SPECS.filter(
12
+ (spec) =>
13
+ spec.fileName === "apple-touch-icon.png" || spec.manifestPurpose
14
+ ).map((spec) => ({
15
+ src: `/${spec.fileName}`,
16
+ sizes: `${spec.size}x${spec.size}`,
17
+ type: "image/png",
18
+ ...(spec.manifestPurpose ? { purpose: spec.manifestPurpose } : {}),
19
+ }));
20
+
21
+ const manifest: Record<string, unknown> = {
22
+ name: title,
23
+ short_name: options.shortName ?? title,
24
+ description: options.description,
25
+ start_url: "/",
26
+ display: "standalone",
27
+ display_override: ["window-controls-overlay", "standalone"],
28
+ background_color: options.backgroundColor ?? "#ffffff",
29
+ theme_color:
30
+ (typeof options.themeColor === "string"
31
+ ? options.themeColor
32
+ : options.themeColor?.light) ??
33
+ options.backgroundColor ??
34
+ "#ffffff",
35
+ icons,
36
+ };
37
+
38
+ return { ...manifest, ...options.manifest };
39
+ }