@malloydata/malloyyo 0.2.16 → 0.2.18
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-entry.tsx +11 -0
- package/dist/frame-inpage-entry.tsx +38 -0
- package/dist/frame-runtime/combine.ts +181 -0
- package/dist/frame-runtime/drill.ts +138 -0
- package/dist/frame-runtime/filters.ts +188 -0
- package/dist/frame-runtime/index.ts +68 -0
- package/dist/frame-runtime/runtime.tsx +943 -0
- package/dist/frame-runtime/ui.tsx +643 -0
- package/dist/frame-runtime/vega-chart.tsx +163 -0
- package/dist/index.js +166 -124
- package/package.json +10 -4
package/dist/index.js
CHANGED
|
@@ -86,7 +86,7 @@ import path from "node:path";
|
|
|
86
86
|
import url from "node:url";
|
|
87
87
|
var HOST_ONLY = "host_only";
|
|
88
88
|
var contentFiles = {
|
|
89
|
-
"dashboards/authoring.md": '---\ndescription: How to author a dashboard \u2014 a self-contained file in the dashboards/ directory that defines its own query; the entry point for dashboard how-to\n---\n\n# Authoring dashboards\n\nA dashboard is a **self-contained `.malloy` file in the `dashboards/`\ndirectory**. The file IS the dashboard: it imports the model parts it needs,\ndefines its query (with the filtering it applies), and tags it. No manifest, and\nfor the basic case no JavaScript. Preview with `malloyyo dashboard dev`; check\nwith `malloyyo lint`. Requires `@malloydata/malloy` 0.0.423+.\n\nRelated: `yo_help dashboards/givens-and-controls` (filter controls),\n`dashboards/grid-layout` (columns/colspan/break), `dashboards/custom-components`\n(a flat `<name>.jsx`), `dashboards/vega-charts` (`<VegaChart>`).\n\n> **Need a chart the `# bar_chart`/`# line_chart`/`# shape_map` tags can\'t do?**\n> Use the `<VegaChart>` COMPONENT (a Vega-Lite spec over query rows) \u2014 NOT a `#`\n> tag; there is no `# vega_lite`. See `yo_help dashboards/vega-charts`.\n\n## The layout\n\n```\nmodel/\n ecommerce.malloy # sources, reusable views/measures, # drill tags\n givens.malloy # given: declarations (the filter controls)\n index.malloy # imports/exports sources \u2014 the MCP/data surface ONLY\n dashboards/\n overview.malloy # one dashboard; the FILENAME is its name/slug\n overview.jsx # optional custom component for overview\n```\n\n**The filename is the dashboard\'s name** \u2014 its URL slug, its `# drill` target,\nand the basename of its optional component. Discovery globs\n`dashboards/*.malloy` and compiles EACH as its own entry \u2014 so dashboards are NOT\ndeclared in, or exported through, `index.malloy` (`index.malloy` is just the\n`query`/`describe_source` data surface).\n\n## Preferred: put the query IN the dashboard file\n\nDefine the query right in `dashboards/<name>.malloy` and tag it `# artifact`, so\nthe given mapping (the `where: \u2026 ~ $GIVEN`) is visible next to the dashboard:\n\n```malloy\n// dashboards/overview.malloy\n##! experimental.givens\nimport "../ecommerce.malloy" // BARE import: source + givens in scope\n\n#" Business health at a glance \u2014 sales, margin, orders.\n# artifact { title="Business Overview" } dashboard {columns=6}\nquery: overview is order_items -> {\n where: // the given mapping, HERE\n inventory_items.product_brand ~ $BRAND, // multi-filter where: is\n inventory_items.product_category ~ $CATEGORY, // COMMA separated\n created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, total_gross_margin, order_count\n # colspan=3\n nest:\n # line_chart\n sales_trend is by_month\n top_brands\n # shape_map\n sales_by_state\n}\n```\n\nThat\'s a complete dashboard: the runtime auto-renders a title (the tag\'s\n`title`, else the `#"` doc), a control for every given the query references, and\nthe result panel. `# artifact` DECLARES the dashboard; `# dashboard {columns=6}`\nis the renderer tag that draws it as KPI tiles + a card grid \u2014 partners, on the\nsame line. Grid rules: `yo_help dashboards/grid-layout`.\n\n**You know you\'re doing it right when the `where: foo ~ $FOO` is in the DASHBOARD\nfile, not the model.** Keep the model\'s sources/views reusable and given-free;\neach dashboard decides its own filtering.\n\n**The bare import is required for controls.** A control renders only when the\ngiven\'s DECLARATION is in the dashboard file\'s scope \u2014 a bare\n`import "../ecommerce.malloy"` (or `import "../givens.malloy"`) brings them all;\nthe runtime shows a control for exactly the givens the query references. A\nselective `import { order_items } from \u2026` brings the filter but NOT the control.\n\nKeep the FILENAME as the name \u2014 don\'t set `name=`, so the URL, the\n`# drill { to=\u2026 }`, and the component basename all agree (one source of truth).\n\n## Other forms\n\n- **A view of a source you extend in the file** \u2014 tag the dashboard `view:` with\n `# artifact` (runs as `<source> -> <view>`). Good when the dashboard needs\n helper views defined alongside it.\n- **Compose existing views**: a model-level `## artifact { tiles=["a -> b", "c -> d"]\n dashboard_columns=6 }` (`##`, ONE line) names several views
|
|
89
|
+
"dashboards/authoring.md": '---\ndescription: How to author a dashboard \u2014 a self-contained file in the dashboards/ directory that defines its own query; the entry point for dashboard how-to\n---\n\n# Authoring dashboards\n\nA dashboard is a **self-contained `.malloy` file in the `dashboards/`\ndirectory**. The file IS the dashboard: it imports the model parts it needs,\ndefines its query (with the filtering it applies), and tags it. No manifest, and\nfor the basic case no JavaScript. Preview with `malloyyo dashboard dev`; check\nwith `malloyyo lint`. Requires `@malloydata/malloy` 0.0.423+.\n\nRelated: `yo_help dashboards/givens-and-controls` (filter controls),\n`dashboards/grid-layout` (columns/colspan/break), `dashboards/custom-components`\n(a flat `<name>.jsx`), `dashboards/vega-charts` (`<VegaChart>`).\n\n> **Need a chart the `# bar_chart`/`# line_chart`/`# shape_map` tags can\'t do?**\n> Use the `<VegaChart>` COMPONENT (a Vega-Lite spec over query rows) \u2014 NOT a `#`\n> tag; there is no `# vega_lite`. See `yo_help dashboards/vega-charts`.\n\n## The layout\n\n```\nmodel/\n ecommerce.malloy # sources, reusable views/measures, # drill tags\n givens.malloy # given: declarations (the filter controls)\n index.malloy # imports/exports sources \u2014 the MCP/data surface ONLY\n dashboards/\n overview.malloy # one dashboard; the FILENAME is its name/slug\n overview.jsx # optional custom component for overview\n```\n\n**The filename is the dashboard\'s name** \u2014 its URL slug, its `# drill` target,\nand the basename of its optional component. Discovery globs\n`dashboards/*.malloy` and compiles EACH as its own entry \u2014 so dashboards are NOT\ndeclared in, or exported through, `index.malloy` (`index.malloy` is just the\n`query`/`describe_source` data surface).\n\n## Preferred: put the query IN the dashboard file\n\nDefine the query right in `dashboards/<name>.malloy` and tag it `# artifact`, so\nthe given mapping (the `where: \u2026 ~ $GIVEN`) is visible next to the dashboard:\n\n```malloy\n// dashboards/overview.malloy\n##! experimental.givens\nimport "../ecommerce.malloy" // BARE import: source + givens in scope\n\n#" Business health at a glance \u2014 sales, margin, orders.\n# artifact { title="Business Overview" } dashboard {columns=6}\nquery: overview is order_items -> {\n where: // the given mapping, HERE\n inventory_items.product_brand ~ $BRAND, // multi-filter where: is\n inventory_items.product_category ~ $CATEGORY, // COMMA separated\n created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, total_gross_margin, order_count\n # colspan=3\n nest:\n # line_chart\n sales_trend is by_month\n top_brands\n # shape_map\n sales_by_state\n}\n```\n\nThat\'s a complete dashboard: the runtime auto-renders a title (the tag\'s\n`title`, else the `#"` doc), a control for every given the query references, and\nthe result panel. `# artifact` DECLARES the dashboard; `# dashboard {columns=6}`\nis the renderer tag that draws it as KPI tiles + a card grid \u2014 partners, on the\nsame line. Grid rules: `yo_help dashboards/grid-layout`.\n\n**You know you\'re doing it right when the `where: foo ~ $FOO` is in the DASHBOARD\nfile, not the model.** Keep the model\'s sources/views reusable and given-free;\neach dashboard decides its own filtering.\n\n**The bare import is required for controls.** A control renders only when the\ngiven\'s DECLARATION is in the dashboard file\'s scope \u2014 a bare\n`import "../ecommerce.malloy"` (or `import "../givens.malloy"`) brings them all;\nthe runtime shows a control for exactly the givens the query references. A\nselective `import { order_items } from \u2026` brings the filter but NOT the control.\n\nKeep the FILENAME as the name \u2014 don\'t set `name=`, so the URL, the\n`# drill { to=\u2026 }`, and the component basename all agree (one source of truth).\n\n## Other forms\n\n- **A view of a source you extend in the file** \u2014 tag the dashboard `view:` with\n `# artifact` (runs as `<source> -> <view>`). Good when the dashboard needs\n helper views defined alongside it.\n- **Compose existing views**: a model-level `## artifact { tiles=["a -> b", "c -> d"]\n dashboard_columns=6 }` (`##`, ONE line) names several views. Each tile runs as\n its own query (in parallel), and the results are combined into ONE\n `# dashboard` that Malloy\'s dashboard renderer lays out \u2014 so it looks exactly\n like the equivalent single-query dashboard: `dashboard_columns=N` sets the grid\n and `# colspan=N` / `# break` on the tile VIEWS place them. A tile that returns\n a SINGLE ROW with no group-by (an aggregate view) is merged in as top-level KPI\n tiles rather than a card (its `# colspan` is spread across those KPIs). The\n dashboard paints once the tiles are ready, with a single early paint if one tile\n straggles so a slow tile can\'t hold up the rest. Use for multi-tile /\n cross-source; prefer the inline query whenever a dashboard has its own filtering.\n- A `dashboards/*.malloy` with NO `# artifact`/`## artifact` is a shared INCLUDE\n (skipped by discovery) \u2014 put helper sources/views there for several dashboards\n to import.\n\n## Givens (filter controls)\n\nDeclare givens as `filter<T>` in the MODEL (`givens.malloy` or the source file) \u2014\nthey\'re shared and used by the MCP surface too; each dashboard APPLIES them in\nits `where:`. Full control reference: `yo_help dashboards/givens-and-controls`.\nPer-dashboard starting values go in the tag:\n\n```malloy\n# artifact { title="Ford recalls" givens { MANUFACTURER=f\'Ford Motor Company\' } }\n```\n\n## Drill from a dimension\n\n`# drill` on a source `dimension:` (in the model) makes its cells clickable \u2014\nopening another dashboard (seeding the value) or filtering in place:\n\n```malloy\ndimension:\n # drill { to=[category_explorer, self] }\n category is inventory_items.product_category\n```\n\n`to` is a list; each is a **dashboard slug** (a `dashboards/<slug>.malloy`\nfilename) \u2192 opens it, seeding the value into the given named like the dimension\nUPPER-cased (`category` \u2192 `CATEGORY`), or **`self`** \u2192 filter the current\ndashboard in place. Add `given=` when the target given differs. `lint` VERIFIES\nevery `to=` slug resolves to a real dashboard file (a typo/renamed dashboard\nfails loudly, not at click time).\n\n> **malloy#2979 (fixed in 0.0.423):** a `# drill` on a bare `group_by: name` was\n> dropped when nested through `+ {\u2026}`. Put it on the source `dimension:`, or use\n> `group_by: name is concat(name,\'\')`.\n\n## Custom component (optional)\n\nFor bespoke layout/charts, add a flat sibling `dashboards/<name>.jsx` (or\n`.tsx`). Only React + `@malloyyo/dashboard` importable (sandboxed). A bare\n`<Panel/>` renders the whole dashboard; a `<Panel query="\u2026"/>` /\n`<VegaChart query="\u2026"/>` runs a query DEFINED in this dashboard file (by name) or\na `source -> view`. `lint` checks each `query="\u2026"` still resolves. See `yo_help\ndashboards/custom-components`.\n\n## Rules\n- Each dashboard is one `dashboards/<name>.malloy`; the filename is the slug.\n Prefer the inline `query: \u2026 # artifact` form \u2014 the `where: ~ $GIVEN` lives in\n the dashboard file.\n- Bare-import the model (and/or `givens.malloy`) so the controls render.\n- Givens are `filter<T>` declared in the model; options come from `# suggest {\u2026}`;\n interactivity = setting given values, not rewriting query text.\n- `index.malloy` is the data surface, NOT where dashboards live.\n- If a query/given you need is missing, add it (check with `describe_source`).\n\n## Preview & validate\n`malloyyo dashboard dev` \u2192 open the URL; `.malloy`/`.jsx` edits hot-reload.\n`malloyyo lint` checks each dashboard file on its own: it compiles as its entry;\neach tile/query and `# suggest` compiles; `dashboard_columns` is a positive int;\nthe component compiles and its `query="\u2026"` resolve; no duplicate names, no\norphaned component; every `# drill { to=\u2026 }` resolves. Tight loop: the local\n`malloyyo mcp --develop` server hot-reloads edits \u2014 `query(execute:false)` to\ncompile-check, `execute:true` to run. Don\'t validate against a hosted/claude.ai\nconnector \u2014 it serves the PUBLISHED model (stale until `malloyyo publish`).\n',
|
|
90
90
|
"dashboards/custom-components.md": '---\ndescription: Custom dashboard UI \u2014 a flat dashboards/<name>.jsx|tsx sibling composing @malloyyo/dashboard widgets/hooks/helpers with your own React\n---\n\n# Custom dashboard components (`dashboards/<name>.jsx`)\n\nThe default UI (auto-rendered controls + panel) covers most dashboards. When it\nisn\'t enough, add ONE file \u2014 a **flat sibling** `dashboards/<name>.jsx` (or\n`.tsx`) next to the dashboard\'s `dashboards/<name>.malloy` (same basename) \u2014 that\ncomposes the runtime\'s widgets/hooks with your own React. You own layout, copy,\nand theming; the `.malloy` file still owns every query and filter. See also\n`yo_help dashboards/authoring` and `dashboards/vega-charts`.\n\n```tsx\nimport React from "react";\nimport { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";\n\nexport default function Dashboard({ dashboard, givens }) {\n return (\n <div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>\n <h1>{dashboard.title}</h1>\n <Controls>\n <Given name="STATE" /> {/* picks the control from the declaration */}\n <Search given="NAME" /> {/* committing input + typeahead + validation */}\n <TimeRange given="PERIOD" presets={[\n { value: "", text: "All time" },\n { value: filters.lastN(1, "day"), text: "Last day" },\n { value: filters.lastN(1, "week"), text: "Last week" },\n { value: filters.lastN(1, "month"), text: "Last month" },\n ]} /> {/* "Custom range\u2026" is always appended */}\n <Select given="MIN_SAMPLE"\n options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: `> ${n}` }))} />\n </Controls>\n <Panel givens={givens} /> {/* the dashboard itself (its tiles/query) */}\n <Panel query="baby_names -> births_by_decade" givens={givens} /> {/* a specific query */}\n </div>\n );\n}\n```\n\nFrom `@malloyyo/dashboard` (also handed to the component as props):\n- **Widgets** (headless-ish; restyle via className/style or the `--dash-*` CSS\n vars \u2014 see Theming below): `<Controls/>` (all givens, or compose children;\n grows Apply/Reset under `autorun=false`), `<Given name/>`,\n `<Select given [options]/>`, `<Search given/>` (committing input + typeahead +\n inline \u2715 clear), `<MultiSelect given [options]/>` (chip multi-select for a\n `filter<string>` \u2014 commits an exact-match list via `filters.oneOf`),\n `<Range given [min max]/>`, `<TimeRange given [presets]/>` (temporal presets +\n custom range), `<Checkbox given/>` (bound to a boolean given),\n `<VegaChart spec query|malloy|data givens/>` (a Vega-Lite chart over query\n rows \u2014 see `yo_help dashboards/vega-charts`)\n- **Hooks**: `useGiven(name)` \u2192 {value, set, spec};\n `useOptions(name, typed?)` \u2192 {options, loading} (typeahead);\n `useQuery({query|malloy, givens})` \u2192 {rows, loading, error} \u2014 plain rows\n for your own visuals\n- **Helpers**: `filters.oneOf/contains/between/atLeast/\u2026` build\n filter-expression strings with correct escaping; temporal:\n `filters.lastN(7, "day")` \u2192 `\'7 days\'`, `filters.dateRange("2026-01-01",\n "2026-07-01")`, `filters.afterDate/beforeDate`; read back with\n `filters.values/numberRange/threshold/inLast/temporalRange`;\n `filters.isValid(type, src)` checks typed input.\n Never hand-concatenate a filter string.\n **Escaping rule for custom controls:** a filter given\'s value is an\n EXPRESSION, so committing a raw column value is wrong the moment it contains\n a comma/percent/dash (\'Tesla, Inc.\' parses as two alternatives and matches\n nothing). Commit `filters.oneOf(value)` (exact) or\n `filters.contains(term)` (substring), and unwrap for display with\n `filters.values(src)`. The stock `<Select/>` does this automatically;\n `<Search/>` deliberately commits raw text (its input IS a filter\n expression).\n- `<Panel/>` runs against the DASHBOARD\'s own file: a bare `<Panel/>` renders\n the whole dashboard (its tiles); `<Panel query="\u2026"/>` runs a query defined in\n the dashboard file (by name) or a `source -> view`; `<Panel malloy="\u2026"/>` and\n `runData(text, givens)` run arbitrary Malloy as a RESTRICTED query (no import /\n given: / connection.* / raw SQL / ##! flags \u2014 the model\'s governed surface\n only). `lint` checks each hard-coded `query="\u2026"` still resolves.\n\n## Theming\n\nEvery widget is styled by the runtime\'s **default Malloyyo theme** (system\nfont, neutral grays, blue accent, auto light/dark following the viewer\'s OS) \u2014\na bare component looks styled with zero effort, so DON\'T hand-hardcode\n`fontFamily`/colors. The theme is CSS custom properties; override any subset by\nsetting them on a wrapper element (more specific than the runtime\'s `:root`):\n\n```tsx\n<div style={{ "--dash-accent": "#e11d48", "--dash-controls-bg": "#faf5ff" }}>\n <Controls /> \u2026\n</div>\n```\n\nVars: `--dash-font`, `--dash-bg`, `--dash-fg`, `--dash-muted`, `--dash-border`,\n`--dash-accent`, `--dash-accent-fg`, `--dash-control-bg`, `--dash-controls-bg`,\n`--dash-chip-bg`, `--dash-chip-fg`, `--dash-panel-bg`, `--dash-radius`,\n`--dash-danger`. `DefaultDashboard` also takes a `theme={{ accent, controlsBg }}`\nprop (camelCase keys \u2192 `--dash-*`). The results `<Panel>` keeps a light surface\nin both light/dark (the Malloy renderer has no dark theme) \u2014 override\n`--dash-panel-bg` if your renderer output is dark-safe.\n\nFor charts beyond the Malloy renderer\'s `#` tags, use `<VegaChart>` \u2014\n`yo_help dashboards/vega-charts`.\n',
|
|
91
91
|
"dashboards/givens-and-controls.md": "---\ndescription: Dashboard filter controls \u2014 declare filter<T> givens with # label / suggest / control tags; faceted (related) suggestions\n---\n\n# Dashboard givens & controls\n\nA dashboard's filters are `filter<T>` **givens** declared in the model; the\n`#` tags on each declaration drive its control. This is part of authoring a\ndashboard \u2014 see also `yo_help dashboards/authoring`.\n\n**Declare the filters as `filter<T>` givens** \u2014 never raw strings/numbers.\nA `filter<string>` value accepts one value ('NY'), alternatives ('NY, CA'),\nwildcards ('Ann%'), negation ('-NY'); a `filter<number>` accepts ranges\n('[1910 to 1930]') and comparisons ('> 200'); a `filter<timestamp>` /\n`filter<date>` accepts relative windows ('7 days' = the last 7 days, 'today',\n'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO `@` in\nfilter literals). Apply with `~`; `f''` = empty = no filter (the natural\n\"All\"/\"all time\" \u2014 just `col ~ $X`, no `$X = '' or \u2026` dance):\n\n```malloy\n##! experimental { givens }\ngiven:\n # label=\"State\" control=select suggest { source=baby_names dimension=state }\n STATE :: filter<string> is f'NY'\n # label=\"Brand\" suggest { query=brand_suggest dimension=product_brand }\n BRAND :: filter<string> is f''\n # label=\"Names\" control=multiselect suggest { query=name_suggest dimension=name }\n NAMES :: filter<string> is f''\n # label=\"Years\" range_min=1910 range_max=2025\n YEAR_RANGE :: filter<number> is f'[1910 to 1930]'\n # label=\"Time period\"\n PERIOD :: filter<timestamp> is f''\n # label=\"Include rare names\"\n INCLUDE_RARE :: boolean is false\n```\n\nTags on the declaration drive the control (tag syntax is `key=\"value\"` \u2014\nequals, not colon):\n- `label` \u2014 control caption (defaults to the given's name)\n- `suggest { \u2026 }` \u2014 where the control's options come from. NO Malloy code in\n strings \u2014 just names:\n - `suggest { query=brand_suggest dimension=product_brand }` \u2014 the FIRST\n COLUMN of a named query (declare the query in the model \u2014 governed and\n reviewable). PREFER THIS FORM. The query must be in scope where the dashboard\n runs (bring it in with the dashboard file's bare `import`).\n - `suggest { source=baby_names dimension=state }` \u2014 the DISTINCT VALUES of\n a dimension on a source (the source must be in the dashboard's scope)\n A `dimension` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the\n runtime refines the base query with what the user has typed\n (`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }`, case-insensitive,\n escaped). Without a dimension the fetched list is filtered client-side.\n Runs as a restricted query; lint checks the declaration compiles.\n\n **RELATED (faceted) filters** \u2014 query-form only: a suggest query may\n reference the OTHER givens, and the runtime runs it with the dashboard's\n current values (the suggested given itself is excluded, so the list never\n collapses to the current pick). Brand suggestions narrow when Category is\n set:\n\n ```malloy\n query: brand_suggest is inventory_items -> product_brand + {\n where:\n product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND\n product_department ~ $DEPARTMENT\n limit: 500\n }\n ```\n\n Declare one `*_suggest` per filter, each referencing the others; `f''`\n defaults mean unset filters don't constrain. `source=` suggests can't do\n this (no place for a `where:`) \u2014 another reason to prefer `query=`.\n- `control=select` \u2014 a fixed dropdown instead of a typeahead search box\n- `control=multiselect` \u2014 a tokenized multi-select for a `filter<string>`:\n each pick is a removable chip, the committed value is an exact-match list\n (`Emma, Olivia, Sophia`). Ideal for \"pick several\" filters (names, brands).\n Suggestions come from the given's `suggest {\u2026}` (server-side typeahead when\n it names a dimension). Empty (start from `f''`) = no filter (all).\n- `range_min` / `range_max` \u2014 bounds; makes a filter<number> given a\n dual-thumb range slider\n- anything else passes through in `spec.tags` for custom components\n\nControl picked from the declaration automatically: numeric range tags \u2192\ndual-thumb slider; `filter<timestamp|timestamptz|date>` \u2192 the TimeRange\nwidget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a\n\"Custom range\u2026\" from/to date picker); `control=multiselect` \u2192 chip\nmulti-select; suggest + control=select \u2192 dropdown; boolean \u2192 checkbox;\nanything else \u2192 committing search box with typeahead (an inline \u2715 clears it;\na \"Press \u21B5 to apply\" hint shows while the typed draft differs from what's\nrunning \u2014 free text can't safely re-run per keystroke).\nThe suggest-driven options are DATA VALUES only \u2014 options that aren't column\nvalues (custom time presets, threshold buckets) need a custom component\n(`yo_help dashboards/custom-components`) with explicit `{value, text}` options\nwhere value is a filter expression built with `filters.*`.\n\n## When the query re-runs: live (default) vs. Apply\n\nBy default a dashboard is **live** \u2014 every control change re-runs the query\nimmediately (the committing search box is the exception: free text commits on\nEnter/blur, since a half-typed filter is invalid). To batch changes behind an\n**Apply** button instead, set `autorun=false` on the `# artifact` tag:\n\n```malloy\n# artifact { name=\"births-by-name\" title=\"Births by name\" autorun=false }\n```\n\n`autorun=false` makes `<Controls>` grow an Apply/Reset pair \u2014 controls edit a\ndraft and nothing re-runs until Apply. Reach for it when the query is expensive\nor several filters are usually changed together; leave it off (live) otherwise.\n",
|
|
92
92
|
"dashboards/grid-layout.md": "---\ndescription: Dashboard grid layout \u2014 # dashboard {columns=N} with # colspan and # break to place KPI tiles and charts\n---\n\n# Dashboard grid layout (`# dashboard {columns=N}`)\n\nBy default a `# dashboard` result flows its KPI tiles and cards and wraps.\nAdd `{columns=N}` to place them on a fixed **N-column grid** instead \u2014 use\n`columns=6`, which divides evenly into 2- and 3-wide cards.\n\n**Key mechanic:** a tag placed ABOVE `aggregate:` or `nest:` applies to EVERY\nitem declared in that block. So you set card widths once per block, not per\nfield.\n\n```malloy\n# artifact { title=\"Customer Insights\" } dashboard {columns=6}\nview: customer_insights is {\n where: created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, user_count, order_count, average_order_value\n # colspan=3\n nest:\n # break\n # bar_chart\n users_by_spend_tier\n sales_by_traffic_source\n # shape_map\n sales_by_state\n # colspan=6\n recent_orders // wide detail table \u2192 full width\n}\n```\n\n## The conventions\n\n- **`# colspan=2` above `aggregate:`** \u2014 each KPI / measure tile spans 2 of 6\n columns \u2192 3 tiles per row.\n- **`# colspan=3` above `nest:`** \u2014 each graph or small table spans 3 \u2192 2 per\n row. Per-item render tags (`# line_chart`, `# bar_chart`, `# shape_map`) still\n go on the individual nested items.\n- **`# colspan=6`** \u2014 a single wide / many-column table gets its own full-width\n line. Tag that one item; a per-item `# colspan` overrides the block default.\n- **`# break` on the FIRST nest item** \u2014 starts the graphs on a fresh row, so\n KPI tiles and charts never share one. The renderer splits fields into a new\n grid at each `# break`. Just always add it: it's a no-op when the tiles\n already fill complete rows, and the fix when they don't (e.g. 4 measures\n leave a lone tile a colspan-3 chart would otherwise pack in beside).\n\n`# colspan` only does anything in columns mode \u2014 without `{columns=N}` the\nlayout is free-flow wrap and colspan is ignored. Clamp colspans to `1..N`.\n\nSee also `yo_help dashboards/vega-charts` for custom charts, and the fuller\nauthoring guide surfaced by the local `malloyyo mcp` server.\n",
|
|
@@ -1188,19 +1188,27 @@ function readArtifactTag(ident, q) {
|
|
|
1188
1188
|
const title = nested?.text("title") ?? tag.text("title") ?? description?.split("\n")[0] ?? ident.defaultName;
|
|
1189
1189
|
const rawTiles = nested?.textArray("tiles") ?? tag.textArray("tiles");
|
|
1190
1190
|
if (rawTiles && rawTiles.length) {
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1191
|
+
const resolved = rawTiles.map((t) => resolveTile(t, ident.source));
|
|
1192
|
+
const cols = nested?.numeric("dashboard_columns") ?? tag.numeric("dashboard_columns");
|
|
1193
|
+
const autorunText2 = nested?.text("autorun") ?? tag.text("autorun");
|
|
1194
|
+
const givens2 = readGivens(tag);
|
|
1195
|
+
if (resolved.length === 1) {
|
|
1196
|
+
const info3 = { name, query: resolved[0], title };
|
|
1197
|
+
if (ident.source) info3.source = ident.source;
|
|
1198
|
+
if (description) info3.description = description;
|
|
1199
|
+
if (autorunText2 === "false") info3.autorun = false;
|
|
1200
|
+
if (givens2) info3.givens = givens2;
|
|
1201
|
+
if (typeof cols === "number")
|
|
1202
|
+
info3.warnings = [
|
|
1203
|
+
"dashboard_columns is ignored on a single-tile artifact (one tile renders like a single-query `# artifact`, laid out by the tile query\u2019s own `# dashboard` tag)"
|
|
1204
|
+
];
|
|
1205
|
+
return info3;
|
|
1206
|
+
}
|
|
1207
|
+
const info2 = { name, query: "", title, tiles: resolved };
|
|
1197
1208
|
if (ident.source) info2.source = ident.source;
|
|
1198
1209
|
if (description) info2.description = description;
|
|
1199
|
-
const cols = nested?.numeric("dashboard_columns") ?? tag.numeric("dashboard_columns");
|
|
1200
1210
|
if (typeof cols === "number") info2.dashboard_columns = cols;
|
|
1201
|
-
const autorunText2 = nested?.text("autorun") ?? tag.text("autorun");
|
|
1202
1211
|
if (autorunText2 === "false") info2.autorun = false;
|
|
1203
|
-
const givens2 = readGivens(tag);
|
|
1204
1212
|
if (givens2) info2.givens = givens2;
|
|
1205
1213
|
return info2;
|
|
1206
1214
|
}
|
|
@@ -1219,7 +1227,7 @@ async function artifactQueries(runtime, entry) {
|
|
|
1219
1227
|
const model = await runtime.loadModel(entry).getModel();
|
|
1220
1228
|
const artifacts = [];
|
|
1221
1229
|
const modelComposite = readArtifactTag({ runExpr: "", defaultName: "dashboard" }, model);
|
|
1222
|
-
if (modelComposite
|
|
1230
|
+
if (modelComposite && (modelComposite.tiles || modelComposite.query)) artifacts.push(modelComposite);
|
|
1223
1231
|
for (const queryName of model.queries().named) {
|
|
1224
1232
|
const pq = model.getPreparedQueryByName(queryName);
|
|
1225
1233
|
const info = readArtifactTag({ runExpr: queryName, defaultName: queryName }, pq);
|
|
@@ -1230,7 +1238,7 @@ async function artifactQueries(runtime, entry) {
|
|
|
1230
1238
|
{ runExpr: "", defaultName: src.name, source: src.name },
|
|
1231
1239
|
src
|
|
1232
1240
|
);
|
|
1233
|
-
if (srcComposite
|
|
1241
|
+
if (srcComposite && (srcComposite.tiles || srcComposite.query)) artifacts.push(srcComposite);
|
|
1234
1242
|
for (const field of src.allFields) {
|
|
1235
1243
|
if (!field.isQueryField()) continue;
|
|
1236
1244
|
const view = field;
|
|
@@ -1279,11 +1287,11 @@ async function modelArtifact(runtime, entry, defaultName) {
|
|
|
1279
1287
|
try {
|
|
1280
1288
|
const model = await runtime.loadModel(entry).getModel();
|
|
1281
1289
|
const composite = readArtifactTag({ runExpr: "", defaultName }, model);
|
|
1282
|
-
if (composite
|
|
1290
|
+
if (composite && (composite.tiles || composite.query)) return { ok: true, artifact: composite };
|
|
1283
1291
|
for (const queryName of model.queries().named) {
|
|
1284
1292
|
const pq = model.getPreparedQueryByName(queryName);
|
|
1285
1293
|
const info = readArtifactTag({ runExpr: queryName, defaultName }, pq);
|
|
1286
|
-
if (info && !info.tiles) return { ok: true, artifact:
|
|
1294
|
+
if (info && !info.tiles) return { ok: true, artifact: info };
|
|
1287
1295
|
}
|
|
1288
1296
|
for (const src of model.explores) {
|
|
1289
1297
|
for (const field of src.allFields) {
|
|
@@ -1293,7 +1301,7 @@ async function modelArtifact(runtime, entry, defaultName) {
|
|
|
1293
1301
|
{ runExpr: `${src.name} -> ${view.name}`, defaultName, source: src.name, view: view.name },
|
|
1294
1302
|
view
|
|
1295
1303
|
);
|
|
1296
|
-
if (info && !info.tiles) return { ok: true, artifact:
|
|
1304
|
+
if (info && !info.tiles) return { ok: true, artifact: info };
|
|
1297
1305
|
}
|
|
1298
1306
|
}
|
|
1299
1307
|
return { ok: true, artifact: void 0 };
|
|
@@ -1301,61 +1309,6 @@ async function modelArtifact(runtime, entry, defaultName) {
|
|
|
1301
1309
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1302
1310
|
}
|
|
1303
1311
|
}
|
|
1304
|
-
function liftRenderTags(annotations) {
|
|
1305
|
-
if (!annotations) return [];
|
|
1306
|
-
return annotations.filter((a) => {
|
|
1307
|
-
const v = a.value;
|
|
1308
|
-
return v.startsWith("# ") && !v.startsWith("# artifact");
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
function tileAsNestField(name, res) {
|
|
1312
|
-
const fields = (res.schema?.fields ?? []).map((f) => ({
|
|
1313
|
-
name: f.name,
|
|
1314
|
-
type: f.type,
|
|
1315
|
-
annotations: f.annotations
|
|
1316
|
-
}));
|
|
1317
|
-
return {
|
|
1318
|
-
kind: "dimension",
|
|
1319
|
-
name,
|
|
1320
|
-
type: {
|
|
1321
|
-
kind: "array_type",
|
|
1322
|
-
element_type: { kind: "record_type", fields }
|
|
1323
|
-
},
|
|
1324
|
-
annotations: liftRenderTags(res.annotations)
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
function uniqueName(name, taken) {
|
|
1328
|
-
if (!taken.has(name)) {
|
|
1329
|
-
taken.add(name);
|
|
1330
|
-
return name;
|
|
1331
|
-
}
|
|
1332
|
-
let i = 2;
|
|
1333
|
-
while (taken.has(`${name}_${i}`)) i++;
|
|
1334
|
-
const out = `${name}_${i}`;
|
|
1335
|
-
taken.add(out);
|
|
1336
|
-
return out;
|
|
1337
|
-
}
|
|
1338
|
-
function combineTiles(tiles, opts = {}) {
|
|
1339
|
-
const taken = /* @__PURE__ */ new Set();
|
|
1340
|
-
const named = tiles.map((t) => ({ name: uniqueName(t.name, taken), result: t.result }));
|
|
1341
|
-
const columns = typeof opts.columns === "number" && Number.isFinite(opts.columns) ? ` {columns=${Math.trunc(opts.columns)}}` : "";
|
|
1342
|
-
return {
|
|
1343
|
-
// Provenance carried from the first tile; the combined result never runs
|
|
1344
|
-
// SQL itself, so connection_name is cosmetic but the renderer reads it.
|
|
1345
|
-
connection_name: tiles[0]?.result.connection_name ?? "composite",
|
|
1346
|
-
model_annotations: tiles[0]?.result.model_annotations,
|
|
1347
|
-
annotations: [{ value: `# dashboard${columns}
|
|
1348
|
-
` }],
|
|
1349
|
-
schema: { fields: named.map((t) => tileAsNestField(t.name, t.result)) },
|
|
1350
|
-
// One dashboard row whose columns are the tiles' data cells (verbatim).
|
|
1351
|
-
data: {
|
|
1352
|
-
kind: "array_cell",
|
|
1353
|
-
array_value: [
|
|
1354
|
-
{ kind: "record_cell", record_value: named.map((t) => t.result.data) }
|
|
1355
|
-
]
|
|
1356
|
-
}
|
|
1357
|
-
};
|
|
1358
|
-
}
|
|
1359
1312
|
var INSTANCE_PLACEHOLDER = "{{INSTANCE_NAME}}";
|
|
1360
1313
|
function renderInstructions(text, instanceName) {
|
|
1361
1314
|
return text.replaceAll(INSTANCE_PLACEHOLDER, instanceName);
|
|
@@ -1954,6 +1907,7 @@ function developSurface(host, opts = {}) {
|
|
|
1954
1907
|
|
|
1955
1908
|
// src/host.ts
|
|
1956
1909
|
var ENTRY = "index.malloy";
|
|
1910
|
+
var IDLE_SHUTDOWN_MS = 6e4;
|
|
1957
1911
|
function tileName(runExpr) {
|
|
1958
1912
|
const arrow = runExpr.lastIndexOf("->");
|
|
1959
1913
|
return (arrow >= 0 ? runExpr.slice(arrow + 2) : runExpr).trim();
|
|
@@ -1998,17 +1952,33 @@ async function makeRunner(root) {
|
|
|
1998
1952
|
let configPromise = null;
|
|
1999
1953
|
const getConfig = () => configPromise ??= loadConfig(rootUrl, reader);
|
|
2000
1954
|
let inFlight = 0;
|
|
1955
|
+
let idleTimer = null;
|
|
1956
|
+
const clearIdleTimer = () => {
|
|
1957
|
+
if (idleTimer) {
|
|
1958
|
+
clearTimeout(idleTimer);
|
|
1959
|
+
idleTimer = null;
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
const scheduleIdleShutdown = () => {
|
|
1963
|
+
clearIdleTimer();
|
|
1964
|
+
idleTimer = setTimeout(() => {
|
|
1965
|
+
idleTimer = null;
|
|
1966
|
+
if (inFlight === 0) void getConfig().then((c) => c.shutdown("idle").catch(() => {
|
|
1967
|
+
}));
|
|
1968
|
+
}, IDLE_SHUTDOWN_MS);
|
|
1969
|
+
idleTimer.unref?.();
|
|
1970
|
+
};
|
|
2001
1971
|
async function leaseIn(entryFile, fn) {
|
|
2002
1972
|
const config = await getConfig();
|
|
2003
1973
|
const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, entryFile) });
|
|
2004
1974
|
const runtime = new Runtime({ config, urlReader: prepared });
|
|
2005
1975
|
inFlight++;
|
|
1976
|
+
clearIdleTimer();
|
|
2006
1977
|
try {
|
|
2007
1978
|
return await fn(runtime, entry);
|
|
2008
1979
|
} finally {
|
|
2009
1980
|
inFlight--;
|
|
2010
|
-
if (inFlight === 0)
|
|
2011
|
-
});
|
|
1981
|
+
if (inFlight === 0) scheduleIdleShutdown();
|
|
2012
1982
|
}
|
|
2013
1983
|
}
|
|
2014
1984
|
const lease = (fn) => leaseIn(ENTRY, fn);
|
|
@@ -2016,6 +1986,7 @@ async function makeRunner(root) {
|
|
|
2016
1986
|
root: abs,
|
|
2017
1987
|
entryExists: () => fs.existsSync(path2.join(abs, ENTRY)),
|
|
2018
1988
|
async dispose() {
|
|
1989
|
+
clearIdleTimer();
|
|
2019
1990
|
if (!configPromise) return;
|
|
2020
1991
|
const config = await configPromise.catch(() => null);
|
|
2021
1992
|
configPromise = null;
|
|
@@ -2065,45 +2036,33 @@ async function makeRunner(root) {
|
|
|
2065
2036
|
artifactForFile(entryFile, defaultName) {
|
|
2066
2037
|
return leaseIn(entryFile, (runtime, entry) => modelArtifact(runtime, entry, defaultName));
|
|
2067
2038
|
},
|
|
2068
|
-
|
|
2039
|
+
dashboardGivens(entryFile, tiles) {
|
|
2069
2040
|
return leaseIn(entryFile, async (runtime, entry) => {
|
|
2070
|
-
const
|
|
2071
|
-
const ran = [];
|
|
2041
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2072
2042
|
for (const tile of tiles) {
|
|
2073
2043
|
const specs = await dashboardGivenSpecs(runtime, entry, tile);
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
runExpr: tile,
|
|
2078
|
-
givens: tileGivens,
|
|
2079
|
-
stableResult: true,
|
|
2080
|
-
rowLimit: 5e3
|
|
2081
|
-
});
|
|
2082
|
-
ran.push({ name: tileName(tile), result });
|
|
2083
|
-
}
|
|
2084
|
-
const problems = ran.flatMap((t) => t.result.problems ?? []);
|
|
2085
|
-
const good = ran.filter((t) => t.result.ok && t.result.stable_result);
|
|
2086
|
-
if (good.length === 0) return { ok: false, problems };
|
|
2087
|
-
if (good.length === 1) {
|
|
2088
|
-
return { ok: true, stable_result: good[0].result.stable_result, problems };
|
|
2044
|
+
if (specs.ok) {
|
|
2045
|
+
for (const s of specs.givens) if (!byName.has(s.name)) byName.set(s.name, s);
|
|
2046
|
+
}
|
|
2089
2047
|
}
|
|
2090
|
-
|
|
2091
|
-
good.map((t) => ({ name: t.name, result: t.result.stable_result })),
|
|
2092
|
-
{ columns: opts.columns }
|
|
2093
|
-
);
|
|
2094
|
-
return { ok: true, stable_result: combined, problems };
|
|
2048
|
+
return { ok: true, givens: [...byName.values()] };
|
|
2095
2049
|
});
|
|
2096
2050
|
},
|
|
2097
|
-
|
|
2051
|
+
dashboardTiles(entryFile, tiles) {
|
|
2098
2052
|
return leaseIn(entryFile, async (runtime, entry) => {
|
|
2099
2053
|
const byName = /* @__PURE__ */ new Map();
|
|
2054
|
+
const out = [];
|
|
2100
2055
|
for (const tile of tiles) {
|
|
2101
2056
|
const specs = await dashboardGivenSpecs(runtime, entry, tile);
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2057
|
+
const gvs = specs.ok ? specs.givens : [];
|
|
2058
|
+
for (const s of gvs) if (!byName.has(s.name)) byName.set(s.name, s);
|
|
2059
|
+
out.push({
|
|
2060
|
+
run: tile,
|
|
2061
|
+
name: tileName(tile),
|
|
2062
|
+
givens: gvs.map((s) => s.name)
|
|
2063
|
+
});
|
|
2105
2064
|
}
|
|
2106
|
-
return { ok: true,
|
|
2065
|
+
return { ok: true, tiles: out, union: [...byName.values()] };
|
|
2107
2066
|
});
|
|
2108
2067
|
},
|
|
2109
2068
|
validate(runExpr, givens) {
|
|
@@ -2154,6 +2113,7 @@ async function gatherDashboards(dir) {
|
|
|
2154
2113
|
const a = res.artifact;
|
|
2155
2114
|
const manifest = { title: a.title, entryFile };
|
|
2156
2115
|
if (a.tiles) manifest.tiles = a.tiles;
|
|
2116
|
+
else if (a.query) manifest.query = a.query;
|
|
2157
2117
|
if (a.dashboard_columns !== void 0) manifest.dashboard_columns = a.dashboard_columns;
|
|
2158
2118
|
if (a.description) manifest.description = a.description;
|
|
2159
2119
|
if (a.givens) manifest.givens = a.givens;
|
|
@@ -2252,6 +2212,7 @@ async function runLint(abs, runner) {
|
|
|
2252
2212
|
}
|
|
2253
2213
|
if (!res.artifact) continue;
|
|
2254
2214
|
const art = res.artifact;
|
|
2215
|
+
if (art.warnings) warnings.push(...art.warnings);
|
|
2255
2216
|
if (seenNames.has(art.name)) {
|
|
2256
2217
|
errors.push(`duplicate dashboard name "${art.name}" (also declared by ${seenNames.get(art.name)})`);
|
|
2257
2218
|
} else {
|
|
@@ -2260,8 +2221,8 @@ async function runLint(abs, runner) {
|
|
|
2260
2221
|
if (art.dashboard_columns !== void 0 && (!Number.isInteger(art.dashboard_columns) || art.dashboard_columns < 1)) {
|
|
2261
2222
|
errors.push(`dashboard_columns must be a positive integer (got ${JSON.stringify(art.dashboard_columns)})`);
|
|
2262
2223
|
}
|
|
2263
|
-
const tiles = art.tiles ?? [];
|
|
2264
|
-
if (tiles.length === 0) errors.push(
|
|
2224
|
+
const tiles = art.tiles ?? (art.query ? [art.query] : []);
|
|
2225
|
+
if (tiles.length === 0) errors.push(`\`# artifact\` declares neither a query nor tiles`);
|
|
2265
2226
|
for (const tile of tiles) {
|
|
2266
2227
|
const v = await runner.validateIn(entryFile, tile, {});
|
|
2267
2228
|
if (!v.ok) errors.push(`tile "${tile}": ${v.error}`);
|
|
@@ -2763,19 +2724,29 @@ for (const spec of HOST_LIBS) {
|
|
|
2763
2724
|
function resolveRuntimeDir() {
|
|
2764
2725
|
const candidates = [
|
|
2765
2726
|
new URL("./frame-runtime/", import.meta.url),
|
|
2766
|
-
//
|
|
2727
|
+
// src/dashboard.ts (tsx) OR dist/index.js (published)
|
|
2767
2728
|
new URL("../src/frame-runtime/", import.meta.url)
|
|
2768
|
-
// built
|
|
2729
|
+
// built dist/ next to sibling src/ (checkout)
|
|
2769
2730
|
].map((u) => fileURLToPath(u));
|
|
2770
2731
|
const found = candidates.find((c) => fs3.existsSync(c));
|
|
2771
2732
|
if (!found) {
|
|
2772
2733
|
throw new Error(
|
|
2773
|
-
"frame-runtime/ not found
|
|
2734
|
+
"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`."
|
|
2774
2735
|
);
|
|
2775
2736
|
}
|
|
2776
2737
|
return found;
|
|
2777
2738
|
}
|
|
2778
2739
|
var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
2740
|
+
var resolveInPageEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-inpage-entry.tsx");
|
|
2741
|
+
var hostAliasPlugin = {
|
|
2742
|
+
name: "host-alias",
|
|
2743
|
+
setup(b) {
|
|
2744
|
+
b.onResolve(
|
|
2745
|
+
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
|
|
2746
|
+
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
};
|
|
2779
2750
|
async function discoverDashboards(root, runner) {
|
|
2780
2751
|
const dir = path4.join(root, "dashboards");
|
|
2781
2752
|
if (!fs3.existsSync(dir)) return [];
|
|
@@ -2831,12 +2802,9 @@ function makeBundler() {
|
|
|
2831
2802
|
}));
|
|
2832
2803
|
}
|
|
2833
2804
|
b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
|
|
2834
|
-
b.onResolve(
|
|
2835
|
-
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
|
|
2836
|
-
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
2837
|
-
);
|
|
2838
2805
|
}
|
|
2839
|
-
}
|
|
2806
|
+
},
|
|
2807
|
+
hostAliasPlugin
|
|
2840
2808
|
]
|
|
2841
2809
|
});
|
|
2842
2810
|
const js = result.outputFiles[0].text;
|
|
@@ -2844,15 +2812,61 @@ function makeBundler() {
|
|
|
2844
2812
|
return js;
|
|
2845
2813
|
};
|
|
2846
2814
|
}
|
|
2815
|
+
function makeInPageBundler() {
|
|
2816
|
+
let cached;
|
|
2817
|
+
const entry = resolveInPageEntry();
|
|
2818
|
+
const runtimeDir = resolveRuntimeDir();
|
|
2819
|
+
const stampOf = () => fs3.statSync(entry).mtimeMs + fs3.readdirSync(runtimeDir).map((f) => fs3.statSync(path4.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2820
|
+
return async function bundle() {
|
|
2821
|
+
const stamp = stampOf();
|
|
2822
|
+
if (cached && cached.stamp === stamp) return cached.js;
|
|
2823
|
+
const result = await esbuild2.build({
|
|
2824
|
+
entryPoints: [entry],
|
|
2825
|
+
bundle: true,
|
|
2826
|
+
format: "iife",
|
|
2827
|
+
platform: "browser",
|
|
2828
|
+
jsx: "automatic",
|
|
2829
|
+
write: false,
|
|
2830
|
+
logLevel: "silent",
|
|
2831
|
+
loader: { ".css": "empty" },
|
|
2832
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
2833
|
+
plugins: [hostAliasPlugin]
|
|
2834
|
+
});
|
|
2835
|
+
const js = result.outputFiles[0].text;
|
|
2836
|
+
cached = { stamp, js };
|
|
2837
|
+
return js;
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2847
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 navHtml(dash, all) {
|
|
2842
|
+
if (all.length <= 1) return "";
|
|
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>`;
|
|
2847
|
+
}
|
|
2848
|
+
function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
|
|
2849
|
+
const info = {
|
|
2850
|
+
name: dash.name,
|
|
2851
|
+
query: dash.query,
|
|
2852
|
+
title: dash.title,
|
|
2853
|
+
description: dash.description,
|
|
2854
|
+
tiles: dash.tiles,
|
|
2855
|
+
tileSpecs,
|
|
2856
|
+
dashboard_columns: dash.dashboard_columns,
|
|
2857
|
+
givens: dash.givens,
|
|
2858
|
+
autorun: dash.autorun
|
|
2859
|
+
};
|
|
2860
|
+
return html(
|
|
2861
|
+
navHtml(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
|
+
dash.title
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2848
2865
|
function parentShell(dash, frameBase, all, initialGivens) {
|
|
2849
2866
|
const givensQs = Object.entries(initialGivens).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
|
|
2850
2867
|
const d = JSON.stringify(dash.name);
|
|
2851
2868
|
const fb = JSON.stringify(frameBase);
|
|
2852
|
-
const nav =
|
|
2853
|
-
const on = x.name === dash.name;
|
|
2854
|
-
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>`;
|
|
2855
|
-
}).join("") + `</nav>` : "";
|
|
2869
|
+
const nav = navHtml(dash, all);
|
|
2856
2870
|
return html(
|
|
2857
2871
|
`<div style="display:flex;flex-direction:column;height:100vh">` + nav + // allow-popups(+escape-sandbox): let a # link mark open its target in a
|
|
2858
2872
|
// normal new tab on click instead of being blocked by the sandbox.
|
|
@@ -2894,13 +2908,16 @@ function givensFromUrl(url4) {
|
|
|
2894
2908
|
for (const [k, v] of url4.searchParams) if (k !== "d") g[k] = v;
|
|
2895
2909
|
return g;
|
|
2896
2910
|
}
|
|
2897
|
-
function frameDoc(dash, givenSpecs, initialGivens) {
|
|
2911
|
+
function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
|
|
2898
2912
|
const info = {
|
|
2899
2913
|
name: dash.name,
|
|
2900
2914
|
query: dash.query,
|
|
2901
2915
|
title: dash.title,
|
|
2902
2916
|
description: dash.description,
|
|
2903
2917
|
tiles: dash.tiles,
|
|
2918
|
+
// Per-tile run/name/given-names for the composite renderer (composite only).
|
|
2919
|
+
// Each tile runs with just the givens it references.
|
|
2920
|
+
tileSpecs,
|
|
2904
2921
|
dashboard_columns: dash.dashboard_columns,
|
|
2905
2922
|
givens: dash.givens,
|
|
2906
2923
|
autorun: dash.autorun
|
|
@@ -2933,7 +2950,17 @@ async function serveDashboard(opts) {
|
|
|
2933
2950
|
}
|
|
2934
2951
|
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2935
2952
|
const bundle = makeBundler();
|
|
2953
|
+
const inPageBundle = makeInPageBundler();
|
|
2936
2954
|
const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
2955
|
+
async function resolveGivens(dash) {
|
|
2956
|
+
if (dash.tiles && dash.entryFile) {
|
|
2957
|
+
const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
|
|
2958
|
+
return { ok: true, union: t.union, tiles: t.tiles };
|
|
2959
|
+
}
|
|
2960
|
+
const specs = dash.entryFile ? await runner.givensForQueryIn(dash.entryFile, dash.query) : await runner.givensForQuery(dash.query);
|
|
2961
|
+
if (!specs.ok) return { ok: false, error: specs.error };
|
|
2962
|
+
return { ok: true, union: specs.givens };
|
|
2963
|
+
}
|
|
2937
2964
|
const sseClients = /* @__PURE__ */ new Set();
|
|
2938
2965
|
const notifyReload = () => {
|
|
2939
2966
|
for (const c of sseClients) c.write("data: reload\n\n");
|
|
@@ -2967,15 +2994,15 @@ async function serveDashboard(opts) {
|
|
|
2967
2994
|
if (onFramePort) {
|
|
2968
2995
|
if (url4.pathname === "/frame") {
|
|
2969
2996
|
const dash = pick(url4);
|
|
2970
|
-
const
|
|
2971
|
-
if (!
|
|
2997
|
+
const g = await resolveGivens(dash);
|
|
2998
|
+
if (!g.ok) {
|
|
2972
2999
|
return send(
|
|
2973
3000
|
200,
|
|
2974
3001
|
"text/html; charset=utf-8",
|
|
2975
|
-
html(`<pre style="color:crimson;padding:16px">model error: ${esc(
|
|
3002
|
+
html(`<pre style="color:crimson;padding:16px">model error: ${esc(g.error)}</pre>`, dash.title)
|
|
2976
3003
|
);
|
|
2977
3004
|
}
|
|
2978
|
-
return send(200, "text/html; charset=utf-8", frameDoc(dash,
|
|
3005
|
+
return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url4), g.tiles));
|
|
2979
3006
|
}
|
|
2980
3007
|
if (url4.pathname === "/bundle.js") {
|
|
2981
3008
|
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
|
|
@@ -2990,14 +3017,29 @@ async function serveDashboard(opts) {
|
|
|
2990
3017
|
return;
|
|
2991
3018
|
}
|
|
2992
3019
|
if (url4.pathname === "/") {
|
|
2993
|
-
|
|
3020
|
+
const dash = pick(url4);
|
|
3021
|
+
if (!dash.tsxPath) {
|
|
3022
|
+
const g = await resolveGivens(dash);
|
|
3023
|
+
if (!g.ok) {
|
|
3024
|
+
return send(
|
|
3025
|
+
200,
|
|
3026
|
+
"text/html; charset=utf-8",
|
|
3027
|
+
html(`<pre style="color:crimson;padding:16px">model error: ${esc(g.error)}</pre>`, dash.title)
|
|
3028
|
+
);
|
|
3029
|
+
}
|
|
3030
|
+
return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url4), g.tiles));
|
|
3031
|
+
}
|
|
3032
|
+
return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url4)));
|
|
3033
|
+
}
|
|
3034
|
+
if (url4.pathname === "/inpage.js") {
|
|
3035
|
+
return send(200, "application/javascript; charset=utf-8", await inPageBundle());
|
|
2994
3036
|
}
|
|
2995
3037
|
if (url4.pathname === "/api/run" && req.method === "POST") {
|
|
2996
|
-
const { d, query, malloy, givens
|
|
3038
|
+
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
2997
3039
|
const dash = byName.get(d);
|
|
2998
3040
|
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
2999
3041
|
const entry = dash.entryFile;
|
|
3000
|
-
const out =
|
|
3042
|
+
const out = typeof malloy === "string" ? entry ? await runner.runTextIn(entry, malloy, givens ?? {}) : await runner.runText(malloy, givens ?? {}) : entry ? await runner.runIn(entry, String(query ?? ""), givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
|
|
3001
3043
|
return send(200, "application/json", JSON.stringify(out));
|
|
3002
3044
|
}
|
|
3003
3045
|
send(404, "text/plain", "not found");
|
|
@@ -3013,7 +3055,7 @@ async function serveDashboard(opts) {
|
|
|
3013
3055
|
malloyyo dashboard dev \u2014 model: ${root}`);
|
|
3014
3056
|
console.error(` http://localhost:${port}/ (artifact origin: ${frameBase})`);
|
|
3015
3057
|
for (const d of dashboards) {
|
|
3016
|
-
const kind = d.tsxPath ? "custom" : "
|
|
3058
|
+
const kind = d.tsxPath ? "custom (iframe)" : "tag-only (in-page)";
|
|
3017
3059
|
console.error(` \u2022 ${d.name} (${kind}) \u2192 http://localhost:${port}/?d=${d.name}`);
|
|
3018
3060
|
}
|
|
3019
3061
|
console.error(` Ctrl-C to stop.
|
|
@@ -3141,7 +3183,7 @@ async function launchCmd(mode, opts) {
|
|
|
3141
3183
|
}
|
|
3142
3184
|
|
|
3143
3185
|
// package.json
|
|
3144
|
-
var version = "0.2.
|
|
3186
|
+
var version = "0.2.18";
|
|
3145
3187
|
|
|
3146
3188
|
// src/index.ts
|
|
3147
3189
|
function shortSha(sha) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloydata/malloyyo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "Publish Malloy models to a Malloyyo instance",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build:engine": "cd ../mcp-engine && npm run build",
|
|
26
|
-
"build": "npm run build:engine && esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js",
|
|
26
|
+
"build": "npm run build:engine && esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js && node scripts/copy-frame-src.mjs",
|
|
27
27
|
"dev": "tsx src/index.ts",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
29
|
"pretest": "npm run build",
|
|
@@ -35,13 +35,19 @@
|
|
|
35
35
|
"@malloydata/malloy": "^0.0.423",
|
|
36
36
|
"@malloydata/malloy-connections": "^0.0.423",
|
|
37
37
|
"@malloydata/malloy-filter": "^0.0.423",
|
|
38
|
+
"@malloydata/render": "^0.0.423",
|
|
38
39
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
|
-
"commander": "^12.1.0"
|
|
40
|
+
"commander": "^12.1.0",
|
|
41
|
+
"esbuild": "^0.24.0",
|
|
42
|
+
"react": "19.2.4",
|
|
43
|
+
"react-dom": "19.2.4",
|
|
44
|
+
"vega": "^5.33.1",
|
|
45
|
+
"vega-interpreter": "^2.2.1",
|
|
46
|
+
"vega-lite": "^5.23.0"
|
|
40
47
|
},
|
|
41
48
|
"devDependencies": {
|
|
42
49
|
"@malloyyo/mcp-engine": "*",
|
|
43
50
|
"@types/node": "^20",
|
|
44
|
-
"esbuild": "^0.24.0",
|
|
45
51
|
"tsx": "^4.21.0",
|
|
46
52
|
"typescript": "^5"
|
|
47
53
|
}
|