@hitslop/runtime 0.1.2 → 0.3.0
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/README.md +11 -2
- package/dist/adapter.d.ts +2 -0
- package/dist/adapter.js +2 -0
- package/dist/bridge-call.d.ts +3 -0
- package/dist/bridge-call.js +15 -0
- package/dist/capture.d.ts +61 -0
- package/dist/capture.js +185 -0
- package/dist/change-events.d.ts +2 -0
- package/dist/change-events.js +20 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +8 -0
- package/dist/host-bridge.d.ts +14 -0
- package/dist/host-bridge.js +73 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +8 -7
- package/dist/json-persister.d.ts +25 -2
- package/dist/json-persister.js +113 -12
- package/dist/latest-task.d.ts +8 -0
- package/dist/latest-task.js +26 -0
- package/dist/lifecycle.d.ts +8 -0
- package/dist/lifecycle.js +13 -0
- package/dist/media-picker.js +4 -1
- package/dist/sql.d.ts +2 -2
- package/dist/theme-reload.d.ts +2 -0
- package/dist/theme-reload.js +27 -0
- package/dist/theme.d.ts +8 -0
- package/dist/theme.js +19 -0
- package/dist/types.d.ts +9 -11
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -14,7 +14,16 @@ ready();
|
|
|
14
14
|
Use `@hitslop/runtime/adapter` only when implementing a framework adapter; it
|
|
15
15
|
exports the shared JSON persister and safe media helpers.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
`JsonPersister` batches idle writes for 150 ms, with a one-second maximum wait.
|
|
18
|
+
Adapters supply detached snapshots and validate values at their I/O boundaries.
|
|
19
|
+
`flush()` bypasses scheduling and drains pending writes; errors retain their
|
|
20
|
+
original identity and code. Register adapter flushers with `registerFlush` so
|
|
21
|
+
the host can await state that has not reached the bridge yet. Keep that
|
|
22
|
+
registration until teardown's final save succeeds.
|
|
23
|
+
The persister's `onError` callback receives `Error | null`; derive display text
|
|
24
|
+
from the error's `message` rather than replacing the error object.
|
|
25
|
+
|
|
26
|
+
Documentation: [Architecture](https://github.com/hitslop/hitslop/blob/master/docs/architecture.md) ·
|
|
27
|
+
[Storage](https://github.com/hitslop/hitslop/blob/master/docs/storage.md)
|
|
19
28
|
|
|
20
29
|
MIT © 2026 hitSlop contributors.
|
package/dist/adapter.d.ts
CHANGED
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
* import `slop`, `ready`, `capture`, and `sql` from `@hitslop/runtime` instead.
|
|
4
4
|
*/
|
|
5
5
|
export { JsonPersister, type JsonSnapshot } from "./json-persister.js";
|
|
6
|
+
export { registerFlush } from "./lifecycle.js";
|
|
7
|
+
export { LatestTask } from "./latest-task.js";
|
|
6
8
|
export { chooseLocalFile, fileToBase64, mediaSourceURL, safeMediaName } from "./media-picker.js";
|
package/dist/adapter.js
CHANGED
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
* import `slop`, `ready`, `capture`, and `sql` from `@hitslop/runtime` instead.
|
|
4
4
|
*/
|
|
5
5
|
export { JsonPersister } from "./json-persister.js";
|
|
6
|
+
export { registerFlush } from "./lifecycle.js";
|
|
7
|
+
export { LatestTask } from "./latest-task.js";
|
|
6
8
|
export { chooseLocalFile, fileToBase64, mediaSourceURL, safeMediaName } from "./media-picker.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type BridgeMethod, type BridgeParams, type BridgeResult } from "@hitslop/schema/bridge";
|
|
2
|
+
/** Bind each request to its response schema at the untyped WebKit boundary. */
|
|
3
|
+
export declare function createBridgeCall(postMessage: (request: unknown) => Promise<unknown>): <M extends BridgeMethod>(method: M, params: NoInfer<BridgeParams<M>>) => Promise<BridgeResult<M>>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BridgeMethods, BridgeReplySchema } from "@hitslop/schema/bridge";
|
|
2
|
+
import { validate } from "@hitslop/schema/validation";
|
|
3
|
+
import { SlopError } from "./errors.js";
|
|
4
|
+
/** Bind each request to its response schema at the untyped WebKit boundary. */
|
|
5
|
+
export function createBridgeCall(postMessage) {
|
|
6
|
+
return async (method, params) => {
|
|
7
|
+
validate(BridgeMethods[method].params, params);
|
|
8
|
+
const reply = validate(BridgeReplySchema, await postMessage({ method, ...params }));
|
|
9
|
+
if (!reply.ok)
|
|
10
|
+
throw new SlopError(reply.error.code, reply.error.message);
|
|
11
|
+
// TS loses the indexed method/result correlation when selecting the schema;
|
|
12
|
+
// the selected validator is the runtime proof for this one boundary cast.
|
|
13
|
+
return validate(BridgeMethods[method].response, reply.value);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type CaptureMode = "preview" | "export" | "icon";
|
|
2
|
+
type Target = {
|
|
3
|
+
element: HTMLElement;
|
|
4
|
+
prepare: () => void | Promise<void>;
|
|
5
|
+
restore: () => void | Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare function createCaptureController(): {
|
|
8
|
+
registerTarget(kind: "icon" | "export", target: Target): () => void;
|
|
9
|
+
onPrepare(handler: (mode: CaptureMode, signal: AbortSignal) => void | Promise<void>): () => void;
|
|
10
|
+
begin(token: string, mode: CaptureMode, options?: {
|
|
11
|
+
blockInteraction?: boolean;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
dedicated: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
measure: (token: string) => {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
dedicated: boolean;
|
|
25
|
+
};
|
|
26
|
+
settle: (token: string) => Promise<void>;
|
|
27
|
+
restore: (token: string) => Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
declare global {
|
|
30
|
+
interface Window {
|
|
31
|
+
__hitslopCapture?: ReturnType<typeof createCaptureController>;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export declare function captureController(): {
|
|
35
|
+
registerTarget(kind: "icon" | "export", target: Target): () => void;
|
|
36
|
+
onPrepare(handler: (mode: CaptureMode, signal: AbortSignal) => void | Promise<void>): () => void;
|
|
37
|
+
begin(token: string, mode: CaptureMode, options?: {
|
|
38
|
+
blockInteraction?: boolean;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
width: number;
|
|
43
|
+
height: number;
|
|
44
|
+
dedicated: boolean;
|
|
45
|
+
}>;
|
|
46
|
+
measure: (token: string) => {
|
|
47
|
+
x: number;
|
|
48
|
+
y: number;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
dedicated: boolean;
|
|
52
|
+
};
|
|
53
|
+
settle: (token: string) => Promise<void>;
|
|
54
|
+
restore: (token: string) => Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
export declare const capture: {
|
|
57
|
+
isRenderer: () => boolean;
|
|
58
|
+
registerTarget: (kind: "icon" | "export", target: Target) => () => void;
|
|
59
|
+
onPrepare: (handler: (mode: CaptureMode, signal: AbortSignal) => void | Promise<void>) => () => void;
|
|
60
|
+
};
|
|
61
|
+
export {};
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const interactionEvents = ["pointerdown", "click", "keydown", "wheel", "touchstart"];
|
|
2
|
+
const timeoutMS = 10_000;
|
|
3
|
+
export function createCaptureController() {
|
|
4
|
+
const targets = new Map();
|
|
5
|
+
const preparations = new Set();
|
|
6
|
+
const states = new Map();
|
|
7
|
+
const bounded = async (work, signal) => {
|
|
8
|
+
let timer;
|
|
9
|
+
const timeout = new Promise((_, reject) => {
|
|
10
|
+
timer = setTimeout(() => reject(new Error("Capture timed out waiting for content, fonts, images, or stable layout")), timeoutMS);
|
|
11
|
+
});
|
|
12
|
+
try {
|
|
13
|
+
signal.throwIfAborted();
|
|
14
|
+
const result = await Promise.race([work, timeout]);
|
|
15
|
+
signal.throwIfAborted();
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const measure = (token) => {
|
|
23
|
+
const target = states.get(token)?.target?.element;
|
|
24
|
+
if (target) {
|
|
25
|
+
const rect = target.getBoundingClientRect();
|
|
26
|
+
return { x: rect.x, y: rect.y, width: Math.ceil(rect.width), height: Math.ceil(Math.max(rect.height, target.scrollHeight)), dedicated: true };
|
|
27
|
+
}
|
|
28
|
+
const root = document.documentElement;
|
|
29
|
+
const body = document.body;
|
|
30
|
+
return { x: 0, y: 0, width: window.innerWidth, height: Math.ceil(Math.max(root.scrollHeight, root.offsetHeight, root.clientHeight, body?.scrollHeight ?? 0, body?.offsetHeight ?? 0)), dedicated: false };
|
|
31
|
+
};
|
|
32
|
+
const settle = async (token) => {
|
|
33
|
+
const state = states.get(token);
|
|
34
|
+
if (!state)
|
|
35
|
+
throw new Error("Capture session is no longer active");
|
|
36
|
+
await bounded((async () => {
|
|
37
|
+
await document.fonts.ready;
|
|
38
|
+
state.controller.signal.throwIfAborted();
|
|
39
|
+
const root = state.target?.element ?? document;
|
|
40
|
+
const images = [...root.querySelectorAll("img")].filter(image => image.getClientRects().length > 0);
|
|
41
|
+
for (const image of images) {
|
|
42
|
+
if (!state.imageLoading.has(image))
|
|
43
|
+
state.imageLoading.set(image, image.getAttribute("loading"));
|
|
44
|
+
image.loading = "eager";
|
|
45
|
+
}
|
|
46
|
+
await Promise.all(images.map(image => image.decode()));
|
|
47
|
+
let previous = "";
|
|
48
|
+
let stable = 0;
|
|
49
|
+
while (stable < 3) {
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
51
|
+
state.controller.signal.throwIfAborted();
|
|
52
|
+
const next = JSON.stringify(measure(token));
|
|
53
|
+
stable = next === previous ? stable + 1 : 0;
|
|
54
|
+
previous = next;
|
|
55
|
+
}
|
|
56
|
+
})(), state.controller.signal);
|
|
57
|
+
};
|
|
58
|
+
const restore = async (token) => {
|
|
59
|
+
const state = states.get(token);
|
|
60
|
+
if (!state)
|
|
61
|
+
return;
|
|
62
|
+
state.controller.abort();
|
|
63
|
+
// Restore host-owned state even when an author's teardown fails.
|
|
64
|
+
try {
|
|
65
|
+
await bounded(Promise.resolve(state.target?.restore()), new AbortController().signal);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
state.target?.element.removeAttribute("data-hitslop-active-target");
|
|
69
|
+
if (state.attribute === null)
|
|
70
|
+
document.documentElement.removeAttribute("data-slop-capture");
|
|
71
|
+
else
|
|
72
|
+
document.documentElement.setAttribute("data-slop-capture", state.attribute);
|
|
73
|
+
state.style.remove();
|
|
74
|
+
for (const [image, loading] of state.imageLoading) {
|
|
75
|
+
if (loading === null)
|
|
76
|
+
image.removeAttribute("loading");
|
|
77
|
+
else
|
|
78
|
+
image.setAttribute("loading", loading);
|
|
79
|
+
}
|
|
80
|
+
for (const event of interactionEvents)
|
|
81
|
+
window.removeEventListener(event, state.block, true);
|
|
82
|
+
state.active?.focus({ preventScroll: true });
|
|
83
|
+
if (state.inputSelection && (state.active instanceof HTMLInputElement || state.active instanceof HTMLTextAreaElement)) {
|
|
84
|
+
const [start, end, direction] = state.inputSelection;
|
|
85
|
+
try {
|
|
86
|
+
state.active.setSelectionRange(start, end, direction ?? undefined);
|
|
87
|
+
}
|
|
88
|
+
catch { /* Non-text inputs. */ }
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const selection = window.getSelection();
|
|
92
|
+
selection?.removeAllRanges();
|
|
93
|
+
for (const range of state.selection) {
|
|
94
|
+
try {
|
|
95
|
+
selection?.addRange(range);
|
|
96
|
+
}
|
|
97
|
+
catch { /* Detached selection. */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const [element, left, top] of state.scroll) {
|
|
101
|
+
element.scrollLeft = left;
|
|
102
|
+
element.scrollTop = top;
|
|
103
|
+
}
|
|
104
|
+
window.scrollTo(...state.windowScroll);
|
|
105
|
+
states.delete(token);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
registerTarget(kind, target) {
|
|
110
|
+
if (targets.has(kind))
|
|
111
|
+
throw new Error(`Expected one ${kind} capture target`);
|
|
112
|
+
targets.set(kind, target);
|
|
113
|
+
return () => { if (targets.get(kind) === target)
|
|
114
|
+
targets.delete(kind); };
|
|
115
|
+
},
|
|
116
|
+
onPrepare(handler) {
|
|
117
|
+
preparations.add(handler);
|
|
118
|
+
return () => { preparations.delete(handler); };
|
|
119
|
+
},
|
|
120
|
+
async begin(token, mode, options = {}) {
|
|
121
|
+
if (states.size)
|
|
122
|
+
throw new Error("Another capture is already in progress");
|
|
123
|
+
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
124
|
+
const selection = window.getSelection();
|
|
125
|
+
const style = document.createElement("style");
|
|
126
|
+
style.textContent = '*{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}html[data-slop-capture="static"] [data-slop-export="hide"]{display:none!important}';
|
|
127
|
+
const state = {
|
|
128
|
+
attribute: document.documentElement.getAttribute("data-slop-capture"), active,
|
|
129
|
+
selection: selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange()) : [],
|
|
130
|
+
scroll: [...document.querySelectorAll("*")].filter(e => e.scrollTop || e.scrollLeft).map(e => [e, e.scrollLeft, e.scrollTop]),
|
|
131
|
+
windowScroll: [window.scrollX, window.scrollY], style,
|
|
132
|
+
block: event => { if (event.isTrusted) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
event.stopImmediatePropagation();
|
|
135
|
+
} },
|
|
136
|
+
controller: new AbortController(),
|
|
137
|
+
imageLoading: new Map(),
|
|
138
|
+
};
|
|
139
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement)
|
|
140
|
+
state.inputSelection = [active.selectionStart, active.selectionEnd, active.selectionDirection];
|
|
141
|
+
states.set(token, state);
|
|
142
|
+
try {
|
|
143
|
+
if (options.blockInteraction !== false)
|
|
144
|
+
for (const event of interactionEvents)
|
|
145
|
+
window.addEventListener(event, state.block, { capture: true, passive: false });
|
|
146
|
+
document.head.append(style);
|
|
147
|
+
active?.blur();
|
|
148
|
+
document.documentElement.setAttribute("data-slop-capture", mode === "icon" ? "icon" : "static");
|
|
149
|
+
state.target = targets.get(mode === "icon" ? "icon" : "export");
|
|
150
|
+
if (state.target) {
|
|
151
|
+
state.target.element.setAttribute("data-hitslop-active-target", "");
|
|
152
|
+
style.textContent += 'html,body{margin:0!important;padding:0!important;width:100%!important;background:transparent!important}body>:not([data-hitslop-active-target]){display:none!important}';
|
|
153
|
+
}
|
|
154
|
+
if (mode === "icon")
|
|
155
|
+
style.textContent += 'html,body{background:transparent!important}';
|
|
156
|
+
await bounded((async () => {
|
|
157
|
+
await state.target?.prepare();
|
|
158
|
+
state.controller.signal.throwIfAborted();
|
|
159
|
+
for (const prepare of preparations) {
|
|
160
|
+
await prepare(mode, state.controller.signal);
|
|
161
|
+
state.controller.signal.throwIfAborted();
|
|
162
|
+
}
|
|
163
|
+
})(), state.controller.signal);
|
|
164
|
+
window.scrollTo(0, 0);
|
|
165
|
+
await settle(token);
|
|
166
|
+
return measure(token);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
await restore(token);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
measure, settle, restore,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export function captureController() {
|
|
177
|
+
return window.__hitslopCapture ??= createCaptureController();
|
|
178
|
+
}
|
|
179
|
+
export const capture = {
|
|
180
|
+
isRenderer: () => typeof document !== "undefined" && document.documentElement.dataset.slopRenderer === "true",
|
|
181
|
+
registerTarget: (kind, target) => captureController().registerTarget(kind, target),
|
|
182
|
+
onPrepare: (handler) => captureController().onPrepare(handler),
|
|
183
|
+
};
|
|
184
|
+
if (typeof window !== "undefined")
|
|
185
|
+
captureController();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ChangeSchema } from "@hitslop/schema/bridge";
|
|
2
|
+
import { validate } from "@hitslop/schema/validation";
|
|
3
|
+
export function dispatchChange(value, listeners, report) {
|
|
4
|
+
let event;
|
|
5
|
+
try {
|
|
6
|
+
event = validate(ChangeSchema, value);
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
report(error);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
for (const callback of [...listeners[event.kind]]) {
|
|
13
|
+
try {
|
|
14
|
+
callback(event);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
report(error);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "./capture.js";
|
|
2
|
+
declare global {
|
|
3
|
+
interface Window {
|
|
4
|
+
webkit: {
|
|
5
|
+
messageHandlers: {
|
|
6
|
+
hitslop: {
|
|
7
|
+
postMessage(request: unknown): Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
__hitslopEmit?: (event: unknown) => void;
|
|
12
|
+
__hitslopReloadTheme?: (revision: string) => void;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import "./capture.js";
|
|
2
|
+
import { assertJSON } from "@hitslop/schema/validation";
|
|
3
|
+
import { createThemeReload } from "./theme-reload.js";
|
|
4
|
+
import { dispatchChange } from "./change-events.js";
|
|
5
|
+
import { createBridgeCall } from "./bridge-call.js";
|
|
6
|
+
const native = window.webkit.messageHandlers.hitslop;
|
|
7
|
+
const invoke = createBridgeCall(request => native.postMessage(request));
|
|
8
|
+
const pending = new Set();
|
|
9
|
+
const listeners = { json: new Set(), sqlite: new Set(), media: new Set() };
|
|
10
|
+
let guestReady = false;
|
|
11
|
+
let readySent = false;
|
|
12
|
+
let readyScheduled = false;
|
|
13
|
+
async function drain() { while (pending.size)
|
|
14
|
+
await Promise.all([...pending]); }
|
|
15
|
+
function call(method, params) {
|
|
16
|
+
const result = invoke(method, params);
|
|
17
|
+
pending.add(result);
|
|
18
|
+
void result.then(() => { pending.delete(result); scheduleReady(); }, () => { pending.delete(result); scheduleReady(); });
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function scheduleReady() {
|
|
22
|
+
if (!guestReady || readySent || readyScheduled || pending.size)
|
|
23
|
+
return;
|
|
24
|
+
readyScheduled = true;
|
|
25
|
+
const finish = () => {
|
|
26
|
+
if (!readyScheduled || readySent)
|
|
27
|
+
return;
|
|
28
|
+
readyScheduled = false;
|
|
29
|
+
if (pending.size)
|
|
30
|
+
return;
|
|
31
|
+
readySent = true;
|
|
32
|
+
void call("ready", {}).catch(error => console.error("hitSlop ready failed", error));
|
|
33
|
+
window.dispatchEvent(new Event("slop:ready"));
|
|
34
|
+
};
|
|
35
|
+
requestAnimationFrame(() => requestAnimationFrame(finish));
|
|
36
|
+
setTimeout(finish, 100);
|
|
37
|
+
}
|
|
38
|
+
const watch = (kind, callback) => {
|
|
39
|
+
listeners[kind].add(callback);
|
|
40
|
+
return () => { listeners[kind].delete(callback); };
|
|
41
|
+
};
|
|
42
|
+
window.__hitslopReloadTheme = createThemeReload(document);
|
|
43
|
+
window.__hitslopEmit = value => dispatchChange(value, listeners, error => console.error("hitSlop change event failed", error));
|
|
44
|
+
const bridge = {
|
|
45
|
+
info: () => call("host.info", {}),
|
|
46
|
+
flush: drain,
|
|
47
|
+
json: {
|
|
48
|
+
open: (value) => { assertJSON(value); return call("json.open", { value }); },
|
|
49
|
+
read: () => call("json.read", {}),
|
|
50
|
+
write: (value, expectedRevision) => { assertJSON(value); return call("json.write", { value, ...(expectedRevision === undefined ? {} : { expectedRevision }) }); },
|
|
51
|
+
onChange: (callback) => watch("json", callback),
|
|
52
|
+
},
|
|
53
|
+
db: {
|
|
54
|
+
query: (sql, parameters = []) => call("sqlite.query", { sql, parameters }),
|
|
55
|
+
execute: (sql, parameters = []) => call("sqlite.execute", { sql, parameters }),
|
|
56
|
+
transaction: (statements) => call("sqlite.transaction", { statements }),
|
|
57
|
+
onChange: (callback) => watch("sqlite", callback),
|
|
58
|
+
},
|
|
59
|
+
media: {
|
|
60
|
+
open: (name) => call("media.open", { name }),
|
|
61
|
+
write: (name, data, mimeType) => call("media.write", { name, data, mimeType }),
|
|
62
|
+
remove: (name) => call("media.remove", { name }),
|
|
63
|
+
onChange: (callback) => watch("media", callback),
|
|
64
|
+
},
|
|
65
|
+
window: {
|
|
66
|
+
resize: (size) => call("window.resize", size),
|
|
67
|
+
drag: async () => { await call("window.drag", {}); },
|
|
68
|
+
},
|
|
69
|
+
ready: () => { guestReady = true; document.documentElement.dataset.hitslopReady = "true"; scheduleReady(); },
|
|
70
|
+
};
|
|
71
|
+
window.slop = Object.freeze(bridge);
|
|
72
|
+
window.addEventListener("error", (event) => { void call("log", { message: event.message }).catch(() => undefined); });
|
|
73
|
+
window.addEventListener("unhandledrejection", (event) => { void call("log", { message: String(event.reason) }).catch(() => undefined); });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import type { SlopHost, SlopStatement, SlopWindowSize } from "./types.ts";
|
|
1
|
+
import type { SlopHost, SlopStatement, SlopWindowSize, SQLValue } from "./types.ts";
|
|
2
2
|
export type { SlopChange, SlopHost, SlopMediaSnapshot, SlopSnapshot, SlopStatement, SlopStoreKind, SlopWindowSize, WindowSlop } from "./types.ts";
|
|
3
3
|
export { sql } from "./sql.js";
|
|
4
|
+
export { flush } from "./lifecycle.js";
|
|
5
|
+
export { SlopError } from "./errors.js";
|
|
6
|
+
export declare function hostInfo(): Promise<{
|
|
7
|
+
protocolVersion: 1;
|
|
8
|
+
capabilities: string[];
|
|
9
|
+
}>;
|
|
4
10
|
export declare function installHost(host: SlopHost): () => void;
|
|
5
11
|
export declare function getHost(): SlopHost;
|
|
6
12
|
export declare const slop: {
|
|
@@ -13,8 +19,8 @@ export declare const slop: {
|
|
|
13
19
|
onChange: (callback: Parameters<SlopHost["watch"]>[1]) => () => void;
|
|
14
20
|
};
|
|
15
21
|
db: {
|
|
16
|
-
query: <T = Record<string, unknown>>(sql: string, params?:
|
|
17
|
-
execute: (sql: string, params?:
|
|
22
|
+
query: <T = Record<string, unknown>>(sql: string, params?: SQLValue[]) => Promise<T[]>;
|
|
23
|
+
execute: (sql: string, params?: SQLValue[]) => Promise<number>;
|
|
18
24
|
transaction: (statements: SlopStatement[]) => Promise<number>;
|
|
19
25
|
onChange: (callback: Parameters<SlopHost["watch"]>[1]) => () => void;
|
|
20
26
|
};
|
|
@@ -34,6 +40,4 @@ export declare const slop: {
|
|
|
34
40
|
};
|
|
35
41
|
};
|
|
36
42
|
export declare function ready(): void;
|
|
37
|
-
export
|
|
38
|
-
isRenderer: () => boolean;
|
|
39
|
-
};
|
|
43
|
+
export { capture, type CaptureMode } from "./capture.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export { sql } from "./sql.js";
|
|
2
|
+
export { flush } from "./lifecycle.js";
|
|
3
|
+
export { SlopError } from "./errors.js";
|
|
4
|
+
export async function hostInfo() {
|
|
5
|
+
if (typeof window === "undefined" || !window.slop?.info)
|
|
6
|
+
throw new Error("Host information is unavailable");
|
|
7
|
+
return window.slop.info();
|
|
8
|
+
}
|
|
2
9
|
let configuredHost;
|
|
3
10
|
const fromBridge = (bridge) => ({
|
|
4
11
|
query: (sql, params = []) => bridge.db.query(sql, params),
|
|
@@ -62,10 +69,4 @@ export const slop = {
|
|
|
62
69
|
};
|
|
63
70
|
export function ready() { if (typeof window !== "undefined")
|
|
64
71
|
window.slop?.ready?.(); }
|
|
65
|
-
|
|
66
|
-
/// hidden WebView with `<html data-slop-renderer="true">` set before any guest
|
|
67
|
-
/// code runs; use `capture.isRenderer()` to mount the `data-slop-render`
|
|
68
|
-
/// icon target only in that pass and keep it out of the interactive app.
|
|
69
|
-
export const capture = {
|
|
70
|
-
isRenderer: () => typeof document !== "undefined" && document.documentElement.dataset.slopRenderer === "true",
|
|
71
|
-
};
|
|
72
|
+
export { capture } from "./capture.js";
|
package/dist/json-persister.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export type JsonSnapshot<T> = {
|
|
|
3
3
|
value: T;
|
|
4
4
|
};
|
|
5
5
|
type Source = "package" | "app" | "external";
|
|
6
|
+
type Scheduler = {
|
|
7
|
+
setTimeout: (callback: () => void, delay: number) => unknown;
|
|
8
|
+
clearTimeout: (handle: unknown) => void;
|
|
9
|
+
};
|
|
6
10
|
type Options<T> = {
|
|
7
11
|
fallback: JsonSnapshot<T>;
|
|
8
12
|
io: {
|
|
@@ -22,7 +26,12 @@ type Options<T> = {
|
|
|
22
26
|
onAdopt: (value: T, source: Source) => void;
|
|
23
27
|
onRevision: (revision: string | null) => void;
|
|
24
28
|
onSource: (source: Source) => void;
|
|
25
|
-
onError: (
|
|
29
|
+
onError: (error: Error | null) => void;
|
|
30
|
+
onStatus?: (state: {
|
|
31
|
+
isDirty: boolean;
|
|
32
|
+
isSaving: boolean;
|
|
33
|
+
}) => void;
|
|
34
|
+
scheduler?: Scheduler;
|
|
26
35
|
};
|
|
27
36
|
export declare class JsonPersister<T> {
|
|
28
37
|
private options;
|
|
@@ -35,14 +44,28 @@ export declare class JsonPersister<T> {
|
|
|
35
44
|
private localVersion;
|
|
36
45
|
private draining;
|
|
37
46
|
private operations;
|
|
47
|
+
private failure;
|
|
48
|
+
private invalid;
|
|
49
|
+
private lastLocalJson;
|
|
50
|
+
private flushing;
|
|
51
|
+
private debounce;
|
|
52
|
+
private maximumWait;
|
|
53
|
+
private readonly scheduler;
|
|
54
|
+
private status;
|
|
55
|
+
flush(): Promise<void>;
|
|
56
|
+
private flushNow;
|
|
38
57
|
constructor(options: Options<T>);
|
|
39
58
|
localChanged(json: string, value: T): void;
|
|
59
|
+
/** An unrepresentable local value must block older queued snapshots too. */
|
|
60
|
+
localInvalid(error: Error): void;
|
|
40
61
|
reload(): Promise<void>;
|
|
41
62
|
externalChanged(eventRevision?: string | null): void;
|
|
63
|
+
private cancelTimers;
|
|
64
|
+
private scheduleDrain;
|
|
42
65
|
private requestDrain;
|
|
43
66
|
private drainLoop;
|
|
44
67
|
private adopt;
|
|
45
68
|
private schedule;
|
|
46
|
-
private
|
|
69
|
+
private asError;
|
|
47
70
|
}
|
|
48
71
|
export {};
|
package/dist/json-persister.js
CHANGED
|
@@ -9,23 +9,83 @@ export class JsonPersister {
|
|
|
9
9
|
localVersion = 0;
|
|
10
10
|
draining = null;
|
|
11
11
|
operations = Promise.resolve(undefined);
|
|
12
|
+
failure = null;
|
|
13
|
+
invalid = null;
|
|
14
|
+
lastLocalJson;
|
|
15
|
+
flushing = null;
|
|
16
|
+
debounce;
|
|
17
|
+
maximumWait;
|
|
18
|
+
scheduler;
|
|
19
|
+
status() { this.options.onStatus?.({ isDirty: this.pending !== null || this.writing || this.invalid !== null, isSaving: this.writing }); }
|
|
20
|
+
flush() {
|
|
21
|
+
if (this.flushing)
|
|
22
|
+
return this.flushing;
|
|
23
|
+
this.cancelTimers();
|
|
24
|
+
this.flushing = this.flushNow().finally(() => { this.flushing = null; });
|
|
25
|
+
return this.flushing;
|
|
26
|
+
}
|
|
27
|
+
async flushNow() {
|
|
28
|
+
const retry = this.stopped;
|
|
29
|
+
await this.operations;
|
|
30
|
+
if (this.invalid)
|
|
31
|
+
throw this.invalid;
|
|
32
|
+
if (this.stopped && !retry && this.failure)
|
|
33
|
+
throw this.failure;
|
|
34
|
+
if (!this.loaded) {
|
|
35
|
+
if (!this.pending)
|
|
36
|
+
return;
|
|
37
|
+
throw this.failure ?? new Error("Document data has not loaded");
|
|
38
|
+
}
|
|
39
|
+
this.stopped = false;
|
|
40
|
+
this.failure = null;
|
|
41
|
+
this.requestDrain();
|
|
42
|
+
while (this.draining)
|
|
43
|
+
await this.draining;
|
|
44
|
+
if (this.failure)
|
|
45
|
+
throw this.failure;
|
|
46
|
+
}
|
|
12
47
|
constructor(options) {
|
|
13
48
|
this.options = options;
|
|
14
49
|
this.lastPersistedJson = options.fallback.json;
|
|
50
|
+
this.lastLocalJson = options.fallback.json;
|
|
51
|
+
this.scheduler = options.scheduler ?? {
|
|
52
|
+
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
|
53
|
+
clearTimeout: handle => clearTimeout(handle),
|
|
54
|
+
};
|
|
15
55
|
}
|
|
16
56
|
localChanged(json, value) {
|
|
57
|
+
if (!this.invalid && json === this.lastLocalJson)
|
|
58
|
+
return;
|
|
17
59
|
this.localVersion += 1;
|
|
60
|
+
this.lastLocalJson = json;
|
|
61
|
+
this.invalid = null;
|
|
62
|
+
this.failure = null;
|
|
18
63
|
// A write already in flight may change what is persisted, so a reversion to
|
|
19
64
|
// the previously persisted value still has to remain queued until it lands.
|
|
20
65
|
if (!this.writing && json === this.lastPersistedJson) {
|
|
21
66
|
this.pending = null;
|
|
67
|
+
this.cancelTimers();
|
|
22
68
|
this.stopped = false;
|
|
23
69
|
this.options.onError(null);
|
|
70
|
+
this.status();
|
|
24
71
|
return;
|
|
25
72
|
}
|
|
26
73
|
this.pending = { json, value };
|
|
27
74
|
this.stopped = false;
|
|
28
|
-
this.
|
|
75
|
+
this.options.onError(null);
|
|
76
|
+
this.status();
|
|
77
|
+
this.scheduleDrain();
|
|
78
|
+
}
|
|
79
|
+
/** An unrepresentable local value must block older queued snapshots too. */
|
|
80
|
+
localInvalid(error) {
|
|
81
|
+
this.localVersion += 1;
|
|
82
|
+
this.invalid = error;
|
|
83
|
+
this.failure = error;
|
|
84
|
+
this.pending = null;
|
|
85
|
+
this.stopped = true;
|
|
86
|
+
this.cancelTimers();
|
|
87
|
+
this.options.onError(error);
|
|
88
|
+
this.status();
|
|
29
89
|
}
|
|
30
90
|
reload() {
|
|
31
91
|
const wasLoaded = this.loaded;
|
|
@@ -35,6 +95,9 @@ export class JsonPersister {
|
|
|
35
95
|
if (wasLoaded) {
|
|
36
96
|
this.pending = null;
|
|
37
97
|
this.stopped = false;
|
|
98
|
+
this.invalid = null;
|
|
99
|
+
this.failure = null;
|
|
100
|
+
this.cancelTimers();
|
|
38
101
|
}
|
|
39
102
|
return this.schedule(async () => {
|
|
40
103
|
try {
|
|
@@ -46,24 +109,31 @@ export class JsonPersister {
|
|
|
46
109
|
this.revision = result.revision;
|
|
47
110
|
this.lastPersistedJson = persistedJson;
|
|
48
111
|
this.options.onRevision(result.revision);
|
|
49
|
-
const local = this.options.getLocal();
|
|
50
112
|
const changedDuringReload = this.localVersion !== requestedAtVersion;
|
|
113
|
+
const local = !wasLoaded || changedDuringReload ? this.options.getLocal() : { json: persistedJson, value: result.value };
|
|
51
114
|
const localWins = wasLoaded
|
|
52
115
|
? changedDuringReload && local.json !== persistedJson
|
|
53
116
|
: local.json !== this.options.fallback.json;
|
|
117
|
+
// A write that was already running can fail after reload was requested.
|
|
118
|
+
// A successful read still recovers the store from that stopped state.
|
|
119
|
+
this.stopped = false;
|
|
120
|
+
this.failure = null;
|
|
121
|
+
this.invalid = null;
|
|
54
122
|
if (localWins) {
|
|
55
123
|
this.pending = local;
|
|
56
|
-
this.
|
|
124
|
+
this.scheduleDrain();
|
|
57
125
|
}
|
|
58
126
|
else {
|
|
59
127
|
this.pending = null;
|
|
128
|
+
this.cancelTimers();
|
|
60
129
|
this.adopt(result.value, result.revision, wasLoaded ? "external" : "package");
|
|
61
130
|
}
|
|
62
131
|
this.options.onError(null);
|
|
132
|
+
this.status();
|
|
63
133
|
}
|
|
64
134
|
catch (error) {
|
|
65
|
-
|
|
66
|
-
this.options.onError(
|
|
135
|
+
this.failure = this.asError(error);
|
|
136
|
+
this.options.onError(this.failure);
|
|
67
137
|
throw error;
|
|
68
138
|
}
|
|
69
139
|
});
|
|
@@ -85,15 +155,36 @@ export class JsonPersister {
|
|
|
85
155
|
this.options.onError(null);
|
|
86
156
|
}
|
|
87
157
|
catch (error) {
|
|
88
|
-
this.options.onError(this.
|
|
158
|
+
this.options.onError(this.asError(error));
|
|
89
159
|
}
|
|
90
160
|
});
|
|
91
161
|
}
|
|
162
|
+
cancelTimers() {
|
|
163
|
+
if (this.debounce !== undefined)
|
|
164
|
+
this.scheduler.clearTimeout(this.debounce);
|
|
165
|
+
if (this.maximumWait !== undefined)
|
|
166
|
+
this.scheduler.clearTimeout(this.maximumWait);
|
|
167
|
+
this.debounce = this.maximumWait = undefined;
|
|
168
|
+
}
|
|
169
|
+
scheduleDrain() {
|
|
170
|
+
if (!this.loaded || this.stopped || !this.pending || this.draining)
|
|
171
|
+
return;
|
|
172
|
+
if (this.flushing) {
|
|
173
|
+
this.requestDrain();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const drain = () => { this.cancelTimers(); this.requestDrain(); };
|
|
177
|
+
if (this.debounce !== undefined)
|
|
178
|
+
this.scheduler.clearTimeout(this.debounce);
|
|
179
|
+
this.debounce = this.scheduler.setTimeout(drain, 150);
|
|
180
|
+
this.maximumWait ??= this.scheduler.setTimeout(drain, 1_000);
|
|
181
|
+
}
|
|
92
182
|
requestDrain() {
|
|
93
183
|
if (this.draining || !this.loaded || this.stopped || !this.pending)
|
|
94
184
|
return;
|
|
95
185
|
this.draining = this.schedule(() => this.drainLoop()).finally(() => {
|
|
96
186
|
this.draining = null;
|
|
187
|
+
this.status();
|
|
97
188
|
if (this.loaded && this.pending && !this.stopped)
|
|
98
189
|
this.requestDrain();
|
|
99
190
|
});
|
|
@@ -102,6 +193,8 @@ export class JsonPersister {
|
|
|
102
193
|
if (!this.loaded || this.stopped)
|
|
103
194
|
return;
|
|
104
195
|
this.writing = true;
|
|
196
|
+
this.status();
|
|
197
|
+
let conflicts = 0;
|
|
105
198
|
try {
|
|
106
199
|
while (this.pending && !this.stopped) {
|
|
107
200
|
const snapshot = this.pending;
|
|
@@ -116,10 +209,14 @@ export class JsonPersister {
|
|
|
116
209
|
this.lastPersistedJson = snapshot.json;
|
|
117
210
|
this.options.onRevision(result.revision);
|
|
118
211
|
this.options.onSource("app");
|
|
119
|
-
this.
|
|
212
|
+
if (!this.invalid) {
|
|
213
|
+
this.options.onError(null);
|
|
214
|
+
this.failure = null;
|
|
215
|
+
}
|
|
216
|
+
conflicts = 0;
|
|
120
217
|
}
|
|
121
218
|
catch (error) {
|
|
122
|
-
if (
|
|
219
|
+
if (error && typeof error === "object" && "code" in error && error.code === "revision_conflict" && conflicts++ === 0) {
|
|
123
220
|
try {
|
|
124
221
|
const result = await this.options.io.read();
|
|
125
222
|
this.revision = result.revision;
|
|
@@ -131,7 +228,8 @@ export class JsonPersister {
|
|
|
131
228
|
catch (readError) {
|
|
132
229
|
this.pending ??= snapshot;
|
|
133
230
|
this.stopped = true;
|
|
134
|
-
this.
|
|
231
|
+
this.failure = this.asError(readError);
|
|
232
|
+
this.options.onError(this.failure);
|
|
135
233
|
continue;
|
|
136
234
|
}
|
|
137
235
|
}
|
|
@@ -139,17 +237,20 @@ export class JsonPersister {
|
|
|
139
237
|
// and re-arms persistence without losing any fields.
|
|
140
238
|
this.pending ??= snapshot;
|
|
141
239
|
this.stopped = true;
|
|
142
|
-
this.
|
|
240
|
+
this.failure = this.asError(error);
|
|
241
|
+
this.options.onError(this.failure);
|
|
143
242
|
}
|
|
144
243
|
}
|
|
145
244
|
}
|
|
146
245
|
finally {
|
|
147
246
|
this.writing = false;
|
|
247
|
+
this.status();
|
|
148
248
|
}
|
|
149
249
|
}
|
|
150
250
|
adopt(value, revision, source) {
|
|
151
251
|
this.revision = revision;
|
|
152
252
|
this.lastPersistedJson = JSON.stringify(value);
|
|
253
|
+
this.lastLocalJson = this.lastPersistedJson;
|
|
153
254
|
this.options.onRevision(revision);
|
|
154
255
|
this.options.onAdopt(value, source);
|
|
155
256
|
}
|
|
@@ -158,7 +259,7 @@ export class JsonPersister {
|
|
|
158
259
|
this.operations = scheduled.catch(() => undefined);
|
|
159
260
|
return scheduled;
|
|
160
261
|
}
|
|
161
|
-
|
|
162
|
-
return error instanceof Error ? error
|
|
262
|
+
asError(error) {
|
|
263
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
163
264
|
}
|
|
164
265
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Prevent a slow response from replacing a newer result or a disposed view. */
|
|
2
|
+
export declare class LatestTask {
|
|
3
|
+
private generation;
|
|
4
|
+
private disposed;
|
|
5
|
+
run<T>(operation: () => Promise<T>, adopt: (value: T) => void, fail: (error: unknown) => void, finish: () => void): Promise<void>;
|
|
6
|
+
invalidate(): void;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Prevent a slow response from replacing a newer result or a disposed view. */
|
|
2
|
+
export class LatestTask {
|
|
3
|
+
generation = 0;
|
|
4
|
+
disposed = false;
|
|
5
|
+
async run(operation, adopt, fail, finish) {
|
|
6
|
+
if (this.disposed)
|
|
7
|
+
return;
|
|
8
|
+
const generation = ++this.generation;
|
|
9
|
+
const current = () => !this.disposed && generation === this.generation;
|
|
10
|
+
try {
|
|
11
|
+
const value = await operation();
|
|
12
|
+
if (current())
|
|
13
|
+
adopt(value);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (current())
|
|
17
|
+
fail(error);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
if (current())
|
|
21
|
+
finish();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
invalidate() { this.generation += 1; }
|
|
25
|
+
dispose() { this.disposed = true; this.invalidate(); }
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Adapters register here so a native close/duplicate can await reactive writes. */
|
|
2
|
+
export declare function registerFlush(handler: () => Promise<void>): () => void;
|
|
3
|
+
export declare function flush(): Promise<void>;
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
__hitslopFlush?: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const flushers = new Set();
|
|
2
|
+
/** Adapters register here so a native close/duplicate can await reactive writes. */
|
|
3
|
+
export function registerFlush(handler) {
|
|
4
|
+
flushers.add(handler);
|
|
5
|
+
return () => { flushers.delete(handler); };
|
|
6
|
+
}
|
|
7
|
+
export async function flush() {
|
|
8
|
+
await Promise.all([...flushers].map((handler) => handler()));
|
|
9
|
+
if (typeof window !== "undefined")
|
|
10
|
+
await window.slop?.flush?.();
|
|
11
|
+
}
|
|
12
|
+
if (typeof window !== "undefined")
|
|
13
|
+
window.__hitslopFlush = flush;
|
package/dist/media-picker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Framework-agnostic helpers shared by the media store adapters
|
|
2
|
-
// (@hitslop/svelte
|
|
2
|
+
// (such as @hitslop/svelte). No reactivity in here: adapters own state.
|
|
3
3
|
export const safeMediaName = (name, label = "Media") => {
|
|
4
4
|
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name))
|
|
5
5
|
throw new Error(`${label} store names must use lowercase letters, numbers, and hyphens`);
|
|
@@ -7,6 +7,8 @@ export const safeMediaName = (name, label = "Media") => {
|
|
|
7
7
|
};
|
|
8
8
|
/** Reads a File into the base64 payload expected by `slop.media.write`. */
|
|
9
9
|
export const fileToBase64 = async (file, label = "file") => {
|
|
10
|
+
if (file.size > 25 * 1024 * 1024)
|
|
11
|
+
throw new Error("Choose a file no larger than 25 MiB");
|
|
10
12
|
let bytes;
|
|
11
13
|
try {
|
|
12
14
|
bytes = new Uint8Array(await file.arrayBuffer());
|
|
@@ -33,6 +35,7 @@ export const chooseLocalFile = (accept, onFile) => {
|
|
|
33
35
|
if (file)
|
|
34
36
|
onFile(file);
|
|
35
37
|
}, { once: true });
|
|
38
|
+
input.addEventListener("cancel", () => input.remove(), { once: true });
|
|
36
39
|
document.body.append(input);
|
|
37
40
|
input.click();
|
|
38
41
|
};
|
package/dist/sql.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { SlopStatement } from "./types.ts";
|
|
1
|
+
import type { SlopStatement, SQLValue } from "./types.ts";
|
|
2
2
|
/** Tagged template that turns interpolated values into positional `?` parameters. */
|
|
3
|
-
export declare function sql(strings: TemplateStringsArray, ...values:
|
|
3
|
+
export declare function sql(strings: TemplateStringsArray, ...values: SQLValue[]): SlopStatement;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Keep the last loaded theme until its latest replacement has loaded. */
|
|
2
|
+
export function createThemeReload(document) {
|
|
3
|
+
let pending;
|
|
4
|
+
return (revision) => {
|
|
5
|
+
pending?.remove();
|
|
6
|
+
pending = undefined;
|
|
7
|
+
const current = document.querySelector('link[data-hitslop-theme]');
|
|
8
|
+
if (!current)
|
|
9
|
+
return;
|
|
10
|
+
const next = current.cloneNode();
|
|
11
|
+
pending = next;
|
|
12
|
+
next.href = `theme.css?revision=${encodeURIComponent(revision)}`;
|
|
13
|
+
next.onload = () => {
|
|
14
|
+
if (pending !== next)
|
|
15
|
+
return;
|
|
16
|
+
pending = undefined;
|
|
17
|
+
current.remove();
|
|
18
|
+
};
|
|
19
|
+
next.onerror = () => {
|
|
20
|
+
if (pending !== next)
|
|
21
|
+
return;
|
|
22
|
+
pending = undefined;
|
|
23
|
+
next.remove();
|
|
24
|
+
};
|
|
25
|
+
current.after(next);
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Plain CSS variables: usable by vanilla-extract or any other styling system. */
|
|
2
|
+
export declare function defineTheme<const Tokens extends Record<string, string>>(tokens: Tokens): Readonly<{
|
|
3
|
+
vars: Readonly<{ readonly [Key in keyof Tokens]: `var(--slop-${string})`; }>;
|
|
4
|
+
css: `:root {
|
|
5
|
+
${string}
|
|
6
|
+
}
|
|
7
|
+
`;
|
|
8
|
+
}>;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Plain CSS variables: usable by vanilla-extract or any other styling system. */
|
|
2
|
+
export function defineTheme(tokens) {
|
|
3
|
+
const vars = {};
|
|
4
|
+
const declarations = [];
|
|
5
|
+
const names = new Set();
|
|
6
|
+
for (const [key, value] of Object.entries(tokens)) {
|
|
7
|
+
const name = `--slop-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
|
8
|
+
if (!/^--slop-[a-z][a-z0-9-]*$/.test(name) || names.has(name))
|
|
9
|
+
throw new Error(`Invalid or duplicate theme token: ${key}`);
|
|
10
|
+
if (!value.trim() || /[{};]|\/\*|<\/style/i.test(value))
|
|
11
|
+
throw new Error(`Invalid theme value for ${key}`);
|
|
12
|
+
names.add(name);
|
|
13
|
+
Object.assign(vars, { [key]: `var(${name})` });
|
|
14
|
+
declarations.push(` ${name}: ${value};`);
|
|
15
|
+
}
|
|
16
|
+
if (!names.size)
|
|
17
|
+
throw new Error("A theme needs at least one token");
|
|
18
|
+
return Object.freeze({ vars: Object.freeze(vars), css: `:root {\n${declarations.join("\n")}\n}\n` });
|
|
19
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
export type SlopStoreKind = "json" | "sqlite" | "media";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
source: "app" | "external" | "dev";
|
|
5
|
-
revision?: string | null;
|
|
6
|
-
sequence?: number;
|
|
7
|
-
};
|
|
2
|
+
import type { HostInfo, SlopChange, SQLValue } from "@hitslop/schema/bridge";
|
|
3
|
+
export type { HostInfo, SlopChange, SQLValue } from "@hitslop/schema/bridge";
|
|
8
4
|
export type SlopStatement = {
|
|
9
5
|
sql: string;
|
|
10
|
-
parameters?:
|
|
6
|
+
parameters?: SQLValue[];
|
|
11
7
|
};
|
|
12
8
|
export type SlopSnapshot<T> = {
|
|
13
9
|
value: T;
|
|
@@ -22,8 +18,8 @@ export type SlopWindowSize = {
|
|
|
22
18
|
height: number;
|
|
23
19
|
};
|
|
24
20
|
export interface SlopHost {
|
|
25
|
-
query<T = Record<string, unknown>>(sql: string, params?:
|
|
26
|
-
execute(sql: string, params?:
|
|
21
|
+
query<T = Record<string, unknown>>(sql: string, params?: SQLValue[]): Promise<T[]>;
|
|
22
|
+
execute(sql: string, params?: SQLValue[]): Promise<number>;
|
|
27
23
|
transaction(statements: SlopStatement[]): Promise<number>;
|
|
28
24
|
jsonOpen<T>(initialValue: T): Promise<SlopSnapshot<T>>;
|
|
29
25
|
jsonRead<T>(): Promise<SlopSnapshot<T>>;
|
|
@@ -42,9 +38,10 @@ export interface SlopHost {
|
|
|
42
38
|
watch(kind: SlopStoreKind, callback: (event: SlopChange) => void): () => void;
|
|
43
39
|
}
|
|
44
40
|
export type WindowSlop = {
|
|
41
|
+
info?: () => Promise<HostInfo>;
|
|
45
42
|
db: {
|
|
46
|
-
query: (sql: string, parameters?:
|
|
47
|
-
execute: (sql: string, parameters?:
|
|
43
|
+
query: (sql: string, parameters?: SQLValue[]) => Promise<unknown[]>;
|
|
44
|
+
execute: (sql: string, parameters?: SQLValue[]) => Promise<number>;
|
|
48
45
|
transaction: (statements: SlopStatement[]) => Promise<number>;
|
|
49
46
|
onChange: (callback: (event: SlopChange) => void) => () => void;
|
|
50
47
|
};
|
|
@@ -71,6 +68,7 @@ export type WindowSlop = {
|
|
|
71
68
|
drag?: () => Promise<void>;
|
|
72
69
|
};
|
|
73
70
|
ready?: () => void;
|
|
71
|
+
flush?: () => Promise<void>;
|
|
74
72
|
};
|
|
75
73
|
declare global {
|
|
76
74
|
interface Window {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hitslop/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Framework-neutral browser bridge for hitSlop documents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/hitslop/hitslop.git", "directory": "packages/runtime" },
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
"files": ["dist"],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./dist/index.js",
|
|
15
|
-
"./adapter": "./dist/adapter.js"
|
|
15
|
+
"./adapter": "./dist/adapter.js",
|
|
16
|
+
"./theme": "./dist/theme.js"
|
|
16
17
|
},
|
|
17
18
|
"scripts": {
|
|
18
19
|
"build": "bun ../../scripts/clean-dist.ts && tsc -p tsconfig.build.json",
|
|
19
20
|
"check": "tsc -p tsconfig.json",
|
|
20
21
|
"test": "bun test"
|
|
21
22
|
},
|
|
23
|
+
"dependencies": { "@hitslop/schema": "^0.3.0" },
|
|
22
24
|
"devDependencies": { "typescript": "^7.0.2" }
|
|
23
25
|
}
|