@malloydata/malloyyo 0.2.19 → 0.2.21
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-runtime/index.ts +19 -0
- package/dist/frame-wasm-entry.tsx +186 -0
- package/dist/index.js +558 -48
- package/dist/shared/givens-url.ts +33 -0
- package/dist/shared/nav.ts +72 -0
- package/dist/shims/assert.cjs +16 -0
- package/dist/shims/util.cjs +7 -0
- package/package.json +3 -1
|
@@ -66,3 +66,22 @@ export function mountInPage(opts: {
|
|
|
66
66
|
// the reset). Returns the React root so the caller can unmount() on teardown.
|
|
67
67
|
return mount(DefaultDashboard, WIDGETS, opts.root, { bodyReset: false });
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Static-site entry (`malloyyo dashboard bundle`): like mountInPage, but mounts
|
|
71
|
+
the dashboard's OWN component when it has one. The published site has no
|
|
72
|
+
server and no iframe — the host runs Malloy + DuckDB-WASM in this same page,
|
|
73
|
+
so there is no trust boundary to sandbox across and custom and tag-only
|
|
74
|
+
dashboards can share one mount. A null Dashboard falls back to
|
|
75
|
+
DefaultDashboard, exactly as mountDashboard does. */
|
|
76
|
+
export function mountStatic(
|
|
77
|
+
Dashboard: unknown,
|
|
78
|
+
opts: {
|
|
79
|
+
root: HTMLElement;
|
|
80
|
+
run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
|
|
81
|
+
navigate: (dashboard: string, givens: Record<string, unknown>) => void;
|
|
82
|
+
syncGivens: (givens: Record<string, unknown>) => void;
|
|
83
|
+
},
|
|
84
|
+
): { unmount: () => void } {
|
|
85
|
+
setHost({ run: opts.run, navigate: opts.navigate, syncGivens: opts.syncGivens });
|
|
86
|
+
return mount(Dashboard ?? DefaultDashboard, WIDGETS, opts.root, { bodyReset: false });
|
|
87
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// Browser entry for a STATIC dashboard site (`malloyyo dashboard bundle`).
|
|
3
|
+
//
|
|
4
|
+
// The other two entries assume a server holds the privileged capability:
|
|
5
|
+
// frame-entry.tsx postMessages a trusted parent, frame-inpage-entry.tsx fetches
|
|
6
|
+
// /api/run. This one has no server at all. Malloy compiles in the page and
|
|
7
|
+
// DuckDB-WASM executes the SQL, so `host.run` is a local function call.
|
|
8
|
+
//
|
|
9
|
+
// That also means there is nothing to sandbox: with no credential in the page,
|
|
10
|
+
// the iframe bought isolation from a threat that no longer exists, so custom and
|
|
11
|
+
// tag-only dashboards share a single in-page mount (mountStatic).
|
|
12
|
+
// Exported as boot(Dashboard) rather than self-executing: `dashboard bundle`
|
|
13
|
+
// generates one tiny entry per dashboard that imports its own component and
|
|
14
|
+
// calls this, so a single build can emit N dashboards that share one chunk.
|
|
15
|
+
import * as duckdb from "@duckdb/duckdb-wasm";
|
|
16
|
+
import { DuckDBWASMConnection } from "@malloydata/db-duckdb/wasm";
|
|
17
|
+
import { API, SingleConnectionRuntime } from "@malloydata/malloy";
|
|
18
|
+
import { mountStatic } from "./frame-runtime/index";
|
|
19
|
+
import { givensFromSearch, givensToParams } from "./shared/givens-url";
|
|
20
|
+
|
|
21
|
+
const info = window.__DASHBOARD__ || {};
|
|
22
|
+
const MODEL_FILES = window.__MODEL_FILES__ || {};
|
|
23
|
+
const ENTRY = `file:///${info.entryFile}`;
|
|
24
|
+
|
|
25
|
+
// ── DuckDB-WASM ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
// Bundle URLs MUST be absolute. selectBundle hands them to the WORKER, whose
|
|
28
|
+
// base URL is the worker's own directory — a relative path resolves against
|
|
29
|
+
// that, 404s, and instantiate() then hangs forever with no error. This bites
|
|
30
|
+
// hardest on GitHub Pages, where the site is served from /<repo>/.
|
|
31
|
+
const abs = (p: string) => new URL(p, document.baseURI).href;
|
|
32
|
+
|
|
33
|
+
// Where the DuckDB binaries come from. The build sets __DUCKDB_BASE__ when it
|
|
34
|
+
// self-hosts them; absent, we use duckdb-wasm's own jsDelivr helper, which pins
|
|
35
|
+
// the exact installed version in an immutable, CORS-open URL.
|
|
36
|
+
const DUCKDB_BASE: string | undefined = (window as any).__DUCKDB_BASE__;
|
|
37
|
+
|
|
38
|
+
class StaticWasmConnection extends DuckDBWASMConnection {
|
|
39
|
+
getBundles() {
|
|
40
|
+
if (!DUCKDB_BASE) return duckdb.getJsDelivrBundles();
|
|
41
|
+
const at = (f: string) => abs(DUCKDB_BASE + f);
|
|
42
|
+
return {
|
|
43
|
+
mvp: { mainModule: at("duckdb-mvp.wasm"), mainWorker: at("duckdb-browser-mvp.worker.js") },
|
|
44
|
+
eh: { mainModule: at("duckdb-eh.wasm"), mainWorker: at("duckdb-browser-eh.worker.js") },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Whole-file fetch, not ranged reads.
|
|
50
|
+
//
|
|
51
|
+
// __TABLE_FILES__ maps each table reference the model makes to the URL this
|
|
52
|
+
// page fetches for it. The KEY is what the model wrote and what DuckDB will
|
|
53
|
+
// look up, so registering the bytes under that exact name means the model needs
|
|
54
|
+
// no rewriting between `dashboard dev` (reads the file off disk) and here.
|
|
55
|
+
//
|
|
56
|
+
// Project-relative refs like `data/x.parquet` resolve against the page, so they
|
|
57
|
+
// are SAME-ORIGIN — no CORS involved at all. Absolute URLs are fetched
|
|
58
|
+
// cross-origin and do need CORS on that host.
|
|
59
|
+
//
|
|
60
|
+
// This cannot go through db-duckdb's registerRemoteTableCallback: findTables
|
|
61
|
+
// skips anything matching ^https?:// ("handled by duckdb-wasm"), so the callback
|
|
62
|
+
// never fires for a remote URL. Pre-registering is also strictly better — one
|
|
63
|
+
// bulk GET instead of dozens of ranged round trips.
|
|
64
|
+
const TABLE_FILES: Record<string, string> = (window as any).__TABLE_FILES__ || {};
|
|
65
|
+
|
|
66
|
+
async function preloadTables(db: any) {
|
|
67
|
+
await Promise.all(
|
|
68
|
+
Object.entries(TABLE_FILES).map(async ([name, href]) => {
|
|
69
|
+
const url = new URL(href, document.baseURI).href;
|
|
70
|
+
const r = await fetch(url);
|
|
71
|
+
if (!r.ok) throw new Error(`fetch ${url} failed: ${r.status} ${r.statusText}`);
|
|
72
|
+
await db.registerFileBuffer(name, new Uint8Array(await r.arrayBuffer()));
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Malloy ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
// Model sources are inlined at build time (model-files.js) and keyed by the
|
|
80
|
+
// file:// URL Malloy resolves imports against, so nothing is fetched at runtime.
|
|
81
|
+
const urlReader = {
|
|
82
|
+
readURL: async (url: URL | string) => {
|
|
83
|
+
const key = url.toString();
|
|
84
|
+
const src = MODEL_FILES[key];
|
|
85
|
+
if (src == null) {
|
|
86
|
+
throw new Error(`model file not found: ${key}\nhave: ${Object.keys(MODEL_FILES).join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
return src;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let runtimeP: Promise<unknown> | null = null;
|
|
93
|
+
function getRuntime() {
|
|
94
|
+
if (!runtimeP) {
|
|
95
|
+
runtimeP = (async () => {
|
|
96
|
+
const connection = new StaticWasmConnection({ name: "duckdb" });
|
|
97
|
+
await connection.connecting;
|
|
98
|
+
await preloadTables((connection as any).database);
|
|
99
|
+
return new SingleConnectionRuntime({ connection, urlReader });
|
|
100
|
+
})();
|
|
101
|
+
}
|
|
102
|
+
return runtimeP;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Panel/data query text may arrive with or without a leading `run:`.
|
|
106
|
+
const asRun = (t: string) => (/^\s*run\s*:/.test(t) ? t : `run: ${t}`);
|
|
107
|
+
|
|
108
|
+
// Match `dashboard dev` exactly (host.ts passes this on every run path). Without
|
|
109
|
+
// an explicit limit Malloy applies its own much smaller default, which silently
|
|
110
|
+
// truncates tiles rather than erroring — they just look half-empty.
|
|
111
|
+
const ROW_LIMIT = 5000;
|
|
112
|
+
|
|
113
|
+
/** The host contract, served locally. `query` names a model-published query (or
|
|
114
|
+
a `source -> view` path); `malloy` is query text the runtime builds itself
|
|
115
|
+
(the given typeahead). Shape matches the dev server's: {ok, rows,
|
|
116
|
+
stable_result, problems[]}. */
|
|
117
|
+
async function run(req: { query?: string; malloy?: string }, givens: Record<string, unknown>) {
|
|
118
|
+
try {
|
|
119
|
+
const runtime: any = await getRuntime();
|
|
120
|
+
const model = runtime.loadModel(new URL(ENTRY));
|
|
121
|
+
const text = req.malloy != null ? asRun(req.malloy) : asRun(req.query as string);
|
|
122
|
+
const result = await model.loadQuery(text).run({ rowLimit: ROW_LIMIT, givens: givens ?? {} });
|
|
123
|
+
// Same shaping the engine does (mcp-engine/src/run.ts): plain rows for
|
|
124
|
+
// components that draw themselves, plus the interfaces-format result the
|
|
125
|
+
// Malloy renderer needs for DefaultDashboard / <Panel>.
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
rows: result.toJSON().queryResult.result,
|
|
129
|
+
stable_result: API.util.wrapResult(result),
|
|
130
|
+
};
|
|
131
|
+
} catch (e: unknown) {
|
|
132
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
133
|
+
return { ok: false, problems: [{ message: msg }] };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── mount ───────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
// Same param encoding as the dev server (shared/givens-url); only the path
|
|
140
|
+
// shape differs — sibling .html pages here vs `/?d=` there.
|
|
141
|
+
const givensToUrl = (dashboard: string, givens: Record<string, unknown>) => {
|
|
142
|
+
const u = new URL(`./${dashboard}.html`, document.baseURI);
|
|
143
|
+
u.search = givensToParams(givens).toString();
|
|
144
|
+
return u.pathname + u.search;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Custom dashboards drive drills by posting to `parent` directly:
|
|
148
|
+
// parent.postMessage({ type: "navigate", dashboard, givens }, "*")
|
|
149
|
+
// Under `dashboard dev` the component runs in a sandboxed iframe, so `parent`
|
|
150
|
+
// is the trusted shell, which listens and navigates. Here there IS no iframe —
|
|
151
|
+
// the dashboard owns the top window — so `parent === window` and those messages
|
|
152
|
+
// are posted to ourselves with nobody listening: clicking a name silently did
|
|
153
|
+
// nothing. Re-implement the shell's half of the protocol against our own window.
|
|
154
|
+
function installMessageBridge(name: string) {
|
|
155
|
+
window.addEventListener("message", (e: MessageEvent) => {
|
|
156
|
+
if (e.source !== window) return; // no other frames exist; ignore anything else
|
|
157
|
+
const m = e.data;
|
|
158
|
+
if (!m || typeof m !== "object") return;
|
|
159
|
+
if (m.type === "givens" && m.givens) {
|
|
160
|
+
history.replaceState(null, "", givensToUrl(name, m.givens));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (m.type === "navigate" && typeof m.dashboard === "string") {
|
|
164
|
+
location.href = givensToUrl(m.dashboard, m.givens || {});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Mount this page's dashboard. `Dashboard` is the model repo's own component,
|
|
170
|
+
or null for a tag-only dashboard (mountStatic falls back to the renderer). */
|
|
171
|
+
export function boot(Dashboard: unknown) {
|
|
172
|
+
// Seed shareable-link values the same way the dev server does — server-side
|
|
173
|
+
// there, here from our own query string, but through the SAME encoder, so the
|
|
174
|
+
// `$` prefix the runtime keys off is preserved. Set before mount: the runtime
|
|
175
|
+
// reads this global lazily when it computes initial given values.
|
|
176
|
+
(window as any).__INITIAL_GIVENS__ = givensFromSearch(location.search);
|
|
177
|
+
installMessageBridge(info.name);
|
|
178
|
+
return mountStatic(Dashboard, {
|
|
179
|
+
root: document.getElementById("root") as HTMLElement,
|
|
180
|
+
run,
|
|
181
|
+
navigate: (dashboard, givens) => {
|
|
182
|
+
location.href = givensToUrl(dashboard, givens);
|
|
183
|
+
},
|
|
184
|
+
syncGivens: (givens) => history.replaceState(null, "", givensToUrl(info.name, givens)),
|
|
185
|
+
});
|
|
186
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2699,11 +2699,48 @@ async function serveMcp(opts) {
|
|
|
2699
2699
|
|
|
2700
2700
|
// src/dashboard.ts
|
|
2701
2701
|
import http2 from "node:http";
|
|
2702
|
+
import fs4 from "node:fs";
|
|
2703
|
+
import path5 from "node:path";
|
|
2704
|
+
import * as esbuild2 from "esbuild";
|
|
2705
|
+
|
|
2706
|
+
// src/shared/givens-url.ts
|
|
2707
|
+
function givensFromSearch(search) {
|
|
2708
|
+
const g = {};
|
|
2709
|
+
for (const [k, v] of new URLSearchParams(search)) {
|
|
2710
|
+
if (k !== "d") g[k] = v;
|
|
2711
|
+
}
|
|
2712
|
+
return g;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// src/shared/nav.ts
|
|
2716
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2717
|
+
var NAV_CSS = `
|
|
2718
|
+
.dash-nav{display:flex;gap:4px;align-items:center;padding:8px 14px;background:#000;font:13px system-ui,-apple-system,sans-serif;flex-wrap:wrap}
|
|
2719
|
+
/* The home icon is an <a> too, so it opts OUT of the switcher-link padding and
|
|
2720
|
+
keeps its own square hit area. */
|
|
2721
|
+
.dash-nav a.brand{display:inline-flex;align-items:center;justify-content:center;color:#9aa1ac;padding:5px;border-radius:6px;text-decoration:none}
|
|
2722
|
+
.dash-nav a.brand:hover{background:#1f232a;color:#fff}
|
|
2723
|
+
.dash-nav .brand svg{display:block}
|
|
2724
|
+
.dash-nav .sep{width:1px;align-self:stretch;background:#2c3038;margin:0 10px}
|
|
2725
|
+
.dash-nav a{padding:4px 10px;border-radius:6px;text-decoration:none;color:#c9ced6}
|
|
2726
|
+
.dash-nav a:hover{background:#1f232a;color:#fff}
|
|
2727
|
+
.dash-nav a.on{background:#fff;color:#000;font-weight:550}
|
|
2728
|
+
`;
|
|
2729
|
+
var HOME_ICON = `<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 10.2 12 3.5l9 6.7"/><path d="M5.2 8.9V20h13.6V8.9"/><path d="M9.6 20v-6.2h4.8V20"/></svg>`;
|
|
2730
|
+
function navHtml(active, all, href, homeHref = "./") {
|
|
2731
|
+
const brand = `<a class="brand" href="${esc(homeHref)}" title="Home" aria-label="Home">${HOME_ICON}</a>`;
|
|
2732
|
+
if (all.length <= 1) return `<nav class="dash-nav">${brand}</nav>`;
|
|
2733
|
+
const links = all.map(
|
|
2734
|
+
(x) => `<a href="${esc(href(x.name))}"${x.name === active ? ' class="on"' : ""}>${esc(x.title || x.name)}</a>`
|
|
2735
|
+
).join("");
|
|
2736
|
+
return `<nav class="dash-nav">${brand}<span class="sep"></span>${links}</nav>`;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// src/discover.ts
|
|
2702
2740
|
import fs3 from "node:fs";
|
|
2703
2741
|
import path4 from "node:path";
|
|
2704
2742
|
import { fileURLToPath } from "node:url";
|
|
2705
2743
|
import { createRequire } from "node:module";
|
|
2706
|
-
import * as esbuild2 from "esbuild";
|
|
2707
2744
|
var require2 = createRequire(import.meta.url);
|
|
2708
2745
|
var HOST_LIBS = [
|
|
2709
2746
|
"react",
|
|
@@ -2736,8 +2773,6 @@ function resolveRuntimeDir() {
|
|
|
2736
2773
|
}
|
|
2737
2774
|
return found;
|
|
2738
2775
|
}
|
|
2739
|
-
var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
2740
|
-
var resolveInPageEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-inpage-entry.tsx");
|
|
2741
2776
|
var hostAliasPlugin = {
|
|
2742
2777
|
name: "host-alias",
|
|
2743
2778
|
setup(b) {
|
|
@@ -2763,15 +2798,35 @@ async function discoverDashboards(root, runner) {
|
|
|
2763
2798
|
}
|
|
2764
2799
|
return dashboards;
|
|
2765
2800
|
}
|
|
2766
|
-
|
|
2801
|
+
function browserBuildBase() {
|
|
2802
|
+
const shims = path4.join(resolveRuntimeDir(), "..", "shims");
|
|
2803
|
+
return {
|
|
2804
|
+
platform: "browser",
|
|
2805
|
+
jsx: "automatic",
|
|
2806
|
+
loader: { ".css": "empty" },
|
|
2807
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
2808
|
+
alias: {
|
|
2809
|
+
assert: path4.join(shims, "assert.cjs"),
|
|
2810
|
+
util: path4.join(shims, "util.cjs")
|
|
2811
|
+
},
|
|
2812
|
+
banner: {
|
|
2813
|
+
js: "globalThis.process||={env:{},platform:'browser',versions:{},argv:[],cwd:()=>'/'};"
|
|
2814
|
+
}
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// src/dashboard.ts
|
|
2819
|
+
var resolveFrameEntry = () => path5.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
2820
|
+
var resolveInPageEntry = () => path5.join(resolveRuntimeDir(), "..", "frame-inpage-entry.tsx");
|
|
2821
|
+
var esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2767
2822
|
function makeBundler() {
|
|
2768
2823
|
const cache = /* @__PURE__ */ new Map();
|
|
2769
2824
|
const frameEntry = resolveFrameEntry();
|
|
2770
2825
|
const runtimeDir = resolveRuntimeDir();
|
|
2771
|
-
const runtimeIndex =
|
|
2772
|
-
const runtimeStamp = () =>
|
|
2826
|
+
const runtimeIndex = path5.join(runtimeDir, "index.ts");
|
|
2827
|
+
const runtimeStamp = () => fs4.statSync(frameEntry).mtimeMs + fs4.readdirSync(runtimeDir).map((f) => fs4.statSync(path5.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2773
2828
|
return async function bundle(dash) {
|
|
2774
|
-
const stamp = runtimeStamp() + (dash.tsxPath ?
|
|
2829
|
+
const stamp = runtimeStamp() + (dash.tsxPath ? fs4.statSync(dash.tsxPath).mtimeMs : 0);
|
|
2775
2830
|
const hit = cache.get(dash.name);
|
|
2776
2831
|
if (hit && hit.stamp === stamp) return hit.js;
|
|
2777
2832
|
const result = await esbuild2.build({
|
|
@@ -2816,7 +2871,7 @@ function makeInPageBundler() {
|
|
|
2816
2871
|
let cached;
|
|
2817
2872
|
const entry = resolveInPageEntry();
|
|
2818
2873
|
const runtimeDir = resolveRuntimeDir();
|
|
2819
|
-
const stampOf = () =>
|
|
2874
|
+
const stampOf = () => fs4.statSync(entry).mtimeMs + fs4.readdirSync(runtimeDir).map((f) => fs4.statSync(path5.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2820
2875
|
return async function bundle() {
|
|
2821
2876
|
const stamp = stampOf();
|
|
2822
2877
|
if (cached && cached.stamp === stamp) return cached.js;
|
|
@@ -2837,13 +2892,9 @@ function makeInPageBundler() {
|
|
|
2837
2892
|
return js;
|
|
2838
2893
|
};
|
|
2839
2894
|
}
|
|
2840
|
-
var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0">${body}</body></html>`;
|
|
2841
|
-
function
|
|
2842
|
-
|
|
2843
|
-
return `<nav style="display:flex;gap:4px;align-items:center;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e2e4e8;font:13px system-ui,sans-serif"><span style="color:#888;margin-right:8px">Dashboards</span>` + all.map((x) => {
|
|
2844
|
-
const on = x.name === dash.name;
|
|
2845
|
-
return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.title || x.name)}</a>`;
|
|
2846
|
-
}).join("") + `</nav>`;
|
|
2895
|
+
var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>${NAV_CSS}</style></head><body style="margin:0">${body}</body></html>`;
|
|
2896
|
+
function navHtml2(dash, all) {
|
|
2897
|
+
return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
|
|
2847
2898
|
}
|
|
2848
2899
|
function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
|
|
2849
2900
|
const info = {
|
|
@@ -2858,7 +2909,7 @@ function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
|
|
|
2858
2909
|
autorun: dash.autorun
|
|
2859
2910
|
};
|
|
2860
2911
|
return html(
|
|
2861
|
-
|
|
2912
|
+
navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
2862
2913
|
dash.title
|
|
2863
2914
|
);
|
|
2864
2915
|
}
|
|
@@ -2866,7 +2917,7 @@ function parentShell(dash, frameBase, all, initialGivens) {
|
|
|
2866
2917
|
const givensQs = Object.entries(initialGivens).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
|
|
2867
2918
|
const d = JSON.stringify(dash.name);
|
|
2868
2919
|
const fb = JSON.stringify(frameBase);
|
|
2869
|
-
const nav =
|
|
2920
|
+
const nav = navHtml2(dash, all);
|
|
2870
2921
|
return html(
|
|
2871
2922
|
`<div style="display:flex;flex-direction:column;height:100vh">` + nav + // allow-popups(+escape-sandbox): let a # link mark open its target in a
|
|
2872
2923
|
// normal new tab on click instead of being blocked by the sandbox.
|
|
@@ -2904,9 +2955,7 @@ window.addEventListener('message',async(e)=>{
|
|
|
2904
2955
|
);
|
|
2905
2956
|
}
|
|
2906
2957
|
function givensFromUrl(url4) {
|
|
2907
|
-
|
|
2908
|
-
for (const [k, v] of url4.searchParams) if (k !== "d") g[k] = v;
|
|
2909
|
-
return g;
|
|
2958
|
+
return givensFromSearch(url4.search);
|
|
2910
2959
|
}
|
|
2911
2960
|
function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
|
|
2912
2961
|
const info = {
|
|
@@ -2934,7 +2983,7 @@ async function readBody(req) {
|
|
|
2934
2983
|
}
|
|
2935
2984
|
async function serveDashboard(opts) {
|
|
2936
2985
|
await import("@malloydata/malloy-connections");
|
|
2937
|
-
const root =
|
|
2986
|
+
const root = path5.resolve(opts.root ?? process.cwd());
|
|
2938
2987
|
const port = opts.port ?? 4173;
|
|
2939
2988
|
const framePort = port + 1;
|
|
2940
2989
|
const frameBase = `http://localhost:${framePort}`;
|
|
@@ -2967,7 +3016,7 @@ async function serveDashboard(opts) {
|
|
|
2967
3016
|
};
|
|
2968
3017
|
let debounce;
|
|
2969
3018
|
try {
|
|
2970
|
-
|
|
3019
|
+
fs4.watch(root, { recursive: true }, (_evt, filename) => {
|
|
2971
3020
|
const f = filename?.toString() ?? "";
|
|
2972
3021
|
if (!f.endsWith(".malloy") && !f.includes("dashboards")) return;
|
|
2973
3022
|
clearTimeout(debounce);
|
|
@@ -2999,7 +3048,7 @@ async function serveDashboard(opts) {
|
|
|
2999
3048
|
return send(
|
|
3000
3049
|
200,
|
|
3001
3050
|
"text/html; charset=utf-8",
|
|
3002
|
-
html(`<pre style="color:crimson;padding:16px">model error: ${
|
|
3051
|
+
html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
|
|
3003
3052
|
);
|
|
3004
3053
|
}
|
|
3005
3054
|
return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url4), g.tiles));
|
|
@@ -3024,7 +3073,7 @@ async function serveDashboard(opts) {
|
|
|
3024
3073
|
return send(
|
|
3025
3074
|
200,
|
|
3026
3075
|
"text/html; charset=utf-8",
|
|
3027
|
-
html(`<pre style="color:crimson;padding:16px">model error: ${
|
|
3076
|
+
html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
|
|
3028
3077
|
);
|
|
3029
3078
|
}
|
|
3030
3079
|
return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url4), g.tiles));
|
|
@@ -3064,9 +3113,445 @@ async function serveDashboard(opts) {
|
|
|
3064
3113
|
});
|
|
3065
3114
|
}
|
|
3066
3115
|
|
|
3116
|
+
// src/bundle.ts
|
|
3117
|
+
import fs6 from "node:fs";
|
|
3118
|
+
import path7 from "node:path";
|
|
3119
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
3120
|
+
import * as esbuild3 from "esbuild";
|
|
3121
|
+
|
|
3122
|
+
// src/static-server.ts
|
|
3123
|
+
import fs5 from "node:fs";
|
|
3124
|
+
import http3 from "node:http";
|
|
3125
|
+
import path6 from "node:path";
|
|
3126
|
+
var MIME = {
|
|
3127
|
+
".html": "text/html; charset=utf-8",
|
|
3128
|
+
".js": "text/javascript; charset=utf-8",
|
|
3129
|
+
".css": "text/css; charset=utf-8",
|
|
3130
|
+
".json": "application/json",
|
|
3131
|
+
// Must be exact: instantiateStreaming rejects anything else, and the failure
|
|
3132
|
+
// shows up as a hang rather than an error.
|
|
3133
|
+
".wasm": "application/wasm",
|
|
3134
|
+
".parquet": "application/octet-stream",
|
|
3135
|
+
".svg": "image/svg+xml"
|
|
3136
|
+
};
|
|
3137
|
+
function serveStatic(dir, port) {
|
|
3138
|
+
const server = http3.createServer((req, res) => {
|
|
3139
|
+
const rel = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
3140
|
+
let file = path6.join(dir, rel === "/" ? "index.html" : rel);
|
|
3141
|
+
if (!file.startsWith(dir)) return void res.writeHead(403).end();
|
|
3142
|
+
if (fs5.existsSync(file) && fs5.statSync(file).isDirectory()) file = path6.join(file, "index.html");
|
|
3143
|
+
if (!fs5.existsSync(file)) return void res.writeHead(404).end("not found");
|
|
3144
|
+
const st = fs5.statSync(file);
|
|
3145
|
+
const type = MIME[path6.extname(file)] ?? "application/octet-stream";
|
|
3146
|
+
const range = req.headers.range;
|
|
3147
|
+
if (range) {
|
|
3148
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
3149
|
+
const start = m?.[1] ? Number(m[1]) : 0;
|
|
3150
|
+
const end = m?.[2] ? Number(m[2]) : st.size - 1;
|
|
3151
|
+
res.writeHead(206, {
|
|
3152
|
+
"Content-Type": type,
|
|
3153
|
+
"Content-Range": `bytes ${start}-${end}/${st.size}`,
|
|
3154
|
+
"Accept-Ranges": "bytes",
|
|
3155
|
+
"Content-Length": end - start + 1
|
|
3156
|
+
});
|
|
3157
|
+
return void fs5.createReadStream(file, { start, end }).pipe(res);
|
|
3158
|
+
}
|
|
3159
|
+
res.writeHead(200, { "Content-Type": type, "Content-Length": st.size, "Accept-Ranges": "bytes" });
|
|
3160
|
+
fs5.createReadStream(file).pipe(res);
|
|
3161
|
+
});
|
|
3162
|
+
return new Promise((resolve3, reject) => {
|
|
3163
|
+
let attempt = 0;
|
|
3164
|
+
const tryPort = (p) => {
|
|
3165
|
+
server.once("error", (err) => {
|
|
3166
|
+
if (err.code === "EADDRINUSE" && attempt < 10) {
|
|
3167
|
+
attempt++;
|
|
3168
|
+
tryPort(p + 1);
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
reject(err);
|
|
3172
|
+
});
|
|
3173
|
+
server.listen(p, () => {
|
|
3174
|
+
if (p !== port) console.log(`
|
|
3175
|
+
(port ${port} busy \u2014 using ${p})`);
|
|
3176
|
+
console.log(`
|
|
3177
|
+
serving ${path6.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
|
|
3178
|
+
resolve3();
|
|
3179
|
+
});
|
|
3180
|
+
};
|
|
3181
|
+
tryPort(port);
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// src/bundle.ts
|
|
3186
|
+
var require3 = createRequire2(import.meta.url);
|
|
3187
|
+
var esc3 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3188
|
+
function inlineModelFiles(root) {
|
|
3189
|
+
const files = {};
|
|
3190
|
+
const skip = /* @__PURE__ */ new Set(["node_modules", ".git", "docs", "dist"]);
|
|
3191
|
+
const walk = (dir) => {
|
|
3192
|
+
for (const entry of fs6.readdirSync(dir, { withFileTypes: true })) {
|
|
3193
|
+
if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
|
|
3194
|
+
const abs = path7.join(dir, entry.name);
|
|
3195
|
+
if (entry.isDirectory()) walk(abs);
|
|
3196
|
+
else if (entry.name.endsWith(".malloy")) {
|
|
3197
|
+
const rel = path7.relative(root, abs).split(path7.sep).join("/");
|
|
3198
|
+
files[`file:///${rel}`] = fs6.readFileSync(abs, "utf8");
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
walk(root);
|
|
3203
|
+
return files;
|
|
3204
|
+
}
|
|
3205
|
+
function reachableModelFiles(modelFiles, entries) {
|
|
3206
|
+
const IMPORT = /\bimport\s+(?:\{[^}]*\}\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
3207
|
+
const out = {};
|
|
3208
|
+
const queue = entries.map((e) => `file:///${e.replace(/^\.?\//, "")}`);
|
|
3209
|
+
while (queue.length) {
|
|
3210
|
+
const key = queue.shift();
|
|
3211
|
+
if (key in out) continue;
|
|
3212
|
+
const src = modelFiles[key];
|
|
3213
|
+
if (src == null) continue;
|
|
3214
|
+
out[key] = src;
|
|
3215
|
+
const dir = key.slice(0, key.lastIndexOf("/"));
|
|
3216
|
+
for (const m of src.matchAll(IMPORT)) {
|
|
3217
|
+
queue.push(new URL(m[1], dir + "/").href);
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
return out;
|
|
3221
|
+
}
|
|
3222
|
+
function findTableRefs(modelFiles) {
|
|
3223
|
+
const refs = /* @__PURE__ */ new Set();
|
|
3224
|
+
const re = /\btable\(\s*(['"])([^'"]+)\1/g;
|
|
3225
|
+
for (const src of Object.values(modelFiles)) {
|
|
3226
|
+
for (const m of src.matchAll(re)) refs.add(m[2]);
|
|
3227
|
+
}
|
|
3228
|
+
return [...refs].sort();
|
|
3229
|
+
}
|
|
3230
|
+
function isDataFile(ref) {
|
|
3231
|
+
if (/^https?:\/\//i.test(ref)) return true;
|
|
3232
|
+
return /\.(parquet|csv|tsv|json|ndjson)$/i.test(ref);
|
|
3233
|
+
}
|
|
3234
|
+
function tableFilePlan(modelFiles, outRel) {
|
|
3235
|
+
const map = {};
|
|
3236
|
+
const copies = [];
|
|
3237
|
+
const out = outRel.replace(/^\.?\//, "").replace(/\/$/, "");
|
|
3238
|
+
for (const ref of findTableRefs(modelFiles)) {
|
|
3239
|
+
if (!isDataFile(ref)) continue;
|
|
3240
|
+
if (/^https?:\/\//i.test(ref)) {
|
|
3241
|
+
map[ref] = ref;
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
const rel = ref.replace(/^\.?\//, "");
|
|
3245
|
+
if (out && (rel === out || rel.startsWith(out + "/"))) {
|
|
3246
|
+
map[ref] = `./${rel.slice(out.length + 1)}`;
|
|
3247
|
+
} else {
|
|
3248
|
+
map[ref] = `./${rel}`;
|
|
3249
|
+
copies.push(rel);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
return { map, copies };
|
|
3253
|
+
}
|
|
3254
|
+
function copyDuckDBAssets(outDir) {
|
|
3255
|
+
const names = [
|
|
3256
|
+
"duckdb-mvp.wasm",
|
|
3257
|
+
"duckdb-eh.wasm",
|
|
3258
|
+
"duckdb-browser-mvp.worker.js",
|
|
3259
|
+
"duckdb-browser-eh.worker.js"
|
|
3260
|
+
];
|
|
3261
|
+
const dir = path7.join(outDir, "duckdb");
|
|
3262
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
3263
|
+
const copied = [];
|
|
3264
|
+
for (const n of names) {
|
|
3265
|
+
const src = require3.resolve(`@duckdb/duckdb-wasm/dist/${n}`);
|
|
3266
|
+
fs6.copyFileSync(src, path7.join(dir, n));
|
|
3267
|
+
copied.push(n);
|
|
3268
|
+
}
|
|
3269
|
+
return copied;
|
|
3270
|
+
}
|
|
3271
|
+
function navFor(dash, all, cleanUrls) {
|
|
3272
|
+
return navHtml(
|
|
3273
|
+
dash.name,
|
|
3274
|
+
all,
|
|
3275
|
+
(n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
function page(dash, all, title, givenSpecs, tileSpecs, cleanUrls) {
|
|
3279
|
+
const info = {
|
|
3280
|
+
name: dash.name,
|
|
3281
|
+
query: dash.query,
|
|
3282
|
+
title: dash.title,
|
|
3283
|
+
description: dash.description,
|
|
3284
|
+
entryFile: dash.entryFile,
|
|
3285
|
+
tiles: dash.tiles,
|
|
3286
|
+
tileSpecs,
|
|
3287
|
+
dashboard_columns: dash.dashboard_columns,
|
|
3288
|
+
givens: dash.givens,
|
|
3289
|
+
autorun: dash.autorun
|
|
3290
|
+
};
|
|
3291
|
+
return `<!doctype html>
|
|
3292
|
+
<html lang="en">
|
|
3293
|
+
<head>
|
|
3294
|
+
<meta charset="utf-8">
|
|
3295
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3296
|
+
<title>${esc3(dash.title || dash.name)}${title ? ` \xB7 ${esc3(title)}` : ""}</title>
|
|
3297
|
+
<link rel="stylesheet" href="./assets/site.css">
|
|
3298
|
+
</head>
|
|
3299
|
+
<body>
|
|
3300
|
+
${navFor(dash, all, cleanUrls)}
|
|
3301
|
+
<div id="root"></div>
|
|
3302
|
+
<script>
|
|
3303
|
+
window.__DASHBOARD__ = ${JSON.stringify(info)};
|
|
3304
|
+
// Given SPECS (label/type/default/suggest) are introspected from the model's
|
|
3305
|
+
// given: declarations at BUILD time \u2014 the runtime reads them from here to draw
|
|
3306
|
+
// controls and seed initial values. Without them there are no controls and
|
|
3307
|
+
// every given starts empty.
|
|
3308
|
+
window.__GIVENS__ = ${JSON.stringify(givenSpecs)};
|
|
3309
|
+
// __INITIAL_GIVENS__ is NOT set here on purpose: the entry bundle sets it from
|
|
3310
|
+
// location.search using shared/givens-url, the same encoder the dev server uses.
|
|
3311
|
+
// An inline copy is what drifted last time (it stripped the dollar-sign prefix
|
|
3312
|
+
// the runtime keys off, so every shareable link fell back to defaults).
|
|
3313
|
+
</script>
|
|
3314
|
+
<script src="./assets/model-files.js"></script>
|
|
3315
|
+
<script type="module" src="./assets/${dash.name}.js"></script>
|
|
3316
|
+
</body>
|
|
3317
|
+
</html>
|
|
3318
|
+
`;
|
|
3319
|
+
}
|
|
3320
|
+
function indexPage(dashboards, title, custom, cleanUrls) {
|
|
3321
|
+
const link = (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`;
|
|
3322
|
+
const body = custom ? `<div id="root"></div>
|
|
3323
|
+
<script>window.__DASHBOARDS__ = ${JSON.stringify(
|
|
3324
|
+
dashboards.map((d) => ({ name: d.name, title: d.title, description: d.description, href: link(d.name) }))
|
|
3325
|
+
)};</script>
|
|
3326
|
+
<script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` + dashboards.map(
|
|
3327
|
+
(d) => `<li><a href="${link(d.name)}"><strong>${esc3(d.title || d.name)}</strong>` + (d.description ? `<span>${esc3(d.description)}</span>` : "") + `</a></li>`
|
|
3328
|
+
).join("") + `</ul></main>`;
|
|
3329
|
+
return `<!doctype html>
|
|
3330
|
+
<html lang="en">
|
|
3331
|
+
<head>
|
|
3332
|
+
<meta charset="utf-8">
|
|
3333
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3334
|
+
<title>${esc3(title)}</title>
|
|
3335
|
+
<link rel="stylesheet" href="./assets/site.css">
|
|
3336
|
+
</head>
|
|
3337
|
+
<body>
|
|
3338
|
+
${navHtml("", dashboards, link)}
|
|
3339
|
+
${body}
|
|
3340
|
+
</body>
|
|
3341
|
+
</html>
|
|
3342
|
+
`;
|
|
3343
|
+
}
|
|
3344
|
+
var SITE_CSS = `:root{--bg:#fbfbfc;--fg:#16181d;--muted:#6b7280;--card:#fff;--line:#e4e6eb;--accent:#2a78d6}
|
|
3345
|
+
@media(prefers-color-scheme:dark){:root{--bg:#14161a;--fg:#e8eaed;--muted:#9aa1ac;--card:#1c1f25;--line:#2c3038;--accent:#4f9bff}}
|
|
3346
|
+
*{box-sizing:border-box}
|
|
3347
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
|
3348
|
+
.index{max-width:760px;margin:0 auto;padding:48px 20px}
|
|
3349
|
+
.index h1{font-size:22px;font-weight:650;margin:0 0 22px}
|
|
3350
|
+
.index ul{list-style:none;padding:0;margin:0;display:grid;gap:10px}
|
|
3351
|
+
.index a{display:flex;flex-direction:column;gap:3px;padding:16px 18px;border:1px solid var(--line);border-radius:12px;background:var(--card);text-decoration:none;color:inherit}
|
|
3352
|
+
.index a:hover{border-color:var(--accent)}
|
|
3353
|
+
.index span{font-size:13px;color:var(--muted)}
|
|
3354
|
+
` + NAV_CSS;
|
|
3355
|
+
async function bundleDashboards(opts = {}) {
|
|
3356
|
+
const root = path7.resolve(opts.root ?? process.cwd());
|
|
3357
|
+
const outDir = path7.resolve(root, opts.out ?? "docs");
|
|
3358
|
+
const title = opts.title ?? path7.basename(root);
|
|
3359
|
+
const target = opts.target ?? "pages";
|
|
3360
|
+
const cleanUrls = target === "vercel";
|
|
3361
|
+
const selfHostDuckdb = opts.duckdb === "bundled";
|
|
3362
|
+
const runner = await makeRunner(root);
|
|
3363
|
+
const dashboards = await discoverDashboards(root, runner);
|
|
3364
|
+
if (dashboards.length === 0) throw new Error(`no dashboards found in ${path7.join(root, "dashboards")}`);
|
|
3365
|
+
const manifestPath = path7.join(outDir, ".bundle-manifest.json");
|
|
3366
|
+
let priorData = [];
|
|
3367
|
+
try {
|
|
3368
|
+
priorData = JSON.parse(fs6.readFileSync(manifestPath, "utf8")).dataFiles ?? [];
|
|
3369
|
+
} catch {
|
|
3370
|
+
}
|
|
3371
|
+
for (const sub of ["assets", "duckdb"]) {
|
|
3372
|
+
fs6.rmSync(path7.join(outDir, sub), { recursive: true, force: true });
|
|
3373
|
+
}
|
|
3374
|
+
if (fs6.existsSync(outDir)) {
|
|
3375
|
+
for (const f of fs6.readdirSync(outDir)) {
|
|
3376
|
+
if (f.endsWith(".html")) fs6.rmSync(path7.join(outDir, f), { force: true });
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
fs6.mkdirSync(path7.join(outDir, "assets"), { recursive: true });
|
|
3380
|
+
if (target === "pages") {
|
|
3381
|
+
fs6.writeFileSync(path7.join(outDir, ".nojekyll"), "");
|
|
3382
|
+
} else {
|
|
3383
|
+
fs6.rmSync(path7.join(outDir, ".nojekyll"), { force: true });
|
|
3384
|
+
fs6.writeFileSync(
|
|
3385
|
+
path7.join(outDir, "vercel.json"),
|
|
3386
|
+
JSON.stringify(
|
|
3387
|
+
{
|
|
3388
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
3389
|
+
cleanUrls: true,
|
|
3390
|
+
headers: [
|
|
3391
|
+
{
|
|
3392
|
+
source: "/duckdb/(.*)",
|
|
3393
|
+
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }]
|
|
3394
|
+
},
|
|
3395
|
+
{
|
|
3396
|
+
source: "/assets/(.*)",
|
|
3397
|
+
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }]
|
|
3398
|
+
}
|
|
3399
|
+
]
|
|
3400
|
+
},
|
|
3401
|
+
null,
|
|
3402
|
+
2
|
|
3403
|
+
) + "\n"
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3406
|
+
const modelFiles = inlineModelFiles(root);
|
|
3407
|
+
const outRel = path7.relative(root, outDir).split(path7.sep).join("/");
|
|
3408
|
+
const usedFiles = reachableModelFiles(
|
|
3409
|
+
modelFiles,
|
|
3410
|
+
dashboards.map((d) => d.entryFile).filter((f) => !!f)
|
|
3411
|
+
);
|
|
3412
|
+
const { map: tableFiles, copies } = tableFilePlan(usedFiles, outRel);
|
|
3413
|
+
for (const rel of copies) {
|
|
3414
|
+
const from = path7.join(root, rel);
|
|
3415
|
+
if (!fs6.existsSync(from)) {
|
|
3416
|
+
throw new Error(
|
|
3417
|
+
`model reads '${rel}' but ${from} does not exist.
|
|
3418
|
+
Data files are referenced by a path relative to the project root.`
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
const to = path7.join(outDir, rel);
|
|
3422
|
+
fs6.mkdirSync(path7.dirname(to), { recursive: true });
|
|
3423
|
+
fs6.copyFileSync(from, to);
|
|
3424
|
+
}
|
|
3425
|
+
const copiedData = copies;
|
|
3426
|
+
for (const stale of priorData) {
|
|
3427
|
+
if (copies.includes(stale)) continue;
|
|
3428
|
+
fs6.rmSync(path7.join(outDir, stale), { force: true });
|
|
3429
|
+
try {
|
|
3430
|
+
fs6.rmdirSync(path7.dirname(path7.join(outDir, stale)));
|
|
3431
|
+
} catch {
|
|
3432
|
+
}
|
|
3433
|
+
console.log(` removed stale ${stale}`);
|
|
3434
|
+
}
|
|
3435
|
+
fs6.writeFileSync(manifestPath, JSON.stringify({ dataFiles: copies }, null, 2) + "\n");
|
|
3436
|
+
fs6.writeFileSync(
|
|
3437
|
+
path7.join(outDir, "assets", "model-files.js"),
|
|
3438
|
+
`window.__MODEL_FILES__ = ${JSON.stringify(modelFiles)};
|
|
3439
|
+
window.__TABLE_FILES__ = ${JSON.stringify(tableFiles)};
|
|
3440
|
+
` + (selfHostDuckdb ? `window.__DUCKDB_BASE__ = "./duckdb/";
|
|
3441
|
+
` : "")
|
|
3442
|
+
);
|
|
3443
|
+
const landing = ["jsx", "tsx"].map((ext) => path7.join(root, "dashboards", `index.${ext}`)).find((f) => fs6.existsSync(f));
|
|
3444
|
+
const runtimeDir = resolveRuntimeDir();
|
|
3445
|
+
const runtimeIndex = path7.join(runtimeDir, "index.ts");
|
|
3446
|
+
const wasmEntry = path7.join(runtimeDir, "..", "frame-wasm-entry.tsx");
|
|
3447
|
+
const byEntry = new Map(dashboards.map((d) => [`vdash:${d.name}`, d]));
|
|
3448
|
+
await esbuild3.build({
|
|
3449
|
+
entryPoints: Object.fromEntries(dashboards.map((d) => [d.name, `vdash:${d.name}`])),
|
|
3450
|
+
bundle: true,
|
|
3451
|
+
splitting: true,
|
|
3452
|
+
format: "esm",
|
|
3453
|
+
outdir: path7.join(outDir, "assets"),
|
|
3454
|
+
minify: true,
|
|
3455
|
+
logLevel: "warning",
|
|
3456
|
+
...browserBuildBase(),
|
|
3457
|
+
plugins: [
|
|
3458
|
+
{
|
|
3459
|
+
name: "virtual-dashboard-per-entry",
|
|
3460
|
+
setup(b) {
|
|
3461
|
+
b.onResolve({ filter: /^vdash:/ }, (args) => ({ path: args.path, namespace: "vdash" }));
|
|
3462
|
+
b.onLoad({ filter: /.*/, namespace: "vdash" }, (args) => {
|
|
3463
|
+
const dash = byEntry.get(args.path);
|
|
3464
|
+
if (!dash) throw new Error(`no dashboard for entry ${args.path}`);
|
|
3465
|
+
const imp = dash.tsxPath ? `import Dashboard from ${JSON.stringify(dash.tsxPath)};` : `const Dashboard = null;`;
|
|
3466
|
+
return {
|
|
3467
|
+
contents: `${imp}
|
|
3468
|
+
import { boot } from ${JSON.stringify(wasmEntry)};
|
|
3469
|
+
boot(Dashboard);
|
|
3470
|
+
`,
|
|
3471
|
+
loader: "js",
|
|
3472
|
+
// Resolve the component's own imports (react, @malloyyo/dashboard)
|
|
3473
|
+
// from the model repo's directory, matching `dashboard dev`.
|
|
3474
|
+
resolveDir: path7.dirname(dash.tsxPath ?? path7.join(root, "dashboards", "x"))
|
|
3475
|
+
};
|
|
3476
|
+
});
|
|
3477
|
+
b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
|
|
3478
|
+
}
|
|
3479
|
+
},
|
|
3480
|
+
hostAliasPlugin
|
|
3481
|
+
]
|
|
3482
|
+
});
|
|
3483
|
+
fs6.writeFileSync(path7.join(outDir, "assets", "site.css"), SITE_CSS);
|
|
3484
|
+
for (const d of dashboards) {
|
|
3485
|
+
let specs = [];
|
|
3486
|
+
let tileSpecs;
|
|
3487
|
+
if (d.tiles && d.entryFile) {
|
|
3488
|
+
const t = await runner.dashboardTiles(d.entryFile, d.tiles);
|
|
3489
|
+
specs = t.union;
|
|
3490
|
+
tileSpecs = t.tiles;
|
|
3491
|
+
} else {
|
|
3492
|
+
const got = d.entryFile ? await runner.givensForQueryIn(d.entryFile, d.query) : await runner.givensForQuery(d.query);
|
|
3493
|
+
if (!got.ok) throw new Error(`dashboard ${d.name}: ${got.error}`);
|
|
3494
|
+
specs = got.givens;
|
|
3495
|
+
}
|
|
3496
|
+
fs6.writeFileSync(path7.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls));
|
|
3497
|
+
}
|
|
3498
|
+
fs6.writeFileSync(path7.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls));
|
|
3499
|
+
if (landing) {
|
|
3500
|
+
await esbuild3.build({
|
|
3501
|
+
stdin: {
|
|
3502
|
+
contents: `import React from "react";
|
|
3503
|
+
import { createRoot } from "react-dom/client";
|
|
3504
|
+
import Landing from ${JSON.stringify(landing)};
|
|
3505
|
+
createRoot(document.getElementById("root")).render(React.createElement(Landing, { dashboards: window.__DASHBOARDS__ || [] }));
|
|
3506
|
+
`,
|
|
3507
|
+
resolveDir: path7.dirname(landing),
|
|
3508
|
+
loader: "js"
|
|
3509
|
+
},
|
|
3510
|
+
bundle: true,
|
|
3511
|
+
format: "esm",
|
|
3512
|
+
minify: true,
|
|
3513
|
+
outfile: path7.join(outDir, "assets", "index.js"),
|
|
3514
|
+
logLevel: "warning",
|
|
3515
|
+
// Same base as the dashboard pass. A landing page needs no Malloy today,
|
|
3516
|
+
// but one that imported anything reaching antlr4ts would otherwise die at
|
|
3517
|
+
// load with "process is not defined" from the util polyfill.
|
|
3518
|
+
...browserBuildBase(),
|
|
3519
|
+
plugins: [hostAliasPlugin]
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
const duck = selfHostDuckdb ? copyDuckDBAssets(outDir) : [];
|
|
3523
|
+
if (!selfHostDuckdb) fs6.rmSync(path7.join(outDir, "duckdb"), { recursive: true, force: true });
|
|
3524
|
+
const bytes = (p) => fs6.statSync(p).size;
|
|
3525
|
+
const assetDir = path7.join(outDir, "assets");
|
|
3526
|
+
const jsTotal = fs6.readdirSync(assetDir).filter((f) => f.endsWith(".js")).reduce((a, f) => a + bytes(path7.join(assetDir, f)), 0);
|
|
3527
|
+
console.log(`
|
|
3528
|
+
bundled ${dashboards.length} dashboard(s) \u2192 ${path7.relative(process.cwd(), outDir) || "."}/`);
|
|
3529
|
+
for (const d of dashboards) console.log(` ${d.name}.html ${d.title ?? ""}`);
|
|
3530
|
+
console.log(`
|
|
3531
|
+
js ${(jsTotal / 1048576).toFixed(2)} MB`);
|
|
3532
|
+
console.log(
|
|
3533
|
+
selfHostDuckdb ? ` duckdb ${duck.length} assets (self-hosted)` : ` duckdb jsDelivr CDN (nothing copied)`
|
|
3534
|
+
);
|
|
3535
|
+
console.log(` model ${Object.keys(modelFiles).length} .malloy files inlined`);
|
|
3536
|
+
if (copiedData.length) {
|
|
3537
|
+
const mb = copiedData.reduce((a, r) => a + fs6.statSync(path7.join(root, r)).size, 0) / 1048576;
|
|
3538
|
+
console.log(` data ${copiedData.length} file(s) copied, ${mb.toFixed(1)} MB (same-origin)`);
|
|
3539
|
+
}
|
|
3540
|
+
console.log(
|
|
3541
|
+
target === "pages" ? `
|
|
3542
|
+
Publish: commit ${path7.basename(outDir)}/ and point GitHub Pages at it.` : `
|
|
3543
|
+
Publish: deploy ${path7.basename(outDir)}/ to Vercel (vercel.json written: clean URLs + asset caching).`
|
|
3544
|
+
);
|
|
3545
|
+
if (opts.serve !== false) {
|
|
3546
|
+
await serveStatic(outDir, opts.port ?? 4180);
|
|
3547
|
+
await new Promise(() => {
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3067
3552
|
// src/init.ts
|
|
3068
|
-
import
|
|
3069
|
-
import
|
|
3553
|
+
import fs7 from "node:fs";
|
|
3554
|
+
import path8 from "node:path";
|
|
3070
3555
|
var AUTHOR_MCP = {
|
|
3071
3556
|
mcpServers: {
|
|
3072
3557
|
// No -C: the server roots at the launch cwd (the project dir), so this file
|
|
@@ -3086,11 +3571,11 @@ function exportableNames(src) {
|
|
|
3086
3571
|
return [...names];
|
|
3087
3572
|
}
|
|
3088
3573
|
function scaffoldIndex(root) {
|
|
3089
|
-
const indexPath =
|
|
3090
|
-
if (
|
|
3574
|
+
const indexPath = path8.join(root, "index.malloy");
|
|
3575
|
+
if (fs7.existsSync(indexPath)) {
|
|
3091
3576
|
return { wrote: false, note: "index.malloy already exists \u2014 left as-is" };
|
|
3092
3577
|
}
|
|
3093
|
-
const models =
|
|
3578
|
+
const models = fs7.readdirSync(root).filter((f) => f.endsWith(".malloy") && f !== "index.malloy").sort();
|
|
3094
3579
|
if (models.length === 0) {
|
|
3095
3580
|
return { wrote: false, note: "no .malloy files found \u2014 skipped index.malloy" };
|
|
3096
3581
|
}
|
|
@@ -3103,7 +3588,7 @@ function scaffoldIndex(root) {
|
|
|
3103
3588
|
];
|
|
3104
3589
|
let anyNames = false;
|
|
3105
3590
|
for (const file of models) {
|
|
3106
|
-
const names = exportableNames(
|
|
3591
|
+
const names = exportableNames(fs7.readFileSync(path8.join(root, file), "utf8"));
|
|
3107
3592
|
if (names.length === 0) {
|
|
3108
3593
|
blocks.push(`// ${file}: no top-level source/query/given detected \u2014 add exports by hand`);
|
|
3109
3594
|
continue;
|
|
@@ -3114,24 +3599,24 @@ function scaffoldIndex(root) {
|
|
|
3114
3599
|
blocks.push(`export { ${list} }`);
|
|
3115
3600
|
blocks.push("");
|
|
3116
3601
|
}
|
|
3117
|
-
|
|
3602
|
+
fs7.writeFileSync(indexPath, blocks.join("\n") + "\n");
|
|
3118
3603
|
return {
|
|
3119
3604
|
wrote: true,
|
|
3120
3605
|
note: anyNames ? `wrote index.malloy re-exporting ${models.length} model file(s) \u2014 REVIEW it` : "wrote index.malloy skeleton \u2014 no exports detected, fill them in by hand"
|
|
3121
3606
|
};
|
|
3122
3607
|
}
|
|
3123
3608
|
async function initCmd(dir) {
|
|
3124
|
-
const root =
|
|
3125
|
-
if (!
|
|
3609
|
+
const root = path8.resolve(dir);
|
|
3610
|
+
if (!fs7.existsSync(root) || !fs7.statSync(root).isDirectory()) {
|
|
3126
3611
|
throw new Error(`not a directory: ${root}`);
|
|
3127
3612
|
}
|
|
3128
|
-
const mcpPath =
|
|
3129
|
-
if (
|
|
3613
|
+
const mcpPath = path8.join(root, ".mcp.json");
|
|
3614
|
+
if (fs7.existsSync(mcpPath)) {
|
|
3130
3615
|
console.log(`\u2022 .mcp.json exists \u2014 leaving it. For author-by-default it should be:`);
|
|
3131
3616
|
console.log(` ${JSON.stringify(AUTHOR_MCP.mcpServers.malloyyo_author)}`);
|
|
3132
3617
|
console.log(` (server key "malloyyo_author", command "malloyyo mcp --develop").`);
|
|
3133
3618
|
} else {
|
|
3134
|
-
|
|
3619
|
+
fs7.writeFileSync(mcpPath, JSON.stringify(AUTHOR_MCP, null, 2) + "\n");
|
|
3135
3620
|
console.log(`\u2713 wrote .mcp.json \u2014 \`cd ${dir} && claude\` now opens in AUTHOR mode`);
|
|
3136
3621
|
}
|
|
3137
3622
|
const idx = scaffoldIndex(root);
|
|
@@ -3145,22 +3630,22 @@ async function initCmd(dir) {
|
|
|
3145
3630
|
|
|
3146
3631
|
// src/launch.ts
|
|
3147
3632
|
import { spawn as spawn2 } from "node:child_process";
|
|
3148
|
-
import
|
|
3633
|
+
import fs8 from "node:fs";
|
|
3149
3634
|
import os from "node:os";
|
|
3150
|
-
import
|
|
3635
|
+
import path9 from "node:path";
|
|
3151
3636
|
var SURFACE_FLAG = { author: "--develop", test: "--explore" };
|
|
3152
3637
|
var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
|
|
3153
3638
|
async function launchCmd(mode, opts) {
|
|
3154
|
-
const root =
|
|
3155
|
-
const tmpDir =
|
|
3156
|
-
const cfgPath =
|
|
3639
|
+
const root = path9.resolve(opts.root ?? process.cwd());
|
|
3640
|
+
const tmpDir = fs8.mkdtempSync(path9.join(os.tmpdir(), "malloyyo-launch-"));
|
|
3641
|
+
const cfgPath = path9.join(tmpDir, "mcp.json");
|
|
3157
3642
|
const cfg = {
|
|
3158
3643
|
mcpServers: {
|
|
3159
3644
|
// Absolute -C: an ephemeral config, so pinning the root is robust.
|
|
3160
3645
|
[SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
|
|
3161
3646
|
}
|
|
3162
3647
|
};
|
|
3163
|
-
|
|
3648
|
+
fs8.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
3164
3649
|
const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
|
|
3165
3650
|
process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
|
|
3166
3651
|
`);
|
|
@@ -3179,11 +3664,11 @@ async function launchCmd(mode, opts) {
|
|
|
3179
3664
|
});
|
|
3180
3665
|
child.on("exit", () => resolve3());
|
|
3181
3666
|
});
|
|
3182
|
-
|
|
3667
|
+
fs8.rmSync(tmpDir, { recursive: true, force: true });
|
|
3183
3668
|
}
|
|
3184
3669
|
|
|
3185
3670
|
// package.json
|
|
3186
|
-
var version = "0.2.
|
|
3671
|
+
var version = "0.2.21";
|
|
3187
3672
|
|
|
3188
3673
|
// src/index.ts
|
|
3189
3674
|
function shortSha(sha) {
|
|
@@ -3290,10 +3775,35 @@ program.command("author").option("-C, --root <dir>", "project root (default: cur
|
|
|
3290
3775
|
program.command("test").option("-C, --root <dir>", "project root (default: current directory)").description("launch Claude wired ONLY to the explore surface \u2014 the claude.ai web preview").action(async (opts) => {
|
|
3291
3776
|
await launchCmd("test", opts);
|
|
3292
3777
|
});
|
|
3293
|
-
program.command("dashboard").argument("<action>", "action to run (
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
});
|
|
3778
|
+
program.command("dashboard").argument("<action>", "action to run (dev | bundle)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on (dev)", "4173").option("-o, --out <dir>", "output directory (bundle)", "docs").option("--title <title>", "site title (bundle; default: project directory name)").option("--target <target>", "deploy target: pages | vercel (bundle)", "pages").option("--duckdb <source>", "DuckDB binaries: cdn | bundled (bundle)", "cdn").option("--no-serve", "bundle only; don't serve the result (bundle)").description("preview dashboards locally (dev), or build a static site from them (bundle)").action(
|
|
3779
|
+
async (action, opts) => {
|
|
3780
|
+
if (action === "dev") {
|
|
3781
|
+
await serveDashboard({ root: opts.root, port: Number(opts.port) });
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3784
|
+
if (action === "bundle") {
|
|
3785
|
+
if (opts.target !== "pages" && opts.target !== "vercel") {
|
|
3786
|
+
throw new Error(`unknown --target '${opts.target}' (expected: pages | vercel)`);
|
|
3787
|
+
}
|
|
3788
|
+
if (opts.duckdb !== "cdn" && opts.duckdb !== "bundled") {
|
|
3789
|
+
throw new Error(`unknown --duckdb '${opts.duckdb}' (expected: cdn | bundled)`);
|
|
3790
|
+
}
|
|
3791
|
+
await bundleDashboards({
|
|
3792
|
+
root: opts.root,
|
|
3793
|
+
out: opts.out,
|
|
3794
|
+
title: opts.title,
|
|
3795
|
+
serve: opts.serve,
|
|
3796
|
+
target: opts.target,
|
|
3797
|
+
duckdb: opts.duckdb,
|
|
3798
|
+
// `dashboard dev` owns 4173/4174; default the bundle preview clear of
|
|
3799
|
+
// both so you can run the two side by side.
|
|
3800
|
+
port: opts.port === "4173" ? 4180 : Number(opts.port)
|
|
3801
|
+
});
|
|
3802
|
+
return;
|
|
3803
|
+
}
|
|
3804
|
+
throw new Error(`unknown dashboard action '${action}' (expected: dev | bundle)`);
|
|
3805
|
+
}
|
|
3806
|
+
);
|
|
3297
3807
|
program.parseAsync().catch((err) => {
|
|
3298
3808
|
console.error(err instanceof Error ? err.message : String(err));
|
|
3299
3809
|
process.exit(1);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The URL <-> givens encoding, in ONE place.
|
|
2
|
+
//
|
|
3
|
+
// Both hosts need it and they used to have separate copies: `dashboard dev`
|
|
4
|
+
// parsed the query server-side, and the static bundle re-implemented it in an
|
|
5
|
+
// inline script. The copies drifted — the static one stripped the `$` prefix
|
|
6
|
+
// that the runtime keys off (runtime.tsx: `if (k[0] === "$")`), so shareable
|
|
7
|
+
// links silently fell back to defaults. That class of bug is the reason this
|
|
8
|
+
// module exists; import it instead of writing the loop again.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately dependency-free (no React, no node builtins) so the Node dev
|
|
11
|
+
// server and the browser bundle can share the same file.
|
|
12
|
+
|
|
13
|
+
/** Query string -> given values. Keys KEEP their `$` prefix — the runtime
|
|
14
|
+
requires it — and `d` (the dashboard selector) is dropped. */
|
|
15
|
+
export function givensFromSearch(search: string): Record<string, string> {
|
|
16
|
+
const g: Record<string, string> = {};
|
|
17
|
+
for (const [k, v] of new URLSearchParams(search)) {
|
|
18
|
+
if (k !== "d") g[k] = v;
|
|
19
|
+
}
|
|
20
|
+
return g;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Given values -> `$`-prefixed query params, skipping empties. Accepts keys
|
|
24
|
+
with or without the prefix so callers can pass either the runtime's bare
|
|
25
|
+
names or values already read out of a URL. */
|
|
26
|
+
export function givensToParams(givens: Record<string, unknown>): URLSearchParams {
|
|
27
|
+
const p = new URLSearchParams();
|
|
28
|
+
for (const [k, v] of Object.entries(givens ?? {})) {
|
|
29
|
+
if (v == null || String(v) === "") continue;
|
|
30
|
+
p.set(k.charAt(0) === "$" ? k : "$" + k, String(v));
|
|
31
|
+
}
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The dashboard switcher bar, in ONE place.
|
|
2
|
+
//
|
|
3
|
+
// `dashboard dev` and every `dashboard bundle` target render the same bar; only
|
|
4
|
+
// the LINK SHAPE differs (`/?d=name` for the dev server, `./name.html` or a
|
|
5
|
+
// clean `/name` for the bundle targets). That difference is a callback, not a
|
|
6
|
+
// reason to keep two copies — the previous two copies had already drifted in
|
|
7
|
+
// styling, and the same drift is what produced the givens-URL bug.
|
|
8
|
+
//
|
|
9
|
+
// Dependency-free so the Node dev server and the emitted static site share it.
|
|
10
|
+
|
|
11
|
+
export interface NavDashboard {
|
|
12
|
+
name: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const esc = (s: string) =>
|
|
17
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
18
|
+
|
|
19
|
+
/** The Malloy mark, inlined rather than shipped as a file so a site stays
|
|
20
|
+
self-contained and a subpath deploy (GitHub Pages) can't 404 it. */
|
|
21
|
+
export const LOGO = `<svg viewBox="0 0 240 240" width="20" height="20" aria-hidden="true" focusable="false">\
|
|
22
|
+
<g transform="translate(8, 44)" fill-rule="nonzero" stroke-width="10">\
|
|
23
|
+
<path d="M66.8164971,8.04981927 C70.8741349,0.502462438 80.0934949,-2.2220379 87.4085141,1.96447745 C94.5645112,6.05998159 97.2471437,15.2521193 93.5616777,22.7156028 L93.3065249,23.2105325 L28.3940308,143.950181 C24.3363931,151.497538 15.117033,154.222038 7.80201383,150.035523 C0.646016803,145.940018 -2.0366157,136.747881 1.64885028,129.284397 L1.90400302,128.789468 L66.8164971,8.04981927 Z" stroke="#1573A1" fill="#1573A1"/>\
|
|
24
|
+
<path d="M192.878294,8.04981927 C196.98997,0.0579198953 207.437352,-1.75261923 213.470311,1.96447745 C219.503269,5.68157413 221.641451,12.7091374 223.25,15.6301759 L219.368321,23.2105325 L154.455827,143.950181 C150.39819,151.497538 141.17883,154.222038 133.86381,150.035523 C126.707813,145.940018 124.025181,136.747881 127.710647,129.284397 L127.9658,128.789468 L192.878294,8.04981927 Z" stroke="#FBBC04" fill="#FBBC04" transform="translate(174.655898, 76.056961) scale(-1, 1) translate(-174.655898, -76.056961)"/>\
|
|
25
|
+
<path d="M129.943475,8.04981927 C134.001113,0.502462438 143.220473,-2.2220379 150.535492,1.96447745 C157.691489,6.05998159 160.374122,15.2521193 156.688656,22.7156028 L156.433503,23.2105325 L91.5210087,143.950181 C87.463371,151.497538 78.2440109,154.222038 70.9289918,150.035523 C63.7729947,145.940018 61.0903622,136.747881 64.7758282,129.284397 L65.0309809,128.789468 L129.943475,8.04981927 Z" stroke="#E37400" fill="#E37400"/>\
|
|
26
|
+
<path d="M132.146094,8.04981927 C136.203731,0.502462438 145.423091,-2.2220379 152.738111,1.96447745 C159.894108,6.05998159 162.57674,15.2521193 158.891274,22.7156028 L158.636121,23.2105325 L93.7236274,143.950181 C89.6659896,151.497538 80.4466296,154.222038 73.1316104,150.035523 C65.9756133,145.940018 63.2929808,136.747881 66.9784468,129.284397 L67.2335996,128.789468 L132.146094,8.04981927 Z" stroke="#11B5CB" fill="#11B5CB" transform="translate(112.934861, 76.000000) scale(-1, 1) translate(-112.934861, -76.000000)"/>\
|
|
27
|
+
</g></svg>`;
|
|
28
|
+
|
|
29
|
+
/** Deliberately black in both light and dark schemes — this is a brand bar, not
|
|
30
|
+
page chrome, so it shouldn't invert with the color scheme. */
|
|
31
|
+
export const NAV_CSS = `
|
|
32
|
+
.dash-nav{display:flex;gap:4px;align-items:center;padding:8px 14px;background:#000;font:13px system-ui,-apple-system,sans-serif;flex-wrap:wrap}
|
|
33
|
+
/* The home icon is an <a> too, so it opts OUT of the switcher-link padding and
|
|
34
|
+
keeps its own square hit area. */
|
|
35
|
+
.dash-nav a.brand{display:inline-flex;align-items:center;justify-content:center;color:#9aa1ac;padding:5px;border-radius:6px;text-decoration:none}
|
|
36
|
+
.dash-nav a.brand:hover{background:#1f232a;color:#fff}
|
|
37
|
+
.dash-nav .brand svg{display:block}
|
|
38
|
+
.dash-nav .sep{width:1px;align-self:stretch;background:#2c3038;margin:0 10px}
|
|
39
|
+
.dash-nav a{padding:4px 10px;border-radius:6px;text-decoration:none;color:#c9ced6}
|
|
40
|
+
.dash-nav a:hover{background:#1f232a;color:#fff}
|
|
41
|
+
.dash-nav a.on{background:#fff;color:#000;font-weight:550}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
/** Render the bar. `href` maps a dashboard name to a link for THIS host — the
|
|
45
|
+
only thing that varies across dev / pages / vercel. The brand shows even for
|
|
46
|
+
a single dashboard; only the switcher links are conditional. */
|
|
47
|
+
export const MALLOYYO_REPO = "https://github.com/malloydata/malloyyo";
|
|
48
|
+
|
|
49
|
+
/** Home, back to the landing page. Attribution lives on that page rather than
|
|
50
|
+
in the bar — the bar should be navigation. */
|
|
51
|
+
const HOME_ICON = `<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" \
|
|
52
|
+
stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">\
|
|
53
|
+
<path d="M3 10.2 12 3.5l9 6.7"/><path d="M5.2 8.9V20h13.6V8.9"/><path d="M9.6 20v-6.2h4.8V20"/></svg>`;
|
|
54
|
+
|
|
55
|
+
export function navHtml(
|
|
56
|
+
active: string,
|
|
57
|
+
all: NavDashboard[],
|
|
58
|
+
href: (name: string) => string,
|
|
59
|
+
homeHref = "./",
|
|
60
|
+
): string {
|
|
61
|
+
const brand =
|
|
62
|
+
`<a class="brand" href="${esc(homeHref)}" title="Home" aria-label="Home">${HOME_ICON}</a>`;
|
|
63
|
+
if (all.length <= 1) return `<nav class="dash-nav">${brand}</nav>`;
|
|
64
|
+
const links = all
|
|
65
|
+
.map(
|
|
66
|
+
(x) =>
|
|
67
|
+
`<a href="${esc(href(x.name))}"${x.name === active ? ' class="on"' : ""}>` +
|
|
68
|
+
`${esc(x.title || x.name)}</a>`,
|
|
69
|
+
)
|
|
70
|
+
.join("");
|
|
71
|
+
return `<nav class="dash-nav">${brand}<span class="sep"></span>${links}</nav>`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// antlr4ts (Malloy's parser runtime) does `require("assert")` in 13 files and
|
|
2
|
+
// only ever calls it as a bare function. Resolving that to the npm `assert`
|
|
3
|
+
// polyfill drags in `util`, which references `process` and dies in a browser
|
|
4
|
+
// with "process is not defined". This is the whole API surface actually used.
|
|
5
|
+
function assert(condition, message) {
|
|
6
|
+
if (!condition) throw new Error(message || "assertion failed");
|
|
7
|
+
}
|
|
8
|
+
assert.ok = assert;
|
|
9
|
+
assert.equal = (a, b, m) => assert(a == b, m || `${a} != ${b}`);
|
|
10
|
+
assert.strictEqual = (a, b, m) => assert(a === b, m || `${a} !== ${b}`);
|
|
11
|
+
assert.notEqual = (a, b, m) => assert(a != b, m || `${a} == ${b}`);
|
|
12
|
+
assert.notStrictEqual = (a, b, m) => assert(a !== b, m || `${a} === ${b}`);
|
|
13
|
+
assert.fail = (m) => assert(false, m);
|
|
14
|
+
assert.default = assert;
|
|
15
|
+
|
|
16
|
+
module.exports = assert;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// antlr4ts/misc/BitSet.js requires("util") for exactly one thing: the
|
|
2
|
+
// `util.inspect.custom` symbol, used to pretty-print in a Node REPL. The npm
|
|
3
|
+
// `util` polyfill that satisfies it is large and references `process`.
|
|
4
|
+
const inspect = (v) => String(v);
|
|
5
|
+
inspect.custom = Symbol.for("nodejs.util.inspect.custom");
|
|
6
|
+
|
|
7
|
+
module.exports = { inspect, default: { inspect } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloydata/malloyyo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"description": "Publish Malloy models to a Malloyyo instance",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"prepublishOnly": "npm run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@duckdb/duckdb-wasm": "1.33.1-dev45.0",
|
|
36
|
+
"@malloydata/db-duckdb": "^0.0.425",
|
|
35
37
|
"@malloydata/malloy": "^0.0.425",
|
|
36
38
|
"@malloydata/malloy-connections": "^0.0.425",
|
|
37
39
|
"@malloydata/malloy-filter": "^0.0.425",
|