@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.
- package/build-head.js +10 -0
- package/dist/client.d.ts +38 -0
- package/dist/global.css +1 -0
- package/dist/head.d.ts +1 -0
- package/dist/head.js +52 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +294 -0
- package/dist/loading.d.ts +5 -0
- package/dist/loading.js +125 -0
- package/dist/repo.d.ts +25 -0
- package/dist/repo.js +108 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.js +132 -0
- package/dist/site-kit/html.d.ts +4 -0
- package/dist/site-kit/html.js +70 -0
- package/dist/site-kit/icons.d.ts +9 -0
- package/dist/site-kit/icons.js +36 -0
- package/dist/site-kit/index.d.ts +15 -0
- package/dist/site-kit/index.js +5 -0
- package/dist/site-kit/manifest.d.ts +3 -0
- package/dist/site-kit/manifest.js +29 -0
- package/dist/site-kit/netlify.d.ts +5 -0
- package/dist/site-kit/netlify.js +27 -0
- package/dist/site-kit/options.d.ts +67 -0
- package/dist/site-kit/options.js +9 -0
- package/dist/site-kit/sync-servers.d.ts +11 -0
- package/dist/site-kit/sync-servers.js +36 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.js +1 -0
- package/dist/vite/config-plugin.d.ts +12 -0
- package/dist/vite/config-plugin.js +70 -0
- package/dist/vite/html-plugin.d.ts +11 -0
- package/dist/vite/html-plugin.js +51 -0
- package/dist/vite/icons.d.ts +4 -0
- package/dist/vite/icons.js +42 -0
- package/dist/vite/importmap-plugin.d.ts +4 -0
- package/dist/vite/importmap-plugin.js +83 -0
- package/dist/vite/manifest-plugin.d.ts +4 -0
- package/dist/vite/manifest-plugin.js +34 -0
- package/dist/vite/netlify-plugin.d.ts +9 -0
- package/dist/vite/netlify-plugin.js +29 -0
- package/dist/vite/patchwork-plugin.d.ts +43 -0
- package/dist/vite/patchwork-plugin.js +40 -0
- package/dist/vite/service-worker-plugin.d.ts +2 -0
- package/dist/vite/service-worker-plugin.js +49 -0
- package/package.json +55 -0
- package/src/client.d.ts +38 -0
- package/src/global.css +1 -0
- package/src/head.ts +14 -0
- package/src/index.ts +469 -0
- package/src/loading.ts +137 -0
- package/src/repo.ts +146 -0
- package/src/router.ts +195 -0
- package/src/site-kit/html.ts +100 -0
- package/src/site-kit/icons.ts +49 -0
- package/src/site-kit/index.ts +22 -0
- package/src/site-kit/manifest.ts +39 -0
- package/src/site-kit/netlify.ts +35 -0
- package/src/site-kit/options.ts +73 -0
- package/src/site-kit/sync-servers.ts +39 -0
- package/src/types.ts +135 -0
- package/src/vite/config-plugin.ts +88 -0
- package/src/vite/html-plugin.ts +60 -0
- package/src/vite/icons.ts +47 -0
- package/src/vite/importmap-plugin.ts +112 -0
- package/src/vite/manifest-plugin.ts +39 -0
- package/src/vite/netlify-plugin.ts +34 -0
- package/src/vite/patchwork-plugin.ts +67 -0
- package/src/vite/service-worker-plugin.ts +54 -0
- 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
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
'"': """,
|
|
10
|
+
"'": "'",
|
|
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
|
+
}
|