@malloydata/malloyyo 0.2.23 → 0.2.25
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/dist/frame-inpage-entry.tsx +23 -3
- package/dist/frame-runtime/index.ts +20 -2
- package/dist/frame-runtime/runtime.tsx +128 -19
- package/dist/frame-wasm-entry.tsx +36 -9
- package/dist/index.js +191 -51
- package/dist/shared/givens-url.ts +49 -2
- package/dist/templates/skills/malloyyo-auto-update/SKILL.md +117 -0
- package/dist/templates/skills/malloyyo-data-site/SKILL.md +174 -0
- package/dist/templates/skills/malloyyo-data-site/reference/data-to-parquet.md +109 -0
- package/dist/templates/skills/malloyyo-data-site/reference/publish-to-github-pages.md +61 -0
- package/package.json +1 -1
|
@@ -10,14 +10,28 @@ import { mountInPage } from "./frame-runtime/index";
|
|
|
10
10
|
const info = window.__DASHBOARD__ || {};
|
|
11
11
|
const name = info.name;
|
|
12
12
|
|
|
13
|
-
// Reflect committed givens into the shell URL as `?d=<name>&$NAME
|
|
14
|
-
|
|
13
|
+
// Reflect committed givens into the shell URL as `?d=<name>&$NAME=…`, plus any
|
|
14
|
+
// `~key` view-state (useUrlState). A tag-only dashboard runs no custom code, so
|
|
15
|
+
// there's normally no view-state here — but the two namespaces share one query
|
|
16
|
+
// string, so each write re-emits the other's params rather than erasing them.
|
|
17
|
+
let lastGivens = {};
|
|
18
|
+
// Seeded (`~key` -> value, prefix stripped) from what the shell put in the page,
|
|
19
|
+
// so a link's view-state survives the very first givens sync.
|
|
20
|
+
let lastUrlState = Object.fromEntries(
|
|
21
|
+
Object.entries(window.__INITIAL_URLSTATE__ || {}).map(([k, v]) => [k[0] === "~" ? k.slice(1) : k, v]),
|
|
22
|
+
);
|
|
23
|
+
const shareUrl = (dashboard) => {
|
|
15
24
|
const u = new URL(location.href);
|
|
16
25
|
u.search = "";
|
|
17
26
|
u.searchParams.set("d", dashboard);
|
|
18
|
-
for (const [k, v] of Object.entries(
|
|
27
|
+
for (const [k, v] of Object.entries(lastGivens)) if (v != null && String(v) !== "") u.searchParams.set("$" + k, String(v));
|
|
28
|
+
for (const [k, v] of Object.entries(lastUrlState)) if (v != null) u.searchParams.set("~" + k, String(v));
|
|
19
29
|
return u.pathname + u.search;
|
|
20
30
|
};
|
|
31
|
+
const givensToUrl = (dashboard, givens) => {
|
|
32
|
+
lastGivens = givens;
|
|
33
|
+
return shareUrl(dashboard);
|
|
34
|
+
};
|
|
21
35
|
|
|
22
36
|
mountInPage({
|
|
23
37
|
root: document.getElementById("root"),
|
|
@@ -32,7 +46,13 @@ mountInPage({
|
|
|
32
46
|
.then((r) => r.json())
|
|
33
47
|
.catch((e) => ({ ok: false, problems: [{ message: String(e) }] })),
|
|
34
48
|
navigate: (dashboard, givens) => {
|
|
49
|
+
// Leaving this dashboard: carry the drilled givens, drop the view-state.
|
|
50
|
+
lastUrlState = {};
|
|
35
51
|
location.href = givensToUrl(dashboard, givens);
|
|
36
52
|
},
|
|
37
53
|
syncGivens: (givens) => history.replaceState(null, "", givensToUrl(name, givens)),
|
|
54
|
+
syncUrlState: (state) => {
|
|
55
|
+
lastUrlState = state;
|
|
56
|
+
history.replaceState(null, "", shareUrl(name));
|
|
57
|
+
},
|
|
38
58
|
});
|
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
useGiven,
|
|
19
19
|
useOptions,
|
|
20
20
|
useQuery,
|
|
21
|
+
useUrlState,
|
|
21
22
|
mount,
|
|
22
23
|
setHost,
|
|
23
24
|
dashboardInfo,
|
|
@@ -59,8 +60,17 @@ export function mountInPage(opts: {
|
|
|
59
60
|
run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
|
|
60
61
|
navigate: (dashboard: string, givens: Record<string, unknown>) => void;
|
|
61
62
|
syncGivens: (givens: Record<string, unknown>) => void;
|
|
63
|
+
/** Mirror useUrlState view-state into the URL as `~key` params. Optional: a
|
|
64
|
+
host that omits it simply has no shareable view-state (tag-only
|
|
65
|
+
dashboards run no custom code, so none exists). */
|
|
66
|
+
syncUrlState?: (state: Record<string, string>) => void;
|
|
62
67
|
}): { unmount: () => void } {
|
|
63
|
-
setHost({
|
|
68
|
+
setHost({
|
|
69
|
+
run: opts.run,
|
|
70
|
+
navigate: opts.navigate,
|
|
71
|
+
syncGivens: opts.syncGivens,
|
|
72
|
+
syncUrlState: opts.syncUrlState,
|
|
73
|
+
});
|
|
64
74
|
// bodyReset:false — the dashboard is one element in the app shell, so it must
|
|
65
75
|
// not restyle <body> (the iframe host DOES own the whole document, so it keeps
|
|
66
76
|
// the reset). Returns the React root so the caller can unmount() on teardown.
|
|
@@ -80,8 +90,16 @@ export function mountStatic(
|
|
|
80
90
|
run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
|
|
81
91
|
navigate: (dashboard: string, givens: Record<string, unknown>) => void;
|
|
82
92
|
syncGivens: (givens: Record<string, unknown>) => void;
|
|
93
|
+
/** Mirror useUrlState view-state into the URL as `~key` params — a static
|
|
94
|
+
site's custom components DO use it, so this host should supply it. */
|
|
95
|
+
syncUrlState?: (state: Record<string, string>) => void;
|
|
83
96
|
},
|
|
84
97
|
): { unmount: () => void } {
|
|
85
|
-
setHost({
|
|
98
|
+
setHost({
|
|
99
|
+
run: opts.run,
|
|
100
|
+
navigate: opts.navigate,
|
|
101
|
+
syncGivens: opts.syncGivens,
|
|
102
|
+
syncUrlState: opts.syncUrlState,
|
|
103
|
+
});
|
|
86
104
|
return mount(Dashboard ?? DefaultDashboard, WIDGETS, opts.root, { bodyReset: false });
|
|
87
105
|
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// and posts results back.
|
|
10
10
|
//
|
|
11
11
|
// Injected frame globals (read lazily — script order differs between hosts):
|
|
12
|
-
// window.__DASHBOARD__
|
|
13
|
-
// window.__GIVENS__
|
|
14
|
-
// window.__INITIAL_GIVENS__
|
|
12
|
+
// window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
|
|
13
|
+
// window.__GIVENS__ given specs introspected from the model's given: decls
|
|
14
|
+
// window.__INITIAL_GIVENS__ URL-seeded given values ($-prefixed) for shareable links
|
|
15
|
+
// window.__INITIAL_URLSTATE__ URL-seeded useUrlState view-state (~-prefixed)
|
|
15
16
|
import React, {
|
|
16
17
|
createContext,
|
|
17
18
|
useCallback,
|
|
@@ -35,9 +36,10 @@ export const dashboardInfo = () => window.__DASHBOARD__ || {};
|
|
|
35
36
|
export const givenSpecs = () => window.__GIVENS__ || [];
|
|
36
37
|
|
|
37
38
|
// ── host bridge ─────────────────────────────────────────────────────
|
|
38
|
-
// The runtime performs
|
|
39
|
-
// governed query, navigate to a sibling dashboard,
|
|
40
|
-
//
|
|
39
|
+
// The runtime performs four privileged actions it can't do itself: run a
|
|
40
|
+
// governed query, navigate to a sibling dashboard, mirror the committed givens
|
|
41
|
+
// into the shareable URL, and mirror a custom component's view-state
|
|
42
|
+
// (useUrlState) into it too. It delegates all of them to a HOST. Two hosts
|
|
41
43
|
// implement the contract:
|
|
42
44
|
// • postMessage host (default) — the sandboxed iframe: posts to the trusted
|
|
43
45
|
// parent shell, which holds the runner. This is the CUSTOM-dashboard path.
|
|
@@ -74,6 +76,9 @@ let host = {
|
|
|
74
76
|
syncGivens(givens) {
|
|
75
77
|
parent.postMessage({ type: "givens", givens }, "*");
|
|
76
78
|
},
|
|
79
|
+
syncUrlState(state) {
|
|
80
|
+
parent.postMessage({ type: "urlstate", state }, "*");
|
|
81
|
+
},
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
/** Swap the host — a tag-only dashboard mounted in the trusted page (mountInPage)
|
|
@@ -133,6 +138,74 @@ export function useGiven(name) {
|
|
|
133
138
|
};
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
// ── view-state in the URL (useUrlState) ─────────────────────────────
|
|
142
|
+
// Givens are the GOVERNED QUERY CONTRACT: declared in the model, filter-typed,
|
|
143
|
+
// they drive the auto-rendered controls and are visible to MCP. A custom
|
|
144
|
+
// component that computes its query inputs in JS has other state that belongs
|
|
145
|
+
// in the URL but is not a query parameter — an anagram rack, a Scrabble board,
|
|
146
|
+
// a "reuse letters" checkbox. Stuffing those into givens overloads what a given
|
|
147
|
+
// is (and they aren't in givenSpecs(), so they never rehydrate).
|
|
148
|
+
//
|
|
149
|
+
// useUrlState is that second channel: a useState twin whose value lives in the
|
|
150
|
+
// URL under a `~key` param, on the same parent<->frame transport as givens. The
|
|
151
|
+
// component runs in a sandboxed cross-origin iframe and CANNOT touch the
|
|
152
|
+
// top-level URL itself, so only the runtime + trusted parent can do this.
|
|
153
|
+
const UrlStateCtx = createContext(null);
|
|
154
|
+
|
|
155
|
+
/** value -> URL string. Strings pass through verbatim (a readable `~rack=cats`
|
|
156
|
+
beats `~rack=%22cats%22`); numbers/booleans stringify; everything else is
|
|
157
|
+
JSON. */
|
|
158
|
+
export function serializeUrlState(v) {
|
|
159
|
+
if (typeof v === "string") return v;
|
|
160
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
161
|
+
return JSON.stringify(v ?? null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** URL string -> value, typed by the `initial` the caller declared. A value the
|
|
165
|
+
URL can't produce (a bad number, malformed JSON) falls back to `initial`
|
|
166
|
+
rather than throwing — a hand-edited link must not white-screen a page. */
|
|
167
|
+
export function deserializeUrlState(raw, initial) {
|
|
168
|
+
if (typeof initial === "string") return raw;
|
|
169
|
+
if (typeof initial === "number") {
|
|
170
|
+
const n = Number(raw);
|
|
171
|
+
return Number.isFinite(n) ? n : initial;
|
|
172
|
+
}
|
|
173
|
+
if (typeof initial === "boolean") return raw === "true" || raw === "1";
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(raw);
|
|
176
|
+
} catch {
|
|
177
|
+
return initial;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A `useState` twin backed by the browser URL — the dashboard-runtime
|
|
182
|
+
equivalent of useSearchParams, abstracted over the iframe boundary:
|
|
183
|
+
|
|
184
|
+
const [rack, setRack] = useUrlState("rack", ""); // string
|
|
185
|
+
const [reuse, setReuse] = useUrlState("reuse", false); // boolean
|
|
186
|
+
const [board, setBoard] = useUrlState("board", "........");
|
|
187
|
+
|
|
188
|
+
The value is read from `?~rack=…` on load (else `initial`), and every change
|
|
189
|
+
is mirrored back (debounced, replaceState — no history spam), so the URL is
|
|
190
|
+
always shareable/bookmarkable. `setValue` takes a value or an updater fn.
|
|
191
|
+
Typing follows `initial` (string / number / boolean / JSON-serializable);
|
|
192
|
+
it has nothing to do with Malloy given types. A value equal to `initial` is
|
|
193
|
+
dropped from the URL, so defaults never clutter the link. */
|
|
194
|
+
export function useUrlState(key, initial) {
|
|
195
|
+
const ctx = useContext(UrlStateCtx);
|
|
196
|
+
if (!ctx) throw new Error("useUrlState must run inside the dashboard runtime");
|
|
197
|
+
const { state, setKey } = ctx;
|
|
198
|
+
const raw = state[key];
|
|
199
|
+
const value = raw === undefined ? initial : deserializeUrlState(raw, initial);
|
|
200
|
+
// `initial` is usually an inline literal (a fresh array each render), so it
|
|
201
|
+
// can't be a dep — capture it in a ref the setter reads. The default a key is
|
|
202
|
+
// declared with doesn't change over a component's life.
|
|
203
|
+
const initialRef = useRef(initial);
|
|
204
|
+
initialRef.current = initial;
|
|
205
|
+
const set = useCallback((next) => setKey(key, next, initialRef.current), [key, setKey]);
|
|
206
|
+
return [value, set];
|
|
207
|
+
}
|
|
208
|
+
|
|
136
209
|
// ── queries as hooks ────────────────────────────────────────────────
|
|
137
210
|
/** Run a query and get plain data back: { rows, result, loading, error }.
|
|
138
211
|
req: { query?: string, malloy?: string, givens?: object }. For charting
|
|
@@ -821,25 +894,61 @@ function Root({ Dashboard, extraProps }) {
|
|
|
821
894
|
useEffect(() => {
|
|
822
895
|
host.syncGivens(committed);
|
|
823
896
|
}, [committed]);
|
|
897
|
+
|
|
898
|
+
// useUrlState's store: `~key` -> serialized string, seeded from the URL. It
|
|
899
|
+
// starts as the WHOLE seed (not just keys some hook has claimed) so params
|
|
900
|
+
// belonging to a component that hasn't mounted yet survive the first sync.
|
|
901
|
+
const urlSeed = useMemo(() => {
|
|
902
|
+
const s = {};
|
|
903
|
+
for (const [k, v] of Object.entries(window.__INITIAL_URLSTATE__ || {})) {
|
|
904
|
+
s[k[0] === "~" ? k.slice(1) : k] = v;
|
|
905
|
+
}
|
|
906
|
+
return s;
|
|
907
|
+
}, []);
|
|
908
|
+
const [urlState, setUrlState] = useState(urlSeed);
|
|
909
|
+
const setUrlKey = useCallback((key, next, initial) => {
|
|
910
|
+
setUrlState((prev) => {
|
|
911
|
+
const cur = prev[key] === undefined ? initial : deserializeUrlState(prev[key], initial);
|
|
912
|
+
const v = typeof next === "function" ? next(cur) : next;
|
|
913
|
+
const s = serializeUrlState(v);
|
|
914
|
+
if (s === prev[key]) return prev; // no-op set: don't re-render or re-sync
|
|
915
|
+
const out = { ...prev };
|
|
916
|
+
// A value back at its default is absent, not empty — keeps links clean.
|
|
917
|
+
if (s === serializeUrlState(initial)) delete out[key];
|
|
918
|
+
else out[key] = s;
|
|
919
|
+
return out;
|
|
920
|
+
});
|
|
921
|
+
}, []);
|
|
922
|
+
// Debounced: view-state is typed into (a rack, a board cell), and Safari
|
|
923
|
+
// throttles replaceState. Givens commit discretely, so they sync immediately.
|
|
924
|
+
useEffect(() => {
|
|
925
|
+
const t = setTimeout(() => host.syncUrlState?.(urlState), 150);
|
|
926
|
+
return () => clearTimeout(t);
|
|
927
|
+
}, [urlState]);
|
|
928
|
+
const urlCtx = useMemo(() => ({ state: urlState, setKey: setUrlKey }), [urlState, setUrlKey]);
|
|
929
|
+
|
|
824
930
|
const ctx = useMemo(
|
|
825
931
|
() => ({ givens: committed, draft, setGiven, apply, reset, dirty, autorun }),
|
|
826
932
|
[committed, draft, setGiven, apply, reset, dirty, autorun],
|
|
827
933
|
);
|
|
828
934
|
return (
|
|
829
935
|
<Ctx.Provider value={ctx}>
|
|
830
|
-
<
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
936
|
+
<UrlStateCtx.Provider value={urlCtx}>
|
|
937
|
+
<Dashboard
|
|
938
|
+
dashboard={dashboardInfo()}
|
|
939
|
+
givenSpecs={givenSpecs()}
|
|
940
|
+
givens={committed}
|
|
941
|
+
setGiven={setGiven}
|
|
942
|
+
Panel={Panel}
|
|
943
|
+
filters={filters}
|
|
944
|
+
useGiven={useGiven}
|
|
945
|
+
useOptions={useOptions}
|
|
946
|
+
useQuery={useQuery}
|
|
947
|
+
useUrlState={useUrlState}
|
|
948
|
+
runData={runData}
|
|
949
|
+
{...extraProps}
|
|
950
|
+
/>
|
|
951
|
+
</UrlStateCtx.Provider>
|
|
843
952
|
</Ctx.Provider>
|
|
844
953
|
);
|
|
845
954
|
}
|
|
@@ -16,7 +16,7 @@ import * as duckdb from "@duckdb/duckdb-wasm";
|
|
|
16
16
|
import { DuckDBWASMConnection } from "@malloydata/db-duckdb/wasm";
|
|
17
17
|
import { API, SingleConnectionRuntime } from "@malloydata/malloy";
|
|
18
18
|
import { mountStatic } from "./frame-runtime/index";
|
|
19
|
-
import { givensFromSearch,
|
|
19
|
+
import { givensFromSearch, shareSearch, urlStateFromSearch } from "./shared/givens-url";
|
|
20
20
|
|
|
21
21
|
const info = window.__DASHBOARD__ || {};
|
|
22
22
|
const MODEL_FILES = window.__MODEL_FILES__ || {};
|
|
@@ -138,11 +138,22 @@ async function run(req: { query?: string; malloy?: string }, givens: Record<stri
|
|
|
138
138
|
|
|
139
139
|
// Same param encoding as the dev server (shared/givens-url); only the path
|
|
140
140
|
// shape differs — sibling .html pages here vs `/?d=` there.
|
|
141
|
-
|
|
141
|
+
//
|
|
142
|
+
// The URL carries TWO namespaces — `$given` and `~view-state` (useUrlState) —
|
|
143
|
+
// written by two independent syncs, so each write must re-emit the other's
|
|
144
|
+
// params or it would erase them. Both are cached here, seeded from the URL this
|
|
145
|
+
// page opened with, and every write rebuilds the whole query string.
|
|
146
|
+
let lastGivens: Record<string, unknown> = givensFromSearch(location.search);
|
|
147
|
+
let lastUrlState: Record<string, unknown> = urlStateFromSearch(location.search);
|
|
148
|
+
|
|
149
|
+
const dashUrl = (dashboard: string, givens: Record<string, unknown>, urlState: Record<string, unknown>) => {
|
|
142
150
|
const u = new URL(`./${dashboard}.html`, document.baseURI);
|
|
143
|
-
u.
|
|
144
|
-
return u.pathname + u.search;
|
|
151
|
+
return u.pathname + shareSearch({ givens, urlState });
|
|
145
152
|
};
|
|
153
|
+
// A drill LEAVES this dashboard: carry the givens it seeded, but not this
|
|
154
|
+
// component's view-state — that belongs to the component being left.
|
|
155
|
+
const navigateUrl = (dashboard: string, givens: Record<string, unknown>) =>
|
|
156
|
+
dashUrl(dashboard, givens, {});
|
|
146
157
|
|
|
147
158
|
// Custom dashboards drive drills by posting to `parent` directly:
|
|
148
159
|
// parent.postMessage({ type: "navigate", dashboard, givens }, "*")
|
|
@@ -157,11 +168,17 @@ function installMessageBridge(name: string) {
|
|
|
157
168
|
const m = e.data;
|
|
158
169
|
if (!m || typeof m !== "object") return;
|
|
159
170
|
if (m.type === "givens" && m.givens) {
|
|
160
|
-
|
|
171
|
+
lastGivens = m.givens;
|
|
172
|
+
history.replaceState(null, "", dashUrl(name, lastGivens, lastUrlState));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (m.type === "urlstate" && m.state) {
|
|
176
|
+
lastUrlState = m.state;
|
|
177
|
+
history.replaceState(null, "", dashUrl(name, lastGivens, lastUrlState));
|
|
161
178
|
return;
|
|
162
179
|
}
|
|
163
180
|
if (m.type === "navigate" && typeof m.dashboard === "string") {
|
|
164
|
-
location.href =
|
|
181
|
+
location.href = navigateUrl(m.dashboard, m.givens || {});
|
|
165
182
|
}
|
|
166
183
|
});
|
|
167
184
|
}
|
|
@@ -173,14 +190,24 @@ export function boot(Dashboard: unknown) {
|
|
|
173
190
|
// there, here from our own query string, but through the SAME encoder, so the
|
|
174
191
|
// `$` prefix the runtime keys off is preserved. Set before mount: the runtime
|
|
175
192
|
// reads this global lazily when it computes initial given values.
|
|
176
|
-
(window as any).__INITIAL_GIVENS__ =
|
|
193
|
+
(window as any).__INITIAL_GIVENS__ = lastGivens;
|
|
194
|
+
// …and the `~` half: a custom component's useUrlState (a rack, a board) —
|
|
195
|
+
// view-state, not a query parameter, but just as shareable.
|
|
196
|
+
(window as any).__INITIAL_URLSTATE__ = lastUrlState;
|
|
177
197
|
installMessageBridge(info.name);
|
|
178
198
|
return mountStatic(Dashboard, {
|
|
179
199
|
root: document.getElementById("root") as HTMLElement,
|
|
180
200
|
run,
|
|
181
201
|
navigate: (dashboard, givens) => {
|
|
182
|
-
location.href =
|
|
202
|
+
location.href = navigateUrl(dashboard, givens);
|
|
203
|
+
},
|
|
204
|
+
syncGivens: (givens) => {
|
|
205
|
+
lastGivens = givens;
|
|
206
|
+
history.replaceState(null, "", dashUrl(info.name, lastGivens, lastUrlState));
|
|
207
|
+
},
|
|
208
|
+
syncUrlState: (state) => {
|
|
209
|
+
lastUrlState = state;
|
|
210
|
+
history.replaceState(null, "", dashUrl(info.name, lastGivens, lastUrlState));
|
|
183
211
|
},
|
|
184
|
-
syncGivens: (givens) => history.replaceState(null, "", givensToUrl(info.name, givens)),
|
|
185
212
|
});
|
|
186
213
|
}
|