@malloydata/malloyyo 0.2.35 → 0.2.36

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.
@@ -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
@@ -2443,6 +2443,97 @@ async function makeRunner(root) {
2443
2443
  };
2444
2444
  }
2445
2445
 
2446
+ // src/discover.ts
2447
+ import fs3 from "node:fs";
2448
+ import path4 from "node:path";
2449
+ import { fileURLToPath } from "node:url";
2450
+ import { createRequire as createRequire2 } from "node:module";
2451
+ var require2 = createRequire2(import.meta.url);
2452
+ var HOST_LIBS = [
2453
+ "react",
2454
+ "react-dom",
2455
+ "react-dom/client",
2456
+ "react/jsx-runtime",
2457
+ "react/jsx-dev-runtime",
2458
+ "@malloydata/render",
2459
+ "@malloydata/malloy-filter"
2460
+ ];
2461
+ var HOST_ALIAS = {};
2462
+ for (const spec of HOST_LIBS) {
2463
+ try {
2464
+ HOST_ALIAS[spec] = require2.resolve(spec);
2465
+ } catch {
2466
+ }
2467
+ }
2468
+ function resolveRuntimeDir() {
2469
+ const candidates = [
2470
+ new URL("./frame-runtime/", import.meta.url),
2471
+ // src/dashboard.ts (tsx) OR dist/index.js (published)
2472
+ new URL("../src/frame-runtime/", import.meta.url)
2473
+ // built dist/ next to sibling src/ (checkout)
2474
+ ].map((u) => fileURLToPath(u));
2475
+ const found = candidates.find((c) => fs3.existsSync(c));
2476
+ if (!found) {
2477
+ throw new Error(
2478
+ "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`."
2479
+ );
2480
+ }
2481
+ return found;
2482
+ }
2483
+ var hostAliasPlugin = {
2484
+ name: "host-alias",
2485
+ setup(b) {
2486
+ b.onResolve(
2487
+ { filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
2488
+ (args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
2489
+ );
2490
+ }
2491
+ };
2492
+ async function discoverDashboards(root, runner) {
2493
+ const dir = path4.join(root, "dashboards");
2494
+ if (!fs3.existsSync(dir)) return [];
2495
+ const files = fs3.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
2496
+ const dashboards = [];
2497
+ for (const file of files) {
2498
+ const base = file.slice(0, -".malloy".length);
2499
+ const entryFile = path4.join("dashboards", file);
2500
+ const res = await runner.artifactForFile(entryFile, base);
2501
+ if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
2502
+ if (!res.artifact) continue;
2503
+ const component = ["jsx", "tsx"].map((ext) => path4.join(dir, `${base}.${ext}`)).find((p) => fs3.existsSync(p));
2504
+ dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
2505
+ }
2506
+ const about = aboutPage(root);
2507
+ if (about && !dashboards.some((d) => d.name === about.name)) dashboards.unshift(about);
2508
+ return dashboards;
2509
+ }
2510
+ function browserBuildBase() {
2511
+ const shims = path4.join(resolveRuntimeDir(), "..", "shims");
2512
+ return {
2513
+ platform: "browser",
2514
+ jsx: "automatic",
2515
+ loader: { ".css": "empty" },
2516
+ define: { "process.env.NODE_ENV": '"production"' },
2517
+ alias: {
2518
+ assert: path4.join(shims, "assert.cjs"),
2519
+ util: path4.join(shims, "util.cjs")
2520
+ },
2521
+ banner: {
2522
+ js: "globalThis.process||={env:{},platform:'browser',versions:{},argv:[],cwd:()=>'/'};"
2523
+ }
2524
+ };
2525
+ }
2526
+ var ABOUT_NAME = "index";
2527
+ var ABOUT_TITLE = "About";
2528
+ function aboutPage(root) {
2529
+ const dir = path4.join(root, "dashboards");
2530
+ if (fs3.existsSync(path4.join(dir, "index.malloy"))) return null;
2531
+ const component = ["jsx", "tsx"].map((ext) => path4.join(dir, `index.${ext}`)).find((p) => fs3.existsSync(p));
2532
+ if (!component) return null;
2533
+ return { name: ABOUT_NAME, query: "", title: ABOUT_TITLE, tsxPath: component };
2534
+ }
2535
+ var rendersNoData = (d) => !d.query && (!d.tiles || d.tiles.length === 0);
2536
+
2446
2537
  // src/gather.ts
2447
2538
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
2448
2539
  function gatherDirectory(dir) {
@@ -2494,6 +2585,14 @@ async function gatherDashboards(dir) {
2494
2585
  source: component ? readFileSync2(component, "utf8") : ""
2495
2586
  });
2496
2587
  }
2588
+ const about = aboutPage(dir);
2589
+ if (about?.tsxPath && !payloads.some((p) => p.name === about.name)) {
2590
+ payloads.unshift({
2591
+ name: about.name,
2592
+ manifest: { title: about.title },
2593
+ source: readFileSync2(about.tsxPath, "utf8")
2594
+ });
2595
+ }
2497
2596
  return payloads;
2498
2597
  } finally {
2499
2598
  await runner.dispose();
@@ -2538,6 +2637,16 @@ function componentQueryLiterals(source) {
2538
2637
  while ((m = re.exec(source)) !== null) out.add(m[2].trim());
2539
2638
  return [...out];
2540
2639
  }
2640
+ function landingPageErrors(dir, file) {
2641
+ const ext = file.endsWith(".tsx") ? "tsx" : "jsx";
2642
+ try {
2643
+ esbuild.transformSync(readFileSync3(join3(dir, file), "utf8"), { loader: ext, jsx: "automatic" });
2644
+ return [];
2645
+ } catch (e) {
2646
+ const msg = e.errors?.map((x) => x.text).join("; ") ?? String(e);
2647
+ return [`${file}: ${msg}`];
2648
+ }
2649
+ }
2541
2650
  async function lintDashboards(root) {
2542
2651
  const abs = resolve(root);
2543
2652
  const runner = await makeRunner(abs);
@@ -2560,13 +2669,16 @@ async function runLint(abs, runner) {
2560
2669
  const malloyBases = new Set(malloyFiles.map((f) => f.slice(0, -".malloy".length)));
2561
2670
  for (const c of entries.filter((f) => /\.(jsx|tsx)$/.test(f)).sort()) {
2562
2671
  const cbase = c.replace(/\.(jsx|tsx)$/, "");
2563
- if (!malloyBases.has(cbase)) {
2564
- dashboards.push({
2565
- name: c,
2566
- errors: [`component "${c}" has no matching "${cbase}.malloy" dashboard`],
2567
- warnings: []
2568
- });
2672
+ if (malloyBases.has(cbase)) continue;
2673
+ if (cbase === "index") {
2674
+ dashboards.push({ name: c, errors: landingPageErrors(dir, c), warnings: [] });
2675
+ continue;
2569
2676
  }
2677
+ dashboards.push({
2678
+ name: c,
2679
+ errors: [`component "${c}" has no matching "${cbase}.malloy" dashboard`],
2680
+ warnings: []
2681
+ });
2570
2682
  }
2571
2683
  const seenNames = /* @__PURE__ */ new Map();
2572
2684
  for (const file of malloyFiles) {
@@ -2735,7 +2847,7 @@ function clearCreds(url6) {
2735
2847
  }
2736
2848
 
2737
2849
  // package.json
2738
- var version = "0.2.35";
2850
+ var version = "0.2.36";
2739
2851
 
2740
2852
  // src/http.ts
2741
2853
  var USER_AGENT = `malloyyo/${version}`;
@@ -2917,8 +3029,8 @@ Run: malloyyo login ${target.name}`);
2917
3029
  }
2918
3030
 
2919
3031
  // src/mcp.ts
2920
- import fs3 from "node:fs";
2921
- import path4 from "node:path";
3032
+ import fs4 from "node:fs";
3033
+ import path5 from "node:path";
2922
3034
  import url4 from "node:url";
2923
3035
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2924
3036
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -3007,7 +3119,7 @@ function defaultConfig(rootUrl) {
3007
3119
  });
3008
3120
  }
3009
3121
  async function loadConfig2(root, reader) {
3010
- const rootUrl = url4.pathToFileURL(root + path4.sep);
3122
+ const rootUrl = url4.pathToFileURL(root + path5.sep);
3011
3123
  let discovered;
3012
3124
  try {
3013
3125
  discovered = await discoverConfig2(rootUrl, rootUrl, reader);
@@ -3029,13 +3141,13 @@ function fsReader2() {
3029
3141
  if (u.protocol !== "file:") {
3030
3142
  throw new Error(`unsupported URL scheme for import: ${u.href}`);
3031
3143
  }
3032
- return fs3.promises.readFile(u, "utf8");
3144
+ return fs4.promises.readFile(u, "utf8");
3033
3145
  }
3034
3146
  };
3035
3147
  }
3036
3148
  function resolveUnderRoot(root, p) {
3037
- const abs = p.includes("://") ? path4.resolve(decodeURIComponent(new URL(p).pathname)) : path4.resolve(root, p);
3038
- if (abs !== root && !abs.startsWith(root + path4.sep)) {
3149
+ const abs = p.includes("://") ? path5.resolve(decodeURIComponent(new URL(p).pathname)) : path5.resolve(root, p);
3150
+ if (abs !== root && !abs.startsWith(root + path5.sep)) {
3039
3151
  throw new Error(`path is outside the project root: ${p}`);
3040
3152
  }
3041
3153
  return abs;
@@ -3044,7 +3156,7 @@ function makeConfigSource(root) {
3044
3156
  let cached;
3045
3157
  const signature = () => ["malloy-config.json", "malloy-config-local.json"].map((name) => {
3046
3158
  try {
3047
- const st = fs3.statSync(path4.join(root, name));
3159
+ const st = fs4.statSync(path5.join(root, name));
3048
3160
  return `${name}:${st.mtimeMs}:${st.size}`;
3049
3161
  } catch {
3050
3162
  return `${name}:absent`;
@@ -3064,7 +3176,7 @@ function makeWithRuntime(root, currentConfig) {
3064
3176
  return gateConfigProblems(problems, async () => {
3065
3177
  const resolved = "url" in input ? { url: resolveUnderRoot(root, input.url) } : {
3066
3178
  source: input.source,
3067
- baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path4.sep
3179
+ baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path5.sep
3068
3180
  };
3069
3181
  const { reader, entry, readSource } = prepareSource(fsReader2(), resolved);
3070
3182
  const runtime = new Runtime2({ config, urlReader: reader });
@@ -3078,7 +3190,7 @@ function makeWithRuntime(root, currentConfig) {
3078
3190
  }
3079
3191
  function makeExploreHost(root, currentConfig) {
3080
3192
  const withRuntime = makeWithRuntime(root, currentConfig);
3081
- const published = (ref) => ref === ENTRY2 && fs3.existsSync(path4.join(root, ENTRY2));
3193
+ const published = (ref) => ref === ENTRY2 && fs4.existsSync(path5.join(root, ENTRY2));
3082
3194
  return {
3083
3195
  withModel: (ref, fn) => {
3084
3196
  if (!published(ref)) throw new Error(`no published model '${ref}'`);
@@ -3099,7 +3211,7 @@ function makeDevelopHost(root, currentConfig) {
3099
3211
  }
3100
3212
  async function serveMcp(opts) {
3101
3213
  await initConnections();
3102
- const root = path4.resolve(opts.root ?? process.cwd());
3214
+ const root = path5.resolve(opts.root ?? process.cwd());
3103
3215
  const mode = opts.mode ?? "explore";
3104
3216
  const currentConfig = makeConfigSource(root);
3105
3217
  const surface = mode === "develop" ? developSurface(makeDevelopHost(root, currentConfig)) : exploreSurface(makeExploreHost(root, currentConfig));
@@ -3172,84 +3284,13 @@ function navHtml(active, all, href, homeHref = "./") {
3172
3284
  ).join("");
3173
3285
  return `<nav class="dash-nav">${brand}<span class="sep"></span>${links}</nav>`;
3174
3286
  }
3175
-
3176
- // src/discover.ts
3177
- import fs4 from "node:fs";
3178
- import path5 from "node:path";
3179
- import { fileURLToPath } from "node:url";
3180
- import { createRequire as createRequire2 } from "node:module";
3181
- var require2 = createRequire2(import.meta.url);
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
- };
3287
+ function siblingList(current, all, href) {
3288
+ return all.filter((d) => d.name !== current).map((d) => ({
3289
+ name: d.name,
3290
+ title: d.title || d.name,
3291
+ ...d.description ? { description: d.description } : {},
3292
+ href: href(d.name)
3293
+ }));
3253
3294
  }
3254
3295
 
3255
3296
  // src/dashboard.ts
@@ -3330,6 +3371,7 @@ function makeInPageBundler() {
3330
3371
  };
3331
3372
  }
3332
3373
  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>`;
3374
+ var devSiblings = (dash, all) => siblingList(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
3333
3375
  function navHtml2(dash, all) {
3334
3376
  return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
3335
3377
  }
@@ -3346,7 +3388,7 @@ function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tile
3346
3388
  autorun: dash.autorun
3347
3389
  };
3348
3390
  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>`,
3391
+ 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
3392
  dash.title
3351
3393
  );
3352
3394
  }
@@ -3413,7 +3455,7 @@ function givensFromUrl(url6) {
3413
3455
  function urlStateFromUrl(url6) {
3414
3456
  return urlStateFromSearch(url6.search);
3415
3457
  }
3416
- function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3458
+ function frameDoc(dash, all, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3417
3459
  const info = {
3418
3460
  name: dash.name,
3419
3461
  query: dash.query,
@@ -3428,7 +3470,7 @@ function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3428
3470
  autorun: dash.autorun
3429
3471
  };
3430
3472
  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>`,
3473
+ `<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
3474
  dash.title
3433
3475
  );
3434
3476
  }
@@ -3458,6 +3500,7 @@ async function serveDashboard(opts) {
3458
3500
  const inPageBundle = makeInPageBundler();
3459
3501
  const pick = (url6) => byName.get(url6.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
3460
3502
  async function resolveGivens(dash) {
3503
+ if (rendersNoData(dash)) return { ok: true, union: [] };
3461
3504
  if (dash.tiles && dash.entryFile) {
3462
3505
  const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
3463
3506
  return { ok: true, union: t.union, tiles: t.tiles };
@@ -3510,7 +3553,7 @@ async function serveDashboard(opts) {
3510
3553
  return send(
3511
3554
  200,
3512
3555
  "text/html; charset=utf-8",
3513
- frameDoc(dash, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
3556
+ frameDoc(dash, dashboards, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
3514
3557
  );
3515
3558
  }
3516
3559
  if (url6.pathname === "/bundle.js") {
@@ -3795,11 +3838,12 @@ window.__GIVENS__ = ${safeJson(givenSpecs)};
3795
3838
  }
3796
3839
  function indexPage(dashboards, title, custom, cleanUrls, analytics) {
3797
3840
  const link = (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`;
3841
+ const listed = dashboards.filter((d) => !rendersNoData(d));
3798
3842
  const body = custom ? `<div id="root"></div>
3799
3843
  <script>window.__DASHBOARDS__ = ${safeJson(
3800
- dashboards.map((d) => ({ name: d.name, title: d.title, description: d.description, href: link(d.name) }))
3844
+ listed.map((d) => ({ name: d.name, title: d.title, description: d.description, href: link(d.name) }))
3801
3845
  )};</script>
3802
- <script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` + dashboards.map(
3846
+ <script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` + listed.map(
3803
3847
  (d) => `<li><a href="${link(d.name)}"><strong>${esc3(d.title || d.name)}</strong>` + (d.description ? `<span>${esc3(d.description)}</span>` : "") + `</a></li>`
3804
3848
  ).join("") + `</ul></main>`;
3805
3849
  return `<!doctype html>
@@ -3960,6 +4004,7 @@ boot(Dashboard);
3960
4004
  });
3961
4005
  fs7.writeFileSync(path8.join(outDir, "assets", "site.css"), SITE_CSS);
3962
4006
  for (const d of dashboards) {
4007
+ if (rendersNoData(d)) continue;
3963
4008
  let specs = [];
3964
4009
  let tileSpecs;
3965
4010
  if (d.tiles && d.entryFile) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {