@malloydata/malloyyo 0.2.35 → 0.2.37
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/runtime.tsx +76 -0
- package/dist/index.js +180 -102
- package/dist/shared/nav.ts +22 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Injected frame globals (read lazily — script order differs between hosts):
|
|
12
12
|
// window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
|
|
13
|
+
// window.__DASHBOARDS__ [{ name, title, description?, href }] (the siblings, nav order)
|
|
13
14
|
// window.__GIVENS__ given specs introspected from the model's given: decls
|
|
14
15
|
// window.__INITIAL_GIVENS__ URL-seeded given values ($-prefixed) for shareable links
|
|
15
16
|
// window.__INITIAL_URLSTATE__ URL-seeded useUrlState view-state (~-prefixed)
|
|
@@ -33,6 +34,13 @@ import { combineTiles } from "./combine";
|
|
|
33
34
|
export { filters };
|
|
34
35
|
|
|
35
36
|
export const dashboardInfo = () => window.__DASHBOARD__ || {};
|
|
37
|
+
|
|
38
|
+
/** The dashboard's SIBLINGS: `[{ name, title, description?, href }]`, in nav
|
|
39
|
+
order, with this one excluded. Every host injects it (dev server, hosted
|
|
40
|
+
frame, static bundle), so a component that links to other dashboards — the
|
|
41
|
+
About page's card list, a "see also" — is written once and works on all
|
|
42
|
+
three. Empty when a host supplies nothing, so reading it is always safe. */
|
|
43
|
+
export const dashboardList = () => window.__DASHBOARDS__ || [];
|
|
36
44
|
export const givenSpecs = () => window.__GIVENS__ || [];
|
|
37
45
|
|
|
38
46
|
// ── host bridge ─────────────────────────────────────────────────────
|
|
@@ -936,6 +944,7 @@ function Root({ Dashboard, extraProps }) {
|
|
|
936
944
|
<UrlStateCtx.Provider value={urlCtx}>
|
|
937
945
|
<Dashboard
|
|
938
946
|
dashboard={dashboardInfo()}
|
|
947
|
+
dashboards={dashboardList()}
|
|
939
948
|
givenSpecs={givenSpecs()}
|
|
940
949
|
givens={committed}
|
|
941
950
|
setGiven={setGiven}
|
|
@@ -966,6 +975,20 @@ function Root({ Dashboard, extraProps }) {
|
|
|
966
975
|
// stays legible; override --dash-panel-bg if your renderer output is dark-safe.
|
|
967
976
|
const THEME_CSS = `
|
|
968
977
|
:root {
|
|
978
|
+
/* The static site's variable names, aliased onto the theme.
|
|
979
|
+
A written page (the About page, most obviously) is authored against the
|
|
980
|
+
bundle's site.css, which names them --bg/--fg/--card/--line/--muted/--accent.
|
|
981
|
+
The same component also runs here — under the dev server and in the hosted
|
|
982
|
+
frame — where only --dash-* exists, so without these it renders with
|
|
983
|
+
transparent cards and invisible borders: technically fine, visibly broken.
|
|
984
|
+
Defined first and with plain names so a page may still override them. */
|
|
985
|
+
--bg: var(--dash-bg, #ffffff);
|
|
986
|
+
--fg: var(--dash-fg, #111418);
|
|
987
|
+
--card: var(--dash-panel-bg, #ffffff);
|
|
988
|
+
--line: var(--dash-border, #e5e7eb);
|
|
989
|
+
--muted: var(--dash-muted, #6b7280);
|
|
990
|
+
--accent: var(--dash-accent, #1573a1);
|
|
991
|
+
|
|
969
992
|
--dash-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
970
993
|
--dash-bg: #ffffff;
|
|
971
994
|
--dash-fg: #171717;
|
|
@@ -1029,8 +1052,61 @@ function injectTheme(bodyReset) {
|
|
|
1029
1052
|
`rootEl` (defaults to #root, the sandboxed iframe's mount node). The tag-only
|
|
1030
1053
|
in-page host passes its own container so the dashboard mounts directly in the
|
|
1031
1054
|
trusted page — no iframe. */
|
|
1055
|
+
/**
|
|
1056
|
+
* Route a click on a sibling link through the host's navigate bridge.
|
|
1057
|
+
*
|
|
1058
|
+
* A written page (the About page) links to its siblings as ordinary anchors —
|
|
1059
|
+
* that is what makes the same component work as a plain static page in a
|
|
1060
|
+
* `dashboard bundle`. Inside a framed host it is NOT a plain page: the document
|
|
1061
|
+
* is a sandboxed iframe on its own origin, so a relative href navigates the
|
|
1062
|
+
* FRAME to a URL that does not exist there (the dev server answers "not found"
|
|
1063
|
+
* on the frame port), and a top-level href would need allow-top-navigation,
|
|
1064
|
+
* which the sandbox deliberately withholds.
|
|
1065
|
+
*
|
|
1066
|
+
* The bridge already solves this for drill: only the trusted parent knows the
|
|
1067
|
+
* environment's dashboard shape (hosted /datasets/:id/dashboard/:slug vs local
|
|
1068
|
+
* /?d=slug). So intercept the click and hand the parent a NAME, exactly as drill
|
|
1069
|
+
* does. The anchor keeps its href, so the static build is untouched and the link
|
|
1070
|
+
* still shows a real target on hover.
|
|
1071
|
+
*
|
|
1072
|
+
* Deliberately capture-phase and non-destructive: modified clicks (new tab,
|
|
1073
|
+
* download) and anchors that aren't siblings fall through untouched.
|
|
1074
|
+
*/
|
|
1075
|
+
/** Installed once per document. `mount()` runs again on every client-side
|
|
1076
|
+
navigation between tag-only dashboards (TagOnlyDashboard unmounts the React
|
|
1077
|
+
root and re-mounts), and a listener added per mount is never removed — so
|
|
1078
|
+
without this each visit would add another handler and one click would fire
|
|
1079
|
+
navigate once per handler. injectTheme guards itself the same way. */
|
|
1080
|
+
let siblingLinksInstalled = false;
|
|
1081
|
+
|
|
1082
|
+
function interceptSiblingLinks() {
|
|
1083
|
+
if (typeof document === "undefined" || siblingLinksInstalled) return;
|
|
1084
|
+
siblingLinksInstalled = true;
|
|
1085
|
+
document.addEventListener(
|
|
1086
|
+
"click",
|
|
1087
|
+
(e: any) => {
|
|
1088
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
1089
|
+
const a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
1090
|
+
if (!a || a.target === "_blank" || a.hasAttribute("download")) return;
|
|
1091
|
+
// dashboardList() is read HERE, per click, not captured at mount: the
|
|
1092
|
+
// header's rule is that injected globals are read lazily because script
|
|
1093
|
+
// order differs between hosts, and a host that sets __DASHBOARDS__ after
|
|
1094
|
+
// the bundle runs would otherwise get no interception at all — silently,
|
|
1095
|
+
// which is the very failure this exists to prevent.
|
|
1096
|
+
// Matched on the authored attribute, which is what the host injected.
|
|
1097
|
+
const href = a.getAttribute("href");
|
|
1098
|
+
const hit = dashboardList().find((d: any) => d && d.href === href);
|
|
1099
|
+
if (!hit) return;
|
|
1100
|
+
e.preventDefault();
|
|
1101
|
+
host.navigate(hit.name, {});
|
|
1102
|
+
},
|
|
1103
|
+
true,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1032
1107
|
export function mount(Dashboard, extraProps, rootEl: any = null, opts: any = {}) {
|
|
1033
1108
|
injectTheme(opts.bodyReset !== false);
|
|
1109
|
+
interceptSiblingLinks();
|
|
1034
1110
|
window.addEventListener("error", (e) => {
|
|
1035
1111
|
if (isBenign(e && e.message)) return;
|
|
1036
1112
|
showFatal((e.error && e.error.stack) || e.message);
|
package/dist/index.js
CHANGED
|
@@ -558,6 +558,9 @@ function stripScalarArrayValue(parent, groups) {
|
|
|
558
558
|
if (!isScalarArray(parent)) return groups;
|
|
559
559
|
return { ...groups, dimensions: groups.dimensions.filter((f) => f.name !== "value") };
|
|
560
560
|
}
|
|
561
|
+
function rawDef(structDefFields, name) {
|
|
562
|
+
return structDefFields.find((x) => (x.as ?? x.name) === name);
|
|
563
|
+
}
|
|
561
564
|
function fieldKind(af, structDefFields) {
|
|
562
565
|
const raw = structDefFields.find((x) => x.name === af.name);
|
|
563
566
|
const et = raw?.expressionType;
|
|
@@ -617,6 +620,7 @@ function walkFields(fields, structDefFields, depth, ctx, anon) {
|
|
|
617
620
|
const mLoc = f.location;
|
|
618
621
|
const local = isLocal(mLoc, ctx.rootUri);
|
|
619
622
|
const loc = local ? toLoc(mLoc) : void 0;
|
|
623
|
+
const access = rawDef(structDefFields, f.name)?.accessModifier;
|
|
620
624
|
if (f.isExploreField()) {
|
|
621
625
|
const ef = f;
|
|
622
626
|
const cls = classifyJoinTarget(ef, ctx.knownSources);
|
|
@@ -630,6 +634,7 @@ function walkFields(fields, structDefFields, depth, ctx, anon) {
|
|
|
630
634
|
if (needsQuote(f.name)) join5.must_quote = true;
|
|
631
635
|
if (annotations.length > 0) join5.annotations = annotations;
|
|
632
636
|
if (loc) join5.location = loc;
|
|
637
|
+
if (access) join5.access = access;
|
|
633
638
|
const synthetic = isScalarArray(ef) || isRepeatedRecord(ef) || isAnonymousRecord(ef);
|
|
634
639
|
if (isScalarArray(ef)) join5.column_shape = "scalar_array";
|
|
635
640
|
else if (isRepeatedRecord(ef)) join5.column_shape = "record_array";
|
|
@@ -653,6 +658,7 @@ function walkFields(fields, structDefFields, depth, ctx, anon) {
|
|
|
653
658
|
if (needsQuote(f.name)) view.must_quote = true;
|
|
654
659
|
if (annotations.length > 0) view.annotations = annotations;
|
|
655
660
|
if (loc) view.location = loc;
|
|
661
|
+
if (access) view.access = access;
|
|
656
662
|
if (mLoc) {
|
|
657
663
|
const body = sliceSource(ctx.readSource(mLoc.url), mLoc);
|
|
658
664
|
if (body) view.body = body;
|
|
@@ -669,6 +675,7 @@ function walkFields(fields, structDefFields, depth, ctx, anon) {
|
|
|
669
675
|
if (expr && expr !== f.name) info.expression = expr;
|
|
670
676
|
if (annotations.length > 0) info.annotations = annotations;
|
|
671
677
|
if (loc) info.location = loc;
|
|
678
|
+
if (access) info.access = access;
|
|
672
679
|
if (fieldKind(af, structDefFields) === "measure") groups.measures.push(info);
|
|
673
680
|
else groups.dimensions.push(info);
|
|
674
681
|
}
|
|
@@ -840,6 +847,31 @@ async function compile(runtime, entry, opts = {}) {
|
|
|
840
847
|
return { ok: false, problems: [errorProblem(e, entry.href)] };
|
|
841
848
|
}
|
|
842
849
|
}
|
|
850
|
+
var isPublic = (m) => m.access === void 0;
|
|
851
|
+
function publicGroups(g) {
|
|
852
|
+
return {
|
|
853
|
+
dimensions: g.dimensions.filter(isPublic),
|
|
854
|
+
measures: g.measures.filter(isPublic),
|
|
855
|
+
views: g.views.filter(isPublic),
|
|
856
|
+
joins: g.joins.filter(isPublic).map(
|
|
857
|
+
(j) => j.fields ? { ...j, fields: publicGroups(j.fields) } : j
|
|
858
|
+
)
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function publicSource(s) {
|
|
862
|
+
const groups = publicGroups(s);
|
|
863
|
+
const out = { ...s, ...groups };
|
|
864
|
+
if (out.primary_key && !groups.dimensions.some((d) => d.name === out.primary_key)) {
|
|
865
|
+
out.primary_key = null;
|
|
866
|
+
}
|
|
867
|
+
if (s.anon_srcs) out.anon_srcs = s.anon_srcs.map(publicSource);
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
function publicOnlyModel(m) {
|
|
871
|
+
const sources = {};
|
|
872
|
+
for (const [name, s] of Object.entries(m.sources)) sources[name] = publicSource(s);
|
|
873
|
+
return { ...m, sources };
|
|
874
|
+
}
|
|
843
875
|
var EMPTY_GROUPS = { dimensions: [], measures: [], views: [], joins: [] };
|
|
844
876
|
var seg = (m) => m.must_quote ? `\`${m.name}\`` : m.name;
|
|
845
877
|
var barePath = (prefix, j) => prefix ? `${prefix}.${j.name}` : j.name;
|
|
@@ -986,7 +1018,8 @@ function emitSourceJoin(j, bare, quoted, anonScope, fans, namedOnPath, anonOnPat
|
|
|
986
1018
|
);
|
|
987
1019
|
}
|
|
988
1020
|
}
|
|
989
|
-
function buildSourceDescribe(
|
|
1021
|
+
function buildSourceDescribe(compiled, name) {
|
|
1022
|
+
const model = publicOnlyModel(compiled);
|
|
990
1023
|
const root = model.sources[name];
|
|
991
1024
|
if (!root) return void 0;
|
|
992
1025
|
const ctx = { model, joins: /* @__PURE__ */ Object.create(null), map: /* @__PURE__ */ Object.create(null) };
|
|
@@ -2443,6 +2476,97 @@ async function makeRunner(root) {
|
|
|
2443
2476
|
};
|
|
2444
2477
|
}
|
|
2445
2478
|
|
|
2479
|
+
// src/discover.ts
|
|
2480
|
+
import fs3 from "node:fs";
|
|
2481
|
+
import path4 from "node:path";
|
|
2482
|
+
import { fileURLToPath } from "node:url";
|
|
2483
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
2484
|
+
var require2 = createRequire2(import.meta.url);
|
|
2485
|
+
var HOST_LIBS = [
|
|
2486
|
+
"react",
|
|
2487
|
+
"react-dom",
|
|
2488
|
+
"react-dom/client",
|
|
2489
|
+
"react/jsx-runtime",
|
|
2490
|
+
"react/jsx-dev-runtime",
|
|
2491
|
+
"@malloydata/render",
|
|
2492
|
+
"@malloydata/malloy-filter"
|
|
2493
|
+
];
|
|
2494
|
+
var HOST_ALIAS = {};
|
|
2495
|
+
for (const spec of HOST_LIBS) {
|
|
2496
|
+
try {
|
|
2497
|
+
HOST_ALIAS[spec] = require2.resolve(spec);
|
|
2498
|
+
} catch {
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
function resolveRuntimeDir() {
|
|
2502
|
+
const candidates = [
|
|
2503
|
+
new URL("./frame-runtime/", import.meta.url),
|
|
2504
|
+
// src/dashboard.ts (tsx) OR dist/index.js (published)
|
|
2505
|
+
new URL("../src/frame-runtime/", import.meta.url)
|
|
2506
|
+
// built dist/ next to sibling src/ (checkout)
|
|
2507
|
+
].map((u) => fileURLToPath(u));
|
|
2508
|
+
const found = candidates.find((c) => fs3.existsSync(c));
|
|
2509
|
+
if (!found) {
|
|
2510
|
+
throw new Error(
|
|
2511
|
+
"frame-runtime/ not found next to the CLI (looked in ./frame-runtime and ../src/frame-runtime). A published install should ship it in dist/; reinstall the CLI, or rebuild with `npm run build`."
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
return found;
|
|
2515
|
+
}
|
|
2516
|
+
var hostAliasPlugin = {
|
|
2517
|
+
name: "host-alias",
|
|
2518
|
+
setup(b) {
|
|
2519
|
+
b.onResolve(
|
|
2520
|
+
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
|
|
2521
|
+
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
2525
|
+
async function discoverDashboards(root, runner) {
|
|
2526
|
+
const dir = path4.join(root, "dashboards");
|
|
2527
|
+
if (!fs3.existsSync(dir)) return [];
|
|
2528
|
+
const files = fs3.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
|
|
2529
|
+
const dashboards = [];
|
|
2530
|
+
for (const file of files) {
|
|
2531
|
+
const base = file.slice(0, -".malloy".length);
|
|
2532
|
+
const entryFile = path4.join("dashboards", file);
|
|
2533
|
+
const res = await runner.artifactForFile(entryFile, base);
|
|
2534
|
+
if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
|
|
2535
|
+
if (!res.artifact) continue;
|
|
2536
|
+
const component = ["jsx", "tsx"].map((ext) => path4.join(dir, `${base}.${ext}`)).find((p) => fs3.existsSync(p));
|
|
2537
|
+
dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
|
|
2538
|
+
}
|
|
2539
|
+
const about = aboutPage(root);
|
|
2540
|
+
if (about && !dashboards.some((d) => d.name === about.name)) dashboards.unshift(about);
|
|
2541
|
+
return dashboards;
|
|
2542
|
+
}
|
|
2543
|
+
function browserBuildBase() {
|
|
2544
|
+
const shims = path4.join(resolveRuntimeDir(), "..", "shims");
|
|
2545
|
+
return {
|
|
2546
|
+
platform: "browser",
|
|
2547
|
+
jsx: "automatic",
|
|
2548
|
+
loader: { ".css": "empty" },
|
|
2549
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
2550
|
+
alias: {
|
|
2551
|
+
assert: path4.join(shims, "assert.cjs"),
|
|
2552
|
+
util: path4.join(shims, "util.cjs")
|
|
2553
|
+
},
|
|
2554
|
+
banner: {
|
|
2555
|
+
js: "globalThis.process||={env:{},platform:'browser',versions:{},argv:[],cwd:()=>'/'};"
|
|
2556
|
+
}
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
var ABOUT_NAME = "index";
|
|
2560
|
+
var ABOUT_TITLE = "About";
|
|
2561
|
+
function aboutPage(root) {
|
|
2562
|
+
const dir = path4.join(root, "dashboards");
|
|
2563
|
+
if (fs3.existsSync(path4.join(dir, "index.malloy"))) return null;
|
|
2564
|
+
const component = ["jsx", "tsx"].map((ext) => path4.join(dir, `index.${ext}`)).find((p) => fs3.existsSync(p));
|
|
2565
|
+
if (!component) return null;
|
|
2566
|
+
return { name: ABOUT_NAME, query: "", title: ABOUT_TITLE, tsxPath: component };
|
|
2567
|
+
}
|
|
2568
|
+
var rendersNoData = (d) => !d.query && (!d.tiles || d.tiles.length === 0);
|
|
2569
|
+
|
|
2446
2570
|
// src/gather.ts
|
|
2447
2571
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
2448
2572
|
function gatherDirectory(dir) {
|
|
@@ -2494,6 +2618,14 @@ async function gatherDashboards(dir) {
|
|
|
2494
2618
|
source: component ? readFileSync2(component, "utf8") : ""
|
|
2495
2619
|
});
|
|
2496
2620
|
}
|
|
2621
|
+
const about = aboutPage(dir);
|
|
2622
|
+
if (about?.tsxPath && !payloads.some((p) => p.name === about.name)) {
|
|
2623
|
+
payloads.unshift({
|
|
2624
|
+
name: about.name,
|
|
2625
|
+
manifest: { title: about.title },
|
|
2626
|
+
source: readFileSync2(about.tsxPath, "utf8")
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2497
2629
|
return payloads;
|
|
2498
2630
|
} finally {
|
|
2499
2631
|
await runner.dispose();
|
|
@@ -2538,6 +2670,16 @@ function componentQueryLiterals(source) {
|
|
|
2538
2670
|
while ((m = re.exec(source)) !== null) out.add(m[2].trim());
|
|
2539
2671
|
return [...out];
|
|
2540
2672
|
}
|
|
2673
|
+
function landingPageErrors(dir, file) {
|
|
2674
|
+
const ext = file.endsWith(".tsx") ? "tsx" : "jsx";
|
|
2675
|
+
try {
|
|
2676
|
+
esbuild.transformSync(readFileSync3(join3(dir, file), "utf8"), { loader: ext, jsx: "automatic" });
|
|
2677
|
+
return [];
|
|
2678
|
+
} catch (e) {
|
|
2679
|
+
const msg = e.errors?.map((x) => x.text).join("; ") ?? String(e);
|
|
2680
|
+
return [`${file}: ${msg}`];
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2541
2683
|
async function lintDashboards(root) {
|
|
2542
2684
|
const abs = resolve(root);
|
|
2543
2685
|
const runner = await makeRunner(abs);
|
|
@@ -2560,13 +2702,16 @@ async function runLint(abs, runner) {
|
|
|
2560
2702
|
const malloyBases = new Set(malloyFiles.map((f) => f.slice(0, -".malloy".length)));
|
|
2561
2703
|
for (const c of entries.filter((f) => /\.(jsx|tsx)$/.test(f)).sort()) {
|
|
2562
2704
|
const cbase = c.replace(/\.(jsx|tsx)$/, "");
|
|
2563
|
-
if (
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
warnings: []
|
|
2568
|
-
});
|
|
2705
|
+
if (malloyBases.has(cbase)) continue;
|
|
2706
|
+
if (cbase === "index") {
|
|
2707
|
+
dashboards.push({ name: c, errors: landingPageErrors(dir, c), warnings: [] });
|
|
2708
|
+
continue;
|
|
2569
2709
|
}
|
|
2710
|
+
dashboards.push({
|
|
2711
|
+
name: c,
|
|
2712
|
+
errors: [`component "${c}" has no matching "${cbase}.malloy" dashboard`],
|
|
2713
|
+
warnings: []
|
|
2714
|
+
});
|
|
2570
2715
|
}
|
|
2571
2716
|
const seenNames = /* @__PURE__ */ new Map();
|
|
2572
2717
|
for (const file of malloyFiles) {
|
|
@@ -2735,7 +2880,7 @@ function clearCreds(url6) {
|
|
|
2735
2880
|
}
|
|
2736
2881
|
|
|
2737
2882
|
// package.json
|
|
2738
|
-
var version = "0.2.
|
|
2883
|
+
var version = "0.2.37";
|
|
2739
2884
|
|
|
2740
2885
|
// src/http.ts
|
|
2741
2886
|
var USER_AGENT = `malloyyo/${version}`;
|
|
@@ -2917,8 +3062,8 @@ Run: malloyyo login ${target.name}`);
|
|
|
2917
3062
|
}
|
|
2918
3063
|
|
|
2919
3064
|
// src/mcp.ts
|
|
2920
|
-
import
|
|
2921
|
-
import
|
|
3065
|
+
import fs4 from "node:fs";
|
|
3066
|
+
import path5 from "node:path";
|
|
2922
3067
|
import url4 from "node:url";
|
|
2923
3068
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2924
3069
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -3007,7 +3152,7 @@ function defaultConfig(rootUrl) {
|
|
|
3007
3152
|
});
|
|
3008
3153
|
}
|
|
3009
3154
|
async function loadConfig2(root, reader) {
|
|
3010
|
-
const rootUrl = url4.pathToFileURL(root +
|
|
3155
|
+
const rootUrl = url4.pathToFileURL(root + path5.sep);
|
|
3011
3156
|
let discovered;
|
|
3012
3157
|
try {
|
|
3013
3158
|
discovered = await discoverConfig2(rootUrl, rootUrl, reader);
|
|
@@ -3029,13 +3174,13 @@ function fsReader2() {
|
|
|
3029
3174
|
if (u.protocol !== "file:") {
|
|
3030
3175
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
3031
3176
|
}
|
|
3032
|
-
return
|
|
3177
|
+
return fs4.promises.readFile(u, "utf8");
|
|
3033
3178
|
}
|
|
3034
3179
|
};
|
|
3035
3180
|
}
|
|
3036
3181
|
function resolveUnderRoot(root, p) {
|
|
3037
|
-
const abs = p.includes("://") ?
|
|
3038
|
-
if (abs !== root && !abs.startsWith(root +
|
|
3182
|
+
const abs = p.includes("://") ? path5.resolve(decodeURIComponent(new URL(p).pathname)) : path5.resolve(root, p);
|
|
3183
|
+
if (abs !== root && !abs.startsWith(root + path5.sep)) {
|
|
3039
3184
|
throw new Error(`path is outside the project root: ${p}`);
|
|
3040
3185
|
}
|
|
3041
3186
|
return abs;
|
|
@@ -3044,7 +3189,7 @@ function makeConfigSource(root) {
|
|
|
3044
3189
|
let cached;
|
|
3045
3190
|
const signature = () => ["malloy-config.json", "malloy-config-local.json"].map((name) => {
|
|
3046
3191
|
try {
|
|
3047
|
-
const st =
|
|
3192
|
+
const st = fs4.statSync(path5.join(root, name));
|
|
3048
3193
|
return `${name}:${st.mtimeMs}:${st.size}`;
|
|
3049
3194
|
} catch {
|
|
3050
3195
|
return `${name}:absent`;
|
|
@@ -3064,7 +3209,7 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
3064
3209
|
return gateConfigProblems(problems, async () => {
|
|
3065
3210
|
const resolved = "url" in input ? { url: resolveUnderRoot(root, input.url) } : {
|
|
3066
3211
|
source: input.source,
|
|
3067
|
-
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root +
|
|
3212
|
+
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path5.sep
|
|
3068
3213
|
};
|
|
3069
3214
|
const { reader, entry, readSource } = prepareSource(fsReader2(), resolved);
|
|
3070
3215
|
const runtime = new Runtime2({ config, urlReader: reader });
|
|
@@ -3078,7 +3223,7 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
3078
3223
|
}
|
|
3079
3224
|
function makeExploreHost(root, currentConfig) {
|
|
3080
3225
|
const withRuntime = makeWithRuntime(root, currentConfig);
|
|
3081
|
-
const published = (ref) => ref === ENTRY2 &&
|
|
3226
|
+
const published = (ref) => ref === ENTRY2 && fs4.existsSync(path5.join(root, ENTRY2));
|
|
3082
3227
|
return {
|
|
3083
3228
|
withModel: (ref, fn) => {
|
|
3084
3229
|
if (!published(ref)) throw new Error(`no published model '${ref}'`);
|
|
@@ -3099,7 +3244,7 @@ function makeDevelopHost(root, currentConfig) {
|
|
|
3099
3244
|
}
|
|
3100
3245
|
async function serveMcp(opts) {
|
|
3101
3246
|
await initConnections();
|
|
3102
|
-
const root =
|
|
3247
|
+
const root = path5.resolve(opts.root ?? process.cwd());
|
|
3103
3248
|
const mode = opts.mode ?? "explore";
|
|
3104
3249
|
const currentConfig = makeConfigSource(root);
|
|
3105
3250
|
const surface = mode === "develop" ? developSurface(makeDevelopHost(root, currentConfig)) : exploreSurface(makeExploreHost(root, currentConfig));
|
|
@@ -3172,84 +3317,13 @@ function navHtml(active, all, href, homeHref = "./") {
|
|
|
3172
3317
|
).join("");
|
|
3173
3318
|
return `<nav class="dash-nav">${brand}<span class="sep"></span>${links}</nav>`;
|
|
3174
3319
|
}
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
var HOST_LIBS = [
|
|
3183
|
-
"react",
|
|
3184
|
-
"react-dom",
|
|
3185
|
-
"react-dom/client",
|
|
3186
|
-
"react/jsx-runtime",
|
|
3187
|
-
"react/jsx-dev-runtime",
|
|
3188
|
-
"@malloydata/render",
|
|
3189
|
-
"@malloydata/malloy-filter"
|
|
3190
|
-
];
|
|
3191
|
-
var HOST_ALIAS = {};
|
|
3192
|
-
for (const spec of HOST_LIBS) {
|
|
3193
|
-
try {
|
|
3194
|
-
HOST_ALIAS[spec] = require2.resolve(spec);
|
|
3195
|
-
} catch {
|
|
3196
|
-
}
|
|
3197
|
-
}
|
|
3198
|
-
function resolveRuntimeDir() {
|
|
3199
|
-
const candidates = [
|
|
3200
|
-
new URL("./frame-runtime/", import.meta.url),
|
|
3201
|
-
// src/dashboard.ts (tsx) OR dist/index.js (published)
|
|
3202
|
-
new URL("../src/frame-runtime/", import.meta.url)
|
|
3203
|
-
// built dist/ next to sibling src/ (checkout)
|
|
3204
|
-
].map((u) => fileURLToPath(u));
|
|
3205
|
-
const found = candidates.find((c) => fs4.existsSync(c));
|
|
3206
|
-
if (!found) {
|
|
3207
|
-
throw new Error(
|
|
3208
|
-
"frame-runtime/ not found next to the CLI (looked in ./frame-runtime and ../src/frame-runtime). A published install should ship it in dist/; reinstall the CLI, or rebuild with `npm run build`."
|
|
3209
|
-
);
|
|
3210
|
-
}
|
|
3211
|
-
return found;
|
|
3212
|
-
}
|
|
3213
|
-
var hostAliasPlugin = {
|
|
3214
|
-
name: "host-alias",
|
|
3215
|
-
setup(b) {
|
|
3216
|
-
b.onResolve(
|
|
3217
|
-
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
|
|
3218
|
-
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
3219
|
-
);
|
|
3220
|
-
}
|
|
3221
|
-
};
|
|
3222
|
-
async function discoverDashboards(root, runner) {
|
|
3223
|
-
const dir = path5.join(root, "dashboards");
|
|
3224
|
-
if (!fs4.existsSync(dir)) return [];
|
|
3225
|
-
const files = fs4.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
|
|
3226
|
-
const dashboards = [];
|
|
3227
|
-
for (const file of files) {
|
|
3228
|
-
const base = file.slice(0, -".malloy".length);
|
|
3229
|
-
const entryFile = path5.join("dashboards", file);
|
|
3230
|
-
const res = await runner.artifactForFile(entryFile, base);
|
|
3231
|
-
if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
|
|
3232
|
-
if (!res.artifact) continue;
|
|
3233
|
-
const component = ["jsx", "tsx"].map((ext) => path5.join(dir, `${base}.${ext}`)).find((p) => fs4.existsSync(p));
|
|
3234
|
-
dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
|
|
3235
|
-
}
|
|
3236
|
-
return dashboards;
|
|
3237
|
-
}
|
|
3238
|
-
function browserBuildBase() {
|
|
3239
|
-
const shims = path5.join(resolveRuntimeDir(), "..", "shims");
|
|
3240
|
-
return {
|
|
3241
|
-
platform: "browser",
|
|
3242
|
-
jsx: "automatic",
|
|
3243
|
-
loader: { ".css": "empty" },
|
|
3244
|
-
define: { "process.env.NODE_ENV": '"production"' },
|
|
3245
|
-
alias: {
|
|
3246
|
-
assert: path5.join(shims, "assert.cjs"),
|
|
3247
|
-
util: path5.join(shims, "util.cjs")
|
|
3248
|
-
},
|
|
3249
|
-
banner: {
|
|
3250
|
-
js: "globalThis.process||={env:{},platform:'browser',versions:{},argv:[],cwd:()=>'/'};"
|
|
3251
|
-
}
|
|
3252
|
-
};
|
|
3320
|
+
function siblingList(current, all, href) {
|
|
3321
|
+
return all.filter((d) => d.name !== current).map((d) => ({
|
|
3322
|
+
name: d.name,
|
|
3323
|
+
title: d.title || d.name,
|
|
3324
|
+
...d.description ? { description: d.description } : {},
|
|
3325
|
+
href: href(d.name)
|
|
3326
|
+
}));
|
|
3253
3327
|
}
|
|
3254
3328
|
|
|
3255
3329
|
// src/dashboard.ts
|
|
@@ -3330,6 +3404,7 @@ function makeInPageBundler() {
|
|
|
3330
3404
|
};
|
|
3331
3405
|
}
|
|
3332
3406
|
var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${esc2(title)}</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>${NAV_CSS}</style></head><body style="margin:0">${body}</body></html>`;
|
|
3407
|
+
var devSiblings = (dash, all) => siblingList(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
|
|
3333
3408
|
function navHtml2(dash, all) {
|
|
3334
3409
|
return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
|
|
3335
3410
|
}
|
|
@@ -3346,7 +3421,7 @@ function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tile
|
|
|
3346
3421
|
autorun: dash.autorun
|
|
3347
3422
|
};
|
|
3348
3423
|
return html(
|
|
3349
|
-
navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${safeJson(info)};window.__GIVENS__=${safeJson(givenSpecs)};window.__INITIAL_GIVENS__=${safeJson(initialGivens)};window.__INITIAL_URLSTATE__=${safeJson(initialUrlState)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
3424
|
+
navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${safeJson(info)};window.__DASHBOARDS__=${safeJson(devSiblings(dash, all))};window.__GIVENS__=${safeJson(givenSpecs)};window.__INITIAL_GIVENS__=${safeJson(initialGivens)};window.__INITIAL_URLSTATE__=${safeJson(initialUrlState)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
3350
3425
|
dash.title
|
|
3351
3426
|
);
|
|
3352
3427
|
}
|
|
@@ -3413,7 +3488,7 @@ function givensFromUrl(url6) {
|
|
|
3413
3488
|
function urlStateFromUrl(url6) {
|
|
3414
3489
|
return urlStateFromSearch(url6.search);
|
|
3415
3490
|
}
|
|
3416
|
-
function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
3491
|
+
function frameDoc(dash, all, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
3417
3492
|
const info = {
|
|
3418
3493
|
name: dash.name,
|
|
3419
3494
|
query: dash.query,
|
|
@@ -3428,7 +3503,7 @@ function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
|
3428
3503
|
autorun: dash.autorun
|
|
3429
3504
|
};
|
|
3430
3505
|
return html(
|
|
3431
|
-
`<div id="root"></div><script>window.__DASHBOARD__=${safeJson(info)};window.__GIVENS__=${safeJson(givenSpecs)};window.__INITIAL_GIVENS__=${safeJson(initialGivens)};window.__INITIAL_URLSTATE__=${safeJson(initialUrlState)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
3506
|
+
`<div id="root"></div><script>window.__DASHBOARD__=${safeJson(info)};window.__DASHBOARDS__=${safeJson(devSiblings(dash, all))};window.__GIVENS__=${safeJson(givenSpecs)};window.__INITIAL_GIVENS__=${safeJson(initialGivens)};window.__INITIAL_URLSTATE__=${safeJson(initialUrlState)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
3432
3507
|
dash.title
|
|
3433
3508
|
);
|
|
3434
3509
|
}
|
|
@@ -3458,6 +3533,7 @@ async function serveDashboard(opts) {
|
|
|
3458
3533
|
const inPageBundle = makeInPageBundler();
|
|
3459
3534
|
const pick = (url6) => byName.get(url6.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
3460
3535
|
async function resolveGivens(dash) {
|
|
3536
|
+
if (rendersNoData(dash)) return { ok: true, union: [] };
|
|
3461
3537
|
if (dash.tiles && dash.entryFile) {
|
|
3462
3538
|
const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
|
|
3463
3539
|
return { ok: true, union: t.union, tiles: t.tiles };
|
|
@@ -3510,7 +3586,7 @@ async function serveDashboard(opts) {
|
|
|
3510
3586
|
return send(
|
|
3511
3587
|
200,
|
|
3512
3588
|
"text/html; charset=utf-8",
|
|
3513
|
-
frameDoc(dash, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
|
|
3589
|
+
frameDoc(dash, dashboards, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
|
|
3514
3590
|
);
|
|
3515
3591
|
}
|
|
3516
3592
|
if (url6.pathname === "/bundle.js") {
|
|
@@ -3795,11 +3871,12 @@ window.__GIVENS__ = ${safeJson(givenSpecs)};
|
|
|
3795
3871
|
}
|
|
3796
3872
|
function indexPage(dashboards, title, custom, cleanUrls, analytics) {
|
|
3797
3873
|
const link = (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`;
|
|
3874
|
+
const listed = dashboards.filter((d) => !rendersNoData(d));
|
|
3798
3875
|
const body = custom ? `<div id="root"></div>
|
|
3799
3876
|
<script>window.__DASHBOARDS__ = ${safeJson(
|
|
3800
|
-
|
|
3877
|
+
listed.map((d) => ({ name: d.name, title: d.title, description: d.description, href: link(d.name) }))
|
|
3801
3878
|
)};</script>
|
|
3802
|
-
<script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` +
|
|
3879
|
+
<script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` + listed.map(
|
|
3803
3880
|
(d) => `<li><a href="${link(d.name)}"><strong>${esc3(d.title || d.name)}</strong>` + (d.description ? `<span>${esc3(d.description)}</span>` : "") + `</a></li>`
|
|
3804
3881
|
).join("") + `</ul></main>`;
|
|
3805
3882
|
return `<!doctype html>
|
|
@@ -3960,6 +4037,7 @@ boot(Dashboard);
|
|
|
3960
4037
|
});
|
|
3961
4038
|
fs7.writeFileSync(path8.join(outDir, "assets", "site.css"), SITE_CSS);
|
|
3962
4039
|
for (const d of dashboards) {
|
|
4040
|
+
if (rendersNoData(d)) continue;
|
|
3963
4041
|
let specs = [];
|
|
3964
4042
|
let tileSpecs;
|
|
3965
4043
|
if (d.tiles && d.entryFile) {
|
package/dist/shared/nav.ts
CHANGED
|
@@ -70,3 +70,25 @@ export function navHtml(
|
|
|
70
70
|
.join("");
|
|
71
71
|
return `<nav class="dash-nav">${brand}<span class="sep"></span>${links}</nav>`;
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/** The sibling list a host injects as `window.__DASHBOARDS__`, in nav order and
|
|
75
|
+
excluding the current page.
|
|
76
|
+
*
|
|
77
|
+
* Same shape and same link-shape callback as `navHtml`, because it answers the
|
|
78
|
+
* same question in component form: an About page (or any dashboard) that wants
|
|
79
|
+
* to link to the others gets `props.dashboards` and never has to know whether it
|
|
80
|
+
* is running under the dev server, the hosted frame, or a static bundle. */
|
|
81
|
+
export function siblingList<T extends NavDashboard & { description?: string }>(
|
|
82
|
+
current: string,
|
|
83
|
+
all: readonly T[],
|
|
84
|
+
href: (name: string) => string,
|
|
85
|
+
): Array<{ name: string; title: string; description?: string; href: string }> {
|
|
86
|
+
return all
|
|
87
|
+
.filter((d) => d.name !== current)
|
|
88
|
+
.map((d) => ({
|
|
89
|
+
name: d.name,
|
|
90
|
+
title: d.title || d.name,
|
|
91
|
+
...(d.description ? { description: d.description } : {}),
|
|
92
|
+
href: href(d.name),
|
|
93
|
+
}));
|
|
94
|
+
}
|