@malloydata/malloyyo 0.2.15 → 0.2.17

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.
Files changed (2) hide show
  1. package/dist/index.js +720 -385
  2. package/package.json +6 -6
package/dist/index.js CHANGED
@@ -86,6 +86,11 @@ 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, run separately and\n combined into one `# dashboard`. Use for multi-tile / cross-source; prefer the\n 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
+ "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
+ "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
+ "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",
93
+ "dashboards/vega-charts.md": '---\ndescription: Custom dashboard charts with Vega-Lite \u2014 the <VegaChart> component, for charts the # renderer tags can\'t do\n---\n\n# Custom charts with Vega-Lite (`<VegaChart>`)\n\nWhen Malloy\'s renderer tags (`# bar_chart`, `# line_chart`, `# shape_map`, \u2026)\ndon\'t cover the chart you want, a dashboard can draw a **Vega-Lite** spec with\nthe `<VegaChart>` component. The chart engine ships in the dashboard runtime, so\nyou author only a JSON spec + a Malloy query \u2014 no library to load.\n\n**It is a COMPONENT, not a `#` tag.** There is no `# vega_lite` or\n`# scatter_chart` tag. `<VegaChart>` lives in a custom component \u2014 a flat sibling\n`dashboards/<name>.jsx` (or `.tsx`) next to the dashboard\'s\n`dashboards/<name>.malloy` \u2014 a different layer from the `#` renderer tags. (The\ndashboard\'s query is declared in the `.malloy` file; the component only\ncustomizes presentation. Preview with `malloyyo dashboard dev`, validate with\n`malloyyo lint`.)\n\n## The recipe\n\n```tsx\nimport { VegaChart } from "@malloyyo/dashboard";\n\n// Encodings point at the query\'s OUTPUT COLUMN NAMES (here: name, births).\nconst spec = {\n mark: { type: "bar", tooltip: true },\n encoding: {\n y: { field: "name", type: "nominal", sort: "-x" },\n x: { field: "births", type: "quantitative" },\n },\n};\n\nexport default function Dashboard({ givens }) {\n return <VegaChart spec={spec} query="births_by_name" givens={givens} />;\n}\n```\n\nThree ways to feed it data:\n- `<VegaChart spec={spec} query="births_by_name" givens={givens}/>` \u2014 a query\n defined in the dashboard\'s `.malloy` file (by name), or a `source -> view`\n- `<VegaChart spec={spec} malloy="source -> view" givens={givens}/>` \u2014 restricted\n Malloy text (same governance as the explore surface: no import / given: /\n connection.* / raw SQL / ##! flags)\n- `<VegaChart spec={spec} data={rows}/>` \u2014 rows you already have from `useQuery`\n\n## Gotchas (the ones that actually bite)\n\n- **Shape the data in Malloy; return FLAT rows.** Do ranking, share/percent\n (`all(x, dim)`), and label lookups (a `pick` for month names) in the QUERY.\n The spec just encodes columns \u2014 it is not the place to reshape data.\n- **Match column names character-for-character.** Run the query once with\n `query(execute:true)` and read the exact output column names; the spec\'s\n `field` values must match them exactly.\n- **The spec\'s `data` is ignored / any `url` is stripped.** The frame has no\n network \u2014 remote data URLs, transform lookups, and remote `image` marks are\n removed. Adapting a Vega-Lite gallery example = delete its\n `"data": {"url": \u2026}` and repoint the encodings; the query rows are inlined for\n you as the dataset.\n- **Nests come back as arrays.** Flatten to plottable rows in the query, or bind\n a nest to its own chart: `<VegaChart data={row.my_nest}/>`.\n- **Interactivity = setting given values**, never rewriting query text per\n interaction. Client-side chart interactions (tooltip, zoom, brush) work;\n anything that calls a server does not.\n- **Reads well:** for normalized/share data use a diverging color scale with\n `domainMid` (e.g. `1/12` for month-share), and sort a discrete axis by a\n companion numeric field (`month_name` sorted by `month_num`) rather than\n alphabetically.\n\n## Validate\n\n`malloyyo lint` checks the query, the givens, AND the component (it compiles,\nand each `query="\u2026"` it references resolves) \u2014 your only pre-browser check. Then\n`malloyyo dashboard dev` to see it render.\n',
89
94
  "develop/connection-setup.md": '---\ndescription: Setting up a data connection (malloy-config.json)\n---\n\n# Setting up a data connection\n\nA model reaches its data through a **connection** declared in\n`malloy-config.json` at the **root of the model** (next to `index.malloy`). This\nis Malloy\'s standard connection config \u2014 the full reference, with every\nconnector\'s properties, is at\n<https://docs.malloydata.dev/documentation/setup/config>. The essentials:\n\n## The file\n\n```json\n{\n "connections": {\n "mydb": { "is": "duckdb" }\n }\n}\n```\n\n- A connection has a **name** (the key) and a type (`is`). Sources refer to it by\n that name \u2014 `source: x is mydb.table("orders")` or `mydb.sql("SELECT \u2026")` \u2014 so\n the name in the config must match the name in the model.\n- Supported types (`is`): `duckdb` (incl. MotherDuck), `bigquery`, `postgres`,\n `mysql`, `snowflake`, `databricks`, `trino`, `presto`. Each has its own\n properties \u2014 see the full docs.\n\n## Default connections (and when they apply)\n\nSetting `"includeDefaultConnections": true` makes one connection available for\n**each registered database type, named by the type** \u2014 a `duckdb` connection\nnamed `duckdb`, a `postgres` named `postgres`, and so on. Each uses that\nconnector\'s default settings, which for several backends means picking up\ncredentials from the environment (e.g. BigQuery\'s application-default\ncredentials \u2014 see the per-connector setup docs). Connections you name explicitly\nin `connections` always win; the defaults only fill in types you didn\'t list.\n\n```json\n{\n "includeDefaultConnections": true,\n "connections": { "warehouse": { "is": "postgres", "host": "\u2026" } }\n}\n```\n\nmalloyyo has one rule worth knowing:\n\n- **No `malloy-config.json` at all \u2192 defaults are ON.** `duckdb` just works with\n zero setup.\n- **Write a `malloy-config.json` and they turn OFF** unless you add\n `"includeDefaultConnections": true`. A config that only defines, say, a\n `postgres` connection will report *No connection named "duckdb"* if a source\n still references `duckdb`.\n\nThis is deliberate \u2014 your local connections then resolve exactly the way the\npublished server\'s will, rather than silently leaning on a default that would not\nexist in production. (It differs from `malloy-cli`, which forces the defaults on\nunconditionally.)\n\nGive a connection a **custom name** \u2014 not the bare type default \u2014 whenever you\nhave more than one connection of the same type, or need non-default parameters.\n\n## DuckDB and local files (the common case)\n\nDuckDB can either open a **DuckDB database file** or read **local data files**\n(CSV, Parquet, \u2026) directly.\n\n**A pre-loaded database file** \u2014 point `databasePath` at a `.duckdb` file and\nreference its tables by name (an absolute path is safest):\n\n```json\n{ "connections": { "warehouse": { "is": "duckdb", "databasePath": "/data/warehouse.duckdb" } } }\n```\n\n**Local files, by path** \u2014 read a CSV or Parquet file straight into a source.\nThese paths are **project-relative** \u2014 resolved against the model root, so they\nsurvive publishing:\n\n source: my_csv is duckdb.table(\'data/my_file.csv\')\n source: my_parquet is duckdb.table(\'data/my_file.parquet\')\n\n`.table()` names a single file. When you need something it can\'t express \u2014 a\nglob, a union, any SQL \u2014 wrap it in `.sql()` (its paths are project-relative too):\n\n source: payments is duckdb.sql(\n "SELECT * FROM read_parquet(\'data/payments-*.parquet\')"\n )\n\nThe default `duckdb` connection is **in-memory** \u2014 nothing persists between runs;\nyour data lives in the files (or the `databasePath` database) you read.\n\n**MotherDuck:** a DuckDB connection against an `md:` database; set the\n`MOTHERDUCK_TOKEN` environment variable.\n\n## Secrets \u2014 keep them out of the file\n\nAny property value may be written as `{ "env": "VAR_NAME" }`. It resolves from\n`process.env.VAR_NAME` when the connection opens, so passwords and tokens never\nget committed:\n\n```json\n{\n "connections": {\n "analytics": {\n "is": "postgres",\n "host": "db.internal",\n "databaseName": "analytics",\n "username": "reader",\n "password": { "env": "PG_PASSWORD" }\n }\n }\n}\n```\n\n(The non-secret property names here are illustrative \u2014 each connector\'s exact\nproperties are in the full docs. The `{ "env": \u2026 }` form is the part that\nmatters: it works for any value.)\n\n## malloyyo specifics\n\n- **One file, one place.** Only the `malloy-config.json` at the model root is\n read \u2014 there is no walk-up to parent directories.\n- **Local override:** a `malloy-config-local.json` (do **not** commit it)\n **replaces** `malloy-config.json` entirely when present \u2014 your private variant\n for local credentials or a different database.\n- **The same file ships to production.** Publishing uploads this exact\n `malloy-config.json`, so it must resolve the same way locally and on the\n server \u2014 put anything environment-specific behind `{ "env": \u2026 }` rather than\n hard-coding it. That is what makes the local test window faithful to\n production.\n- Edits are picked up **without a restart** \u2014 the server re-reads the file when\n it changes.\n\n## When a connection will not resolve\n\nFix the connection **first**: a broken connection yields an empty schema and then\na cascade of misleading `field-not-found` errors \u2014 ignore those and fix the\nconnection. A fast check is to compile a probe and see if it alone compiles:\n\n source: _probe is mydb.sql("SELECT 1 AS one")\n',
90
95
  "develop/getting-started.md": '---\ndescription: Getting started \u2014 build a Malloy model step by step\n---\n\n# Building a Malloy model, step by step\n\nA model is a `malloy-config.json` (the connection to the data) and an\n`index.malloy` (the published query surface), optionally with other `.malloy`\nfiles that `index.malloy` imports. You edit these with your own file tools; the\nMCP tools compile, inspect, and test what you wrote. Never read `.malloy` as\ntext \u2014 compiling a bare source is how you read a table\'s schema.\n\n## 1. Verify the connection first\n\nConfirm the connection named in `malloy-config.json` resolves \u2014 compile a\nthrowaway probe inline with `compile` (no file needed):\n\n source: _probe is CONN.sql("SELECT 1 AS one")\n\nIf it compiles, the connection is good. If not, fix the connection / config\nbefore going further \u2014 a broken connection produces an empty schema and then a\ncascade of misleading `field-not-found` errors; ignore the cascade and fix the\nconnection. Call `yo_help("develop/connection-setup")` for how to set up or repair a connection.\n\n## 2. Identify the tables the model needs\n\nIf you are unsure which tables matter, ask the fox \u2014 they own the data and know\nwhere it lives.\n\n## 3. Get a base source per table\n\nA base is "what\'s in the table and what\'s computable from it" \u2014 no joins.\nDiscover the schema by compiling a bare stub inline with `compile`:\n\n source: users_base is CONN.table("users")\n\n`compile` returns the full column list + types \u2014 that is your schema browser.\nThen write the base into its own file and iterate with `compile_file`, adding\nonly the dimensions and measures intrinsic to that one table:\n\n // users_base.malloy\n source: users_base is CONN.table("users") extend {\n measure: user_count is count()\n }\n\nIf the data lives in files rather than database tables \u2014 common when the\nconnection is DuckDB \u2014 DuckDB lets you name a file path as the table (a\nproject-relative path):\n\n source: users_base is CONN.table(\'data/users.parquet\')\n\nOnly drop to a `.sql()` block when a single file-as-table can\'t express what you\nneed \u2014 e.g. globbing or unioning several files:\n\n source: users_base is CONN.sql("SELECT * FROM read_parquet(\'data/users-*.parquet\')")\n\n(`read_parquet` there is DuckDB SQL, not Malloy \u2014 see `yo_help("develop/connection-setup")`.)\n\n## 4. Assemble index.malloy \u2014 the published surface\n\nImport the bases, join them into the consumer-facing sources, and explicitly\nexport what consumers may query:\n\n import "users_base.malloy"\n import "orders_base.malloy"\n\n source: users is users_base extend {\n join_many: orders is orders_base on id = orders.user_id\n }\n source: orders is orders_base extend { }\n\n export { users, orders }\n\n**Export discipline** \u2014 the model is a published artifact, so be deliberate about\nits public surface:\n\n- Imported names are private. Base sources stay internal scaffolding unless you\n export them.\n- Without an `export` statement, everything you define is public. Add one and the\n surface becomes explicit: only the names you list \u2014 defined or imported \u2014 are\n public.\n- Hide intermediates. A staging source you define only so other sources can build\n on it should not be exported.\n- The export list is the consumer\'s menu \u2014 exactly what the test window and real\n consumers can query, nothing more.\n\n## The loop\n\nEdit a file \u2192 `compile_file` \u2192 fix `problems[]` \u2192 `query` (pass the model file\'s\npath as `ref`; `execute: false` validates first and reports the givens the query\nneeds, supplied via `givens`). Query text is restricted \u2014 `import`, `given:`\ndeclarations, `connection.table`/`connection.sql`, raw SQL, and `##!` flags\nbelong in the model file, not the query. When `compile`/`compile_file` returns\n`formatted: false`, call `prettify` and save its output. Use project-relative\ndata paths, not absolute \u2014 they resolve against the project root and survive\npublishing the model.\n',
91
96
  "develop/working-with-models.md": "---\ndescription: Working with an existing Malloy model\n---\n\n# Working with an existing model\n\nAn existing model is an `index.malloy` (plus any `.malloy` files it imports) and\na `malloy-config.json`.\n\n## Understand the model\n\n- **Read `malloy-config.json` directly** \u2014 it is JSON, so read it as text. It\n lists the connection(s) the model queries against; `yo_help(\"develop/connection-setup\")` explains\n the format (and how to set one up or repair it).\n- **Do NOT read `.malloy` as text \u2014 compile it.** `compile_file` returns the\n structured model: each source with its fields, joins, views, and named queries,\n plus `problems[]`. That is how you describe what is in the model. (Compiling a\n bare source \u2014 no `extend` block \u2014 likewise reads a raw table's schema.)\n\n## The loop\n\nEdit a file \u2192 `compile_file` \u2192 fix `problems[]` \u2192 `query`. Pass the model file's\npath as `ref`; `execute: false` validates first and reports the givens the query\nneeds (supplied via `givens`). Query text is restricted \u2014 `import`, `given:`\ndeclarations, `connection.table`/`connection.sql`, raw SQL, and `##!` flags\nbelong in the model file, not the query. When `compile`/`compile_file` returns\n`formatted: false`, run `prettify` and save its output. Use project-relative data\npaths, not absolute.\n",
@@ -1152,6 +1157,23 @@ function docText(t) {
1152
1157
  return void 0;
1153
1158
  }
1154
1159
  }
1160
+ function resolveTile(tile, source) {
1161
+ const t = tile.trim();
1162
+ if (source && !t.includes("->")) return `${source} -> ${t}`;
1163
+ return t;
1164
+ }
1165
+ function readGivens(tag) {
1166
+ const givensTag = tag.tag("artifact", "givens");
1167
+ if (!givensTag) return void 0;
1168
+ const givens = {};
1169
+ for (const [key, t] of Object.entries(givensTag.dict ?? {})) {
1170
+ const eq = t.eq;
1171
+ if (typeof eq === "string" || typeof eq === "number" || typeof eq === "boolean") {
1172
+ givens[key] = eq;
1173
+ }
1174
+ }
1175
+ return Object.keys(givens).length ? givens : void 0;
1176
+ }
1155
1177
  function readArtifactTag(ident, q) {
1156
1178
  let tag;
1157
1179
  try {
@@ -1164,33 +1186,51 @@ function readArtifactTag(ident, q) {
1164
1186
  const description = docText(q);
1165
1187
  const name = nested?.text("name") ?? tag.text("name") ?? ident.defaultName;
1166
1188
  const title = nested?.text("title") ?? tag.text("title") ?? description?.split("\n")[0] ?? ident.defaultName;
1189
+ const rawTiles = nested?.textArray("tiles") ?? tag.textArray("tiles");
1190
+ if (rawTiles && rawTiles.length) {
1191
+ const info2 = {
1192
+ name,
1193
+ query: "",
1194
+ title,
1195
+ tiles: rawTiles.map((t) => resolveTile(t, ident.source))
1196
+ };
1197
+ if (ident.source) info2.source = ident.source;
1198
+ if (description) info2.description = description;
1199
+ const cols = nested?.numeric("dashboard_columns") ?? tag.numeric("dashboard_columns");
1200
+ if (typeof cols === "number") info2.dashboard_columns = cols;
1201
+ const autorunText2 = nested?.text("autorun") ?? tag.text("autorun");
1202
+ if (autorunText2 === "false") info2.autorun = false;
1203
+ const givens2 = readGivens(tag);
1204
+ if (givens2) info2.givens = givens2;
1205
+ return info2;
1206
+ }
1167
1207
  const info = { name, query: ident.runExpr, title };
1168
1208
  if (ident.source) info.source = ident.source;
1169
1209
  if (ident.view) info.view = ident.view;
1170
1210
  if (description) info.description = description;
1171
- const givensTag = tag.tag("artifact", "givens");
1172
- if (givensTag) {
1173
- const givens = {};
1174
- for (const [key, t] of Object.entries(givensTag.dict ?? {})) {
1175
- const eq = t.eq;
1176
- if (typeof eq === "string" || typeof eq === "number" || typeof eq === "boolean") {
1177
- givens[key] = eq;
1178
- }
1179
- }
1180
- if (Object.keys(givens).length) info.givens = givens;
1181
- }
1211
+ const autorunText = nested?.text("autorun") ?? tag.text("autorun");
1212
+ if (autorunText === "false") info.autorun = false;
1213
+ const givens = readGivens(tag);
1214
+ if (givens) info.givens = givens;
1182
1215
  return info;
1183
1216
  }
1184
1217
  async function artifactQueries(runtime, entry) {
1185
1218
  try {
1186
1219
  const model = await runtime.loadModel(entry).getModel();
1187
1220
  const artifacts = [];
1221
+ const modelComposite = readArtifactTag({ runExpr: "", defaultName: "dashboard" }, model);
1222
+ if (modelComposite?.tiles) artifacts.push(modelComposite);
1188
1223
  for (const queryName of model.queries().named) {
1189
1224
  const pq = model.getPreparedQueryByName(queryName);
1190
1225
  const info = readArtifactTag({ runExpr: queryName, defaultName: queryName }, pq);
1191
1226
  if (info) artifacts.push(info);
1192
1227
  }
1193
1228
  for (const src of model.explores) {
1229
+ const srcComposite = readArtifactTag(
1230
+ { runExpr: "", defaultName: src.name, source: src.name },
1231
+ src
1232
+ );
1233
+ if (srcComposite?.tiles) artifacts.push(srcComposite);
1194
1234
  for (const field of src.allFields) {
1195
1235
  if (!field.isQueryField()) continue;
1196
1236
  const view = field;
@@ -1211,6 +1251,111 @@ async function artifactQueries(runtime, entry) {
1211
1251
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
1212
1252
  }
1213
1253
  }
1254
+ async function collectDrillTargets(runtime, entry) {
1255
+ try {
1256
+ const model = await runtime.loadModel(entry).getModel();
1257
+ const targets = /* @__PURE__ */ new Set();
1258
+ for (const src of model.explores) {
1259
+ for (const field of src.allFields) {
1260
+ const tagged = field;
1261
+ let tag;
1262
+ try {
1263
+ tag = tagged.annotations.parseAsTag().tag;
1264
+ } catch {
1265
+ continue;
1266
+ }
1267
+ const drill = tag.tag("drill");
1268
+ if (!drill) continue;
1269
+ const to = drill.textArray("to") ?? (drill.text("to") ? [drill.text("to")] : []);
1270
+ for (const t of to) if (t && t !== "self") targets.add(t);
1271
+ }
1272
+ }
1273
+ return { ok: true, targets: [...targets] };
1274
+ } catch (e) {
1275
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1276
+ }
1277
+ }
1278
+ async function modelArtifact(runtime, entry, defaultName) {
1279
+ try {
1280
+ const model = await runtime.loadModel(entry).getModel();
1281
+ const composite = readArtifactTag({ runExpr: "", defaultName }, model);
1282
+ if (composite?.tiles) return { ok: true, artifact: composite };
1283
+ for (const queryName of model.queries().named) {
1284
+ const pq = model.getPreparedQueryByName(queryName);
1285
+ const info = readArtifactTag({ runExpr: queryName, defaultName }, pq);
1286
+ if (info && !info.tiles) return { ok: true, artifact: { ...info, tiles: [info.query], query: "" } };
1287
+ }
1288
+ for (const src of model.explores) {
1289
+ for (const field of src.allFields) {
1290
+ if (!field.isQueryField()) continue;
1291
+ const view = field;
1292
+ const info = readArtifactTag(
1293
+ { runExpr: `${src.name} -> ${view.name}`, defaultName, source: src.name, view: view.name },
1294
+ view
1295
+ );
1296
+ if (info && !info.tiles) return { ok: true, artifact: { ...info, tiles: [info.query], query: "" } };
1297
+ }
1298
+ }
1299
+ return { ok: true, artifact: void 0 };
1300
+ } catch (e) {
1301
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1302
+ }
1303
+ }
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
+ }
1214
1359
  var INSTANCE_PLACEHOLDER = "{{INSTANCE_NAME}}";
1215
1360
  function renderInstructions(text, instanceName) {
1216
1361
  return text.replaceAll(INSTANCE_PLACEHOLDER, instanceName);
@@ -1361,6 +1506,17 @@ function refModelProblem(ref, e) {
1361
1506
  const msg = e instanceof Error ? e.message : String(e);
1362
1507
  return codeProblem("model-not-found", `Cannot use model '${ref}': ${msg}`);
1363
1508
  }
1509
+ function refNudge(ref, inspect) {
1510
+ const also = inspect.also ? ` and ${inspect.also}=<the source you queried>` : "";
1511
+ return (p) => {
1512
+ if (p.code !== "field-not-found") return p;
1513
+ return {
1514
+ ...p,
1515
+ message: `${p.message} \u2014 call ${inspect.tool} with ${inspect.param}="${ref}"${also} to see what fields, measures, views, and joins exist.`,
1516
+ help_topic: p.help_topic ?? "language/fields"
1517
+ };
1518
+ };
1519
+ }
1364
1520
  async function executeQuery(m, args, fix, result) {
1365
1521
  const malloy = argString(args, "malloy");
1366
1522
  const execute = argOptBool(args, "execute") ?? true;
@@ -1374,6 +1530,50 @@ async function executeQuery(m, args, fix, result) {
1374
1530
  const budgeted = await applyResultBudget(full, result, { toolName: "query", args });
1375
1531
  return { ...budgeted, problems: budgeted.problems.map(fix) };
1376
1532
  }
1533
+ function queryTool(host, opts = {}) {
1534
+ const inspect = opts.inspect ?? { tool: "describe_source", param: "model_ref", also: "source" };
1535
+ return {
1536
+ name: "query",
1537
+ title: prompts.shared.tools.query.title,
1538
+ description: prompts.shared.tools.query.description,
1539
+ inputSchema: {
1540
+ type: "object",
1541
+ properties: {
1542
+ model_ref: {
1543
+ type: "string",
1544
+ description: "Model ref \u2014 a published model name, or (on a local develop server) the path of the root model file."
1545
+ },
1546
+ malloy: { type: "string", description: "Malloy query text, e.g. `run: orders -> { ... }`." },
1547
+ question: {
1548
+ type: "string",
1549
+ description: "Plain-English description of what this query answers; hosts may record or share it."
1550
+ },
1551
+ givens: {
1552
+ type: "object",
1553
+ description: "Values for $NAME givens, keyed by name (no $). Validate with execute:false to learn which givens the query needs."
1554
+ },
1555
+ execute: { type: "boolean", description: "Default true. false \u2192 compile/validate only (no execution)." },
1556
+ max_rows: { type: "integer", minimum: 1, maximum: 1e4, description: `Row cap (default ${DEFAULT_ROW_LIMIT}).` }
1557
+ },
1558
+ required: ["model_ref", "malloy"],
1559
+ additionalProperties: false
1560
+ },
1561
+ handler: async (args) => {
1562
+ const ref = argString(args, "model_ref");
1563
+ if (!ref.trim()) {
1564
+ return { ok: false, problems: [codeProblem("model-ref-required", prompts.shared.errors["no-model-ref"])] };
1565
+ }
1566
+ try {
1567
+ return await host.withModel(
1568
+ ref,
1569
+ (m) => executeQuery(m, args, refNudge(ref, inspect), opts.result)
1570
+ );
1571
+ } catch (e) {
1572
+ return { ok: false, problems: [refModelProblem(ref, e)] };
1573
+ }
1574
+ }
1575
+ };
1576
+ }
1377
1577
  async function resolveModel(host, source, modelRef) {
1378
1578
  if (modelRef) return { model_ref: modelRef };
1379
1579
  if (!host.list) {
@@ -1654,9 +1854,126 @@ function exploreSurface(host, opts = {}) {
1654
1854
  skills: sharedSkills()
1655
1855
  };
1656
1856
  }
1857
+ var pathSchema = {
1858
+ type: "string",
1859
+ description: "Path to a .malloy file, relative to the server root. (Absolute paths and file:// URIs are also accepted.)"
1860
+ };
1861
+ var sourceSchema = { type: "string", description: "Malloy source code." };
1862
+ var basePathSchema = {
1863
+ type: "string",
1864
+ description: "Optional path for resolving relative imports in the inline source \u2014 typically the file the snippet will live next to. If omitted, imports must be absolute."
1865
+ };
1866
+ var expandSchema = {
1867
+ type: "string",
1868
+ enum: ["ref", "inline"],
1869
+ description: "Join rendering: 'ref' (default) references joined sources by name via source_ref; 'inline' recursively inlines the joined schema."
1870
+ };
1871
+ var emitRunSqlSchema = {
1872
+ type: "boolean",
1873
+ description: "Compile each run: statement to SQL and include it as model.runs[].sql. Default false (large; use query to execute)."
1874
+ };
1875
+ function developSurface(host, opts = {}) {
1876
+ async function lease(input, fn) {
1877
+ try {
1878
+ return await host.withRuntime(input, fn);
1879
+ } catch (e) {
1880
+ return { ok: false, problems: [errorProblem(e)] };
1881
+ }
1882
+ }
1883
+ const modelHost = {
1884
+ withModel: (ref, fn) => host.withRuntime({ url: ref }, fn)
1885
+ };
1886
+ const tools = [
1887
+ {
1888
+ name: "compile_file",
1889
+ title: prompts.develop.tools.compile_file.title,
1890
+ description: prompts.develop.tools.compile_file.description,
1891
+ inputSchema: {
1892
+ type: "object",
1893
+ properties: { path: pathSchema, expand: expandSchema, emit_run_sql: emitRunSqlSchema },
1894
+ required: ["path"],
1895
+ additionalProperties: false
1896
+ },
1897
+ handler: async (args) => lease(
1898
+ { url: argString(args, "path") },
1899
+ (m) => compile(m.runtime, m.entry, {
1900
+ readSource: m.readSource,
1901
+ expand: argOptString(args, "expand"),
1902
+ emitRunSql: argOptBool(args, "emit_run_sql")
1903
+ })
1904
+ )
1905
+ },
1906
+ {
1907
+ name: "compile",
1908
+ title: prompts.develop.tools.compile.title,
1909
+ description: prompts.develop.tools.compile.description,
1910
+ inputSchema: {
1911
+ type: "object",
1912
+ properties: {
1913
+ source: sourceSchema,
1914
+ base_path: basePathSchema,
1915
+ expand: expandSchema,
1916
+ emit_run_sql: emitRunSqlSchema
1917
+ },
1918
+ required: ["source"],
1919
+ additionalProperties: false
1920
+ },
1921
+ handler: async (args) => lease(
1922
+ { source: argString(args, "source"), baseUrl: argOptString(args, "base_path") },
1923
+ (m) => compile(m.runtime, m.entry, {
1924
+ readSource: m.readSource,
1925
+ expand: argOptString(args, "expand"),
1926
+ emitRunSql: argOptBool(args, "emit_run_sql")
1927
+ })
1928
+ )
1929
+ },
1930
+ queryTool(modelHost, {
1931
+ result: opts.result,
1932
+ inspect: { tool: "compile_file", param: "path" }
1933
+ }),
1934
+ {
1935
+ name: "prettify",
1936
+ title: prompts.develop.tools.prettify.title,
1937
+ description: prompts.develop.tools.prettify.description,
1938
+ inputSchema: {
1939
+ type: "object",
1940
+ properties: { source: sourceSchema },
1941
+ required: ["source"],
1942
+ additionalProperties: false
1943
+ },
1944
+ handler: async (args) => prettify(argString(args, "source"))
1945
+ },
1946
+ yoHelpTool()
1947
+ ];
1948
+ return {
1949
+ tools: tools.map(withHelp),
1950
+ instructions: assembleInstructions("develop"),
1951
+ skills: sharedSkills()
1952
+ };
1953
+ }
1657
1954
 
1658
1955
  // src/host.ts
1659
1956
  var ENTRY = "index.malloy";
1957
+ function tileName(runExpr) {
1958
+ const arrow = runExpr.lastIndexOf("->");
1959
+ return (arrow >= 0 ? runExpr.slice(arrow + 2) : runExpr).trim();
1960
+ }
1961
+ async function validateQuery(runtime, entry, runExpr, givens) {
1962
+ try {
1963
+ const q = runtime.loadModel(entry).loadQuery(`run: ${runExpr}`);
1964
+ const has = givens && Object.keys(givens).length > 0;
1965
+ await q.getSQL(has ? { givens } : void 0);
1966
+ return { ok: true };
1967
+ } catch (e) {
1968
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1969
+ }
1970
+ }
1971
+ async function validateRestrictedText(runtime, entry, malloy) {
1972
+ const v = await validateRestricted(runtime, entry, malloy);
1973
+ if (v.ok) return { ok: true };
1974
+ const msg = v.problems.filter((p) => p.severity === "error").map((p) => p.message).join("; ");
1975
+ return { ok: false, error: msg || "restricted query failed to compile" };
1976
+ }
1660
1977
  function fsReader() {
1661
1978
  return {
1662
1979
  readURL: async (u) => {
@@ -1677,56 +1994,123 @@ async function makeRunner(root) {
1677
1994
  await import("@malloydata/malloy-connections");
1678
1995
  const abs = path2.resolve(root);
1679
1996
  const rootUrl = url2.pathToFileURL(abs + path2.sep);
1680
- async function lease(fn) {
1681
- const reader = fsReader();
1682
- const config = await loadConfig(rootUrl, reader);
1683
- const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, ENTRY) });
1997
+ const reader = fsReader();
1998
+ let configPromise = null;
1999
+ const getConfig = () => configPromise ??= loadConfig(rootUrl, reader);
2000
+ let inFlight = 0;
2001
+ async function leaseIn(entryFile, fn) {
2002
+ const config = await getConfig();
2003
+ const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, entryFile) });
1684
2004
  const runtime = new Runtime({ config, urlReader: prepared });
2005
+ inFlight++;
1685
2006
  try {
1686
2007
  return await fn(runtime, entry);
1687
2008
  } finally {
1688
- await config.shutdown("idle").catch(() => {
2009
+ inFlight--;
2010
+ if (inFlight === 0) await config.shutdown("idle").catch(() => {
1689
2011
  });
1690
2012
  }
1691
2013
  }
2014
+ const lease = (fn) => leaseIn(ENTRY, fn);
1692
2015
  return {
1693
2016
  root: abs,
1694
2017
  entryExists: () => fs.existsSync(path2.join(abs, ENTRY)),
2018
+ async dispose() {
2019
+ if (!configPromise) return;
2020
+ const config = await configPromise.catch(() => null);
2021
+ configPromise = null;
2022
+ if (config) await config.shutdown("close").catch(() => {
2023
+ });
2024
+ },
1695
2025
  run(runExpr, givens) {
1696
2026
  return lease(
1697
2027
  (runtime, entry) => run(runtime, entry, { runExpr, givens, stableResult: true, rowLimit: 5e3 })
1698
2028
  );
1699
2029
  },
2030
+ runIn(entryFile, runExpr, givens) {
2031
+ return leaseIn(
2032
+ entryFile,
2033
+ (runtime, entry) => run(runtime, entry, { runExpr, givens, stableResult: true, rowLimit: 5e3 })
2034
+ );
2035
+ },
1700
2036
  runText(malloy, givens) {
1701
2037
  return lease(
1702
2038
  (runtime, entry) => runRestricted(runtime, entry, malloy, { givens, stableResult: true, rowLimit: 5e3 })
1703
2039
  );
1704
2040
  },
2041
+ runTextIn(entryFile, malloy, givens) {
2042
+ return leaseIn(
2043
+ entryFile,
2044
+ (runtime, entry) => runRestricted(runtime, entry, malloy, { givens, stableResult: true, rowLimit: 5e3 })
2045
+ );
2046
+ },
1705
2047
  validateText(malloy) {
1706
- return lease(async (runtime, entry) => {
1707
- const v = await validateRestricted(runtime, entry, malloy);
1708
- if (v.ok) return { ok: true };
1709
- const msg = v.problems.filter((p) => p.severity === "error").map((p) => p.message).join("; ");
1710
- return { ok: false, error: msg || "restricted query failed to compile" };
1711
- });
2048
+ return lease((runtime, entry) => validateRestrictedText(runtime, entry, malloy));
2049
+ },
2050
+ validateTextIn(entryFile, malloy) {
2051
+ return leaseIn(entryFile, (runtime, entry) => validateRestrictedText(runtime, entry, malloy));
1712
2052
  },
1713
2053
  givensForQuery(runExpr) {
1714
2054
  return lease((runtime, entry) => dashboardGivenSpecs(runtime, entry, runExpr));
1715
2055
  },
2056
+ givensForQueryIn(entryFile, runExpr) {
2057
+ return leaseIn(entryFile, (runtime, entry) => dashboardGivenSpecs(runtime, entry, runExpr));
2058
+ },
1716
2059
  artifacts() {
1717
2060
  return lease((runtime, entry) => artifactQueries(runtime, entry));
1718
2061
  },
1719
- validate(runExpr, givens) {
1720
- return lease(async (runtime, entry) => {
1721
- try {
1722
- const q = runtime.loadModel(entry).loadQuery(`run: ${runExpr}`);
1723
- const has = givens && Object.keys(givens).length > 0;
1724
- await q.getSQL(has ? { givens } : void 0);
1725
- return { ok: true };
1726
- } catch (e) {
1727
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
2062
+ drillTargets() {
2063
+ return lease((runtime, entry) => collectDrillTargets(runtime, entry));
2064
+ },
2065
+ artifactForFile(entryFile, defaultName) {
2066
+ return leaseIn(entryFile, (runtime, entry) => modelArtifact(runtime, entry, defaultName));
2067
+ },
2068
+ runDashboard(entryFile, tiles, opts) {
2069
+ return leaseIn(entryFile, async (runtime, entry) => {
2070
+ const givens = opts.givens ?? {};
2071
+ const ran = [];
2072
+ for (const tile of tiles) {
2073
+ const specs = await dashboardGivenSpecs(runtime, entry, tile);
2074
+ const names = specs.ok ? new Set(specs.givens.map((s) => s.name)) : null;
2075
+ const tileGivens = names ? Object.fromEntries(Object.entries(givens).filter(([k]) => names.has(k))) : givens;
2076
+ const result = await run(runtime, entry, {
2077
+ runExpr: tile,
2078
+ givens: tileGivens,
2079
+ stableResult: true,
2080
+ rowLimit: 5e3
2081
+ });
2082
+ ran.push({ name: tileName(tile), result });
1728
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 };
2089
+ }
2090
+ const combined = combineTiles(
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 };
1729
2095
  });
2096
+ },
2097
+ dashboardGivens(entryFile, tiles) {
2098
+ return leaseIn(entryFile, async (runtime, entry) => {
2099
+ const byName = /* @__PURE__ */ new Map();
2100
+ for (const tile of tiles) {
2101
+ const specs = await dashboardGivenSpecs(runtime, entry, tile);
2102
+ if (specs.ok) {
2103
+ for (const s of specs.givens) if (!byName.has(s.name)) byName.set(s.name, s);
2104
+ }
2105
+ }
2106
+ return { ok: true, givens: [...byName.values()] };
2107
+ });
2108
+ },
2109
+ validate(runExpr, givens) {
2110
+ return lease((runtime, entry) => validateQuery(runtime, entry, runExpr, givens));
2111
+ },
2112
+ validateIn(entryFile, runExpr, givens) {
2113
+ return leaseIn(entryFile, (runtime, entry) => validateQuery(runtime, entry, runExpr, givens));
1730
2114
  }
1731
2115
  };
1732
2116
  }
@@ -1754,28 +2138,37 @@ function gatherDirectory(dir) {
1754
2138
  const config = existsSync2(configPath) ? readFileSync2(configPath, "utf8") : void 0;
1755
2139
  return { files, config };
1756
2140
  }
1757
- function listDashboardDirs(dir) {
1758
- const base = join2(dir, "dashboards");
1759
- if (!existsSync2(base)) return [];
1760
- return readdirSync(base).filter((name) => statSync(join2(base, name)).isDirectory()).sort();
1761
- }
1762
2141
  async function gatherDashboards(dir) {
2142
+ const dashDir = join2(dir, "dashboards");
2143
+ if (!existsSync2(dashDir)) return [];
1763
2144
  const runner = await makeRunner(dir);
1764
- const res = await runner.artifacts();
1765
- if (!res.ok) throw new Error(`model error: ${res.error}`);
1766
- return res.artifacts.map((a) => {
1767
- const tsxPath = join2(dir, "dashboards", a.name, "Dashboard.tsx");
1768
- const manifest = { title: a.title, query: a.query };
1769
- if (a.source) manifest.source = a.source;
1770
- if (a.view) manifest.view = a.view;
1771
- if (a.description) manifest.description = a.description;
1772
- if (a.givens) manifest.givens = a.givens;
1773
- return {
1774
- name: a.name,
1775
- manifest,
1776
- source: existsSync2(tsxPath) ? readFileSync2(tsxPath, "utf8") : ""
1777
- };
1778
- });
2145
+ try {
2146
+ const files = readdirSync(dashDir).filter((f) => f.endsWith(".malloy")).sort();
2147
+ const payloads = [];
2148
+ for (const file of files) {
2149
+ const base = file.slice(0, -".malloy".length);
2150
+ const entryFile = `dashboards/${file}`;
2151
+ const res = await runner.artifactForFile(entryFile, base);
2152
+ if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
2153
+ if (!res.artifact) continue;
2154
+ const a = res.artifact;
2155
+ const manifest = { title: a.title, entryFile };
2156
+ if (a.tiles) manifest.tiles = a.tiles;
2157
+ if (a.dashboard_columns !== void 0) manifest.dashboard_columns = a.dashboard_columns;
2158
+ if (a.description) manifest.description = a.description;
2159
+ if (a.givens) manifest.givens = a.givens;
2160
+ if (a.autorun === false) manifest.autorun = false;
2161
+ const component = ["jsx", "tsx"].map((ext) => join2(dashDir, `${base}.${ext}`)).find((p) => existsSync2(p));
2162
+ payloads.push({
2163
+ name: a.name || base,
2164
+ manifest,
2165
+ source: component ? readFileSync2(component, "utf8") : ""
2166
+ });
2167
+ }
2168
+ return payloads;
2169
+ } finally {
2170
+ await runner.dispose();
2171
+ }
1779
2172
  }
1780
2173
  function gitInfo(dir) {
1781
2174
  const git = (args) => execFileSync("git", args, {
@@ -1805,85 +2198,135 @@ function gitInfo(dir) {
1805
2198
  }
1806
2199
 
1807
2200
  // src/lint.ts
1808
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
2201
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
1809
2202
  import { join as join3, resolve } from "node:path";
1810
2203
  import * as esbuild from "esbuild";
1811
2204
  var quoteField = (f) => /^[A-Za-z_]\w*$/.test(f) ? f : `\`${f}\``;
2205
+ function componentQueryLiterals(source) {
2206
+ const out = /* @__PURE__ */ new Set();
2207
+ const re = /\bquery\s*=\s*(["'])([^"'\n]+)\1/g;
2208
+ let m;
2209
+ while ((m = re.exec(source)) !== null) out.add(m[2].trim());
2210
+ return [...out];
2211
+ }
1812
2212
  async function lintDashboards(root) {
1813
2213
  const abs = resolve(root);
1814
- const dashboards = [];
1815
2214
  const runner = await makeRunner(abs);
1816
- if (!runner.entryExists()) {
1817
- return { ok: false, dashboards: [{ name: "(model)", errors: [`no index.malloy at ${abs}`] }] };
2215
+ try {
2216
+ return await runLint(abs, runner);
2217
+ } finally {
2218
+ await runner.dispose();
1818
2219
  }
1819
- const arts = await runner.artifacts();
1820
- if (!arts.ok) {
1821
- return { ok: false, dashboards: [{ name: "(model)", errors: [arts.error] }] };
2220
+ }
2221
+ async function runLint(abs, runner) {
2222
+ const dashboards = [];
2223
+ if (runner.entryExists()) {
2224
+ const arts = await runner.artifacts();
2225
+ if (!arts.ok) dashboards.push({ name: "index.malloy", errors: [arts.error], warnings: [] });
2226
+ }
2227
+ const dir = join3(abs, "dashboards");
2228
+ if (!existsSync3(dir)) return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
2229
+ const entries = readdirSync2(dir);
2230
+ const malloyFiles = entries.filter((f) => f.endsWith(".malloy")).sort();
2231
+ const malloyBases = new Set(malloyFiles.map((f) => f.slice(0, -".malloy".length)));
2232
+ for (const c of entries.filter((f) => /\.(jsx|tsx)$/.test(f)).sort()) {
2233
+ const cbase = c.replace(/\.(jsx|tsx)$/, "");
2234
+ if (!malloyBases.has(cbase)) {
2235
+ dashboards.push({
2236
+ name: c,
2237
+ errors: [`component "${c}" has no matching "${cbase}.malloy" dashboard`],
2238
+ warnings: []
2239
+ });
2240
+ }
1822
2241
  }
1823
- const artifacts = arts.artifacts;
1824
- const dirs = listDashboardDirs(abs);
1825
- if (artifacts.length === 0 && dirs.length === 0) return { ok: true, dashboards };
1826
- const declared = new Set(artifacts.map((a) => a.name));
1827
- for (const dir of dirs) {
2242
+ const seenNames = /* @__PURE__ */ new Map();
2243
+ for (const file of malloyFiles) {
2244
+ const base = file.slice(0, -".malloy".length);
2245
+ const entryFile = join3("dashboards", file);
1828
2246
  const errors = [];
1829
- if (existsSync3(join3(abs, "dashboards", dir, "manifest.json"))) {
1830
- errors.push(
1831
- `manifest.json is obsolete \u2014 delete it; the model's \`# artifact title="\u2026"\` tag on the query is the manifest now`
1832
- );
2247
+ const warnings = [];
2248
+ const res = await runner.artifactForFile(entryFile, base);
2249
+ if (!res.ok) {
2250
+ dashboards.push({ name: base, errors: [res.error], warnings: [] });
2251
+ continue;
1833
2252
  }
1834
- if (!declared.has(dir)) {
1835
- errors.push(
1836
- `no query is tagged for this dashboard \u2014 tag one with \`# artifact\` (or \`# artifact name="${dir}"\`) in the model`
1837
- );
2253
+ if (!res.artifact) continue;
2254
+ const art = res.artifact;
2255
+ if (seenNames.has(art.name)) {
2256
+ errors.push(`duplicate dashboard name "${art.name}" (also declared by ${seenNames.get(art.name)})`);
2257
+ } else {
2258
+ seenNames.set(art.name, file);
1838
2259
  }
1839
- if (errors.length) dashboards.push({ name: dir, errors });
1840
- }
1841
- for (const artifact of artifacts) {
1842
- const errors = [];
1843
- const v = await runner.validate(artifact.query, {});
1844
- if (!v.ok) errors.push(v.error);
1845
- const specs = await runner.givensForQuery(artifact.query);
2260
+ if (art.dashboard_columns !== void 0 && (!Number.isInteger(art.dashboard_columns) || art.dashboard_columns < 1)) {
2261
+ errors.push(`dashboard_columns must be a positive integer (got ${JSON.stringify(art.dashboard_columns)})`);
2262
+ }
2263
+ const tiles = art.tiles ?? [];
2264
+ if (tiles.length === 0) errors.push(`\`## artifact\` declares no tiles`);
2265
+ for (const tile of tiles) {
2266
+ const v = await runner.validateIn(entryFile, tile, {});
2267
+ if (!v.ok) errors.push(`tile "${tile}": ${v.error}`);
2268
+ }
2269
+ const specs = await runner.dashboardGivens(entryFile, tiles);
1846
2270
  if (specs.ok) {
1847
2271
  for (const spec of specs.givens) {
1848
- if (spec.tags?.suggest_query !== void 0) {
1849
- errors.push(
1850
- `given "${spec.name}": suggest_query is obsolete \u2014 declare # suggest { source=\u2026 dimension=\u2026 } or # suggest { query=\u2026 }`
1851
- );
1852
- }
1853
2272
  const suggest = spec.suggest;
1854
2273
  if (!suggest) continue;
1855
- const base = suggest.query ? `run: ${suggest.query}` : suggest.source && suggest.dimension ? `run: ${suggest.source} -> ${quoteField(suggest.dimension)}` : null;
1856
- if (base === null) {
2274
+ const suggestBase = suggest.query ? `run: ${suggest.query}` : suggest.source && suggest.dimension ? `run: ${suggest.source} -> ${quoteField(suggest.dimension)}` : null;
2275
+ if (suggestBase === null) {
1857
2276
  errors.push(
1858
- `given "${spec.name}": suggest must be \`suggest { source=<source> dimension=<field> }\` or \`suggest { query=<named_query> [dimension=<field>] }\``
2277
+ `given "${spec.name}": suggest must be \`suggest { source=<source> dimension=<field> }\` or \`suggest { query=<query> [dimension=<field>] }\``
1859
2278
  );
1860
2279
  continue;
1861
2280
  }
1862
- const sv = await runner.validateText(base);
2281
+ const sv = await runner.validateTextIn(entryFile, suggestBase);
1863
2282
  if (!sv.ok) errors.push(`given "${spec.name}": suggest does not compile \u2014 ${sv.error}`);
1864
2283
  }
1865
2284
  }
1866
- const tsxPath = join3(abs, "dashboards", artifact.name, "Dashboard.tsx");
1867
- if (existsSync3(tsxPath)) {
2285
+ for (const ext of ["jsx", "tsx"]) {
2286
+ const cp = join3(dir, `${base}.${ext}`);
2287
+ if (!existsSync3(cp)) continue;
2288
+ const source = readFileSync3(cp, "utf8");
1868
2289
  try {
1869
- await esbuild.transform(readFileSync3(tsxPath, "utf8"), { loader: "tsx", jsx: "automatic" });
2290
+ await esbuild.transform(source, { loader: ext, jsx: "automatic" });
1870
2291
  } catch (e) {
1871
2292
  const msg = e.errors?.map((x) => x.text).join("; ") ?? String(e);
1872
- errors.push(`Dashboard.tsx: ${msg}`);
2293
+ errors.push(`${base}.${ext}: ${msg}`);
2294
+ continue;
2295
+ }
2296
+ for (const q of componentQueryLiterals(source)) {
2297
+ const v = await runner.validateIn(entryFile, q, {});
2298
+ if (!v.ok) errors.push(`${base}.${ext}: query "${q}" doesn't resolve \u2014 ${v.error}`);
2299
+ }
2300
+ }
2301
+ dashboards.push({ name: art.name, errors, warnings });
2302
+ }
2303
+ const drills = await runner.drillTargets();
2304
+ if (drills.ok) {
2305
+ for (const target of drills.targets) {
2306
+ if (!seenNames.has(target)) {
2307
+ dashboards.push({
2308
+ name: `drill \u2192 ${target}`,
2309
+ errors: [
2310
+ `# drill { to=[${target}] } targets no dashboard \u2014 add dashboards/${target}.malloy, or fix the slug to a real dashboard`
2311
+ ],
2312
+ warnings: []
2313
+ });
1873
2314
  }
1874
2315
  }
1875
- dashboards.push({ name: artifact.name, errors });
1876
2316
  }
1877
2317
  return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
1878
2318
  }
1879
2319
  function printLintReport(report) {
1880
2320
  for (const d of report.dashboards) {
1881
- if (d.errors.length === 0) {
2321
+ const hasErr = d.errors.length > 0;
2322
+ const hasWarn = d.warnings.length > 0;
2323
+ if (!hasErr && !hasWarn) {
1882
2324
  console.log(` \u2713 ${d.name}`);
1883
- } else {
1884
- console.log(` \u2717 ${d.name}`);
1885
- for (const e of d.errors) console.log(` ${e}`);
2325
+ continue;
1886
2326
  }
2327
+ console.log(` ${hasErr ? "\u2717" : "\u26A0"} ${d.name}`);
2328
+ for (const e of d.errors) console.log(` ${e}`);
2329
+ for (const w of d.warnings) console.log(` warning: ${w}`);
1887
2330
  }
1888
2331
  }
1889
2332
 
@@ -2167,278 +2610,12 @@ function attachSurface(server, surface, opts = {}) {
2167
2610
  }
2168
2611
  }
2169
2612
 
2170
- // src/dashboard-guidance.ts
2171
- var DASHBOARD_GUIDANCE = `
2172
-
2173
- # Authoring dashboards
2174
-
2175
- A dashboard is DECLARED IN THE MODEL \u2014 there is no manifest file and, for the
2176
- basic case, no JavaScript at all. Preview with \`malloyyo dashboard dev\`; check
2177
- with \`malloyyo lint\`.
2178
-
2179
- ## Make dashboards discoverable: the entry model
2180
-
2181
- **The entry is \`index.malloy\`.** \`dashboard dev\`, \`lint\`, and the hosted
2182
- server only see what that file EXPORTS. Three things must all be surfaced
2183
- (imported AND exported) through it, or the feature looks broken:
2184
-
2185
- 1. **Whatever holds each \`# artifact\` tag.** A tag on a \`view:\` rides along
2186
- with its SOURCE (export the source \u2014 you can't export a view on its own); a
2187
- tag on a top-level \`query:\` needs that query exported. Not surfaced \u2192
2188
- \`dashboard dev\` says "No dashboards declared" and \`lint\` says "no dashboards
2189
- to lint", even though the model compiles clean.
2190
- 2. **Every filter given the dashboards reference.** An unexported given
2191
- silently resolves to its declaration default \u2014 the control still renders
2192
- but CAN'T CHANGE THE QUERY (the filter looks inert).
2193
- 3. **Whatever backs each \`suggest\`** \u2014 the named query (\`suggest {query=\u2026}\`)
2194
- or source (\`suggest {source=\u2026}\`). Suggestions run against the entry model;
2195
- an unexported one fails lint with "Reference to undefined object".
2196
-
2197
- \`\`\`malloy
2198
- ##! experimental.givens
2199
- import {
2200
- order_items, // the source \u2014 carries its # artifact views
2201
- BRAND, CATEGORY, PERIOD, // the filter givens
2202
- brand_suggest // backs a suggest {query=\u2026}
2203
- } from 'ecommerce.malloy'
2204
- export { order_items, BRAND, CATEGORY, PERIOD, brand_suggest }
2205
- \`\`\`
2206
-
2207
- Exporting the source is often the whole job: its \`# artifact\` views, its
2208
- dimensions (for \`suggest {source=\u2026}\`), and its measures all travel with it.
2209
-
2210
- Prefer \`suggest { query=<named-query> \u2026 }\` over \`suggest { source=\u2026 }\` for
2211
- anything beyond a throwaway: you export one small governed query instead of a
2212
- whole base source.
2213
-
2214
- ## The model is the whole contract
2215
-
2216
- **1. Tag a \`view:\` inside a source** with \`# artifact\` to declare a dashboard
2217
- (the idiomatic form \u2014 a view is reusable, nestable, and explorable through the
2218
- normal \`query\`/\`describe_source\` surface). For the common overview shape
2219
- (top-level aggregates + nests), ALSO tag it \`# dashboard\` so the result
2220
- renders as KPI tiles + a card grid instead of one flat table \u2014 they're
2221
- partners: \`# artifact\` declares the dashboard, \`# dashboard\` is the renderer
2222
- tag that draws it like one:
2223
-
2224
- \`\`\`malloy
2225
- source: order_items is \u2026 extend {
2226
- #" Business health at a glance \u2014 sales, margin, orders.
2227
- # artifact { title="Business Overview" } dashboard
2228
- view: overview_dashboard is {
2229
- where:
2230
- inventory_items.product_brand ~ $BRAND, // multi-filter where: is
2231
- inventory_items.product_category ~ $CATEGORY, // COMMA separated
2232
- created_at ~ $PERIOD
2233
- aggregate: total_sales, total_gross_margin, order_count
2234
- nest:
2235
- # line_chart
2236
- sales_trend is by_month
2237
- top_brands
2238
- # shape_map
2239
- sales_by_state
2240
- }
2241
- }
2242
- \`\`\`
2243
-
2244
- That's a complete dashboard: the runtime auto-renders a title (the tag's
2245
- \`title\`, else the \`#"\` doc comment), a control for every given the view
2246
- references, and the result panel. It runs as \`run: <source> -> <view>\` (here
2247
- \`order_items -> overview_dashboard\`). \`name="slug"\` overrides the
2248
- URL/directory slug (default: the view name). Note the \`where:\` clauses
2249
- applying givens are COMMA-separated \u2014 newline-separated conditions do not
2250
- parse.
2251
-
2252
- Tagging a **top-level \`query:\`** still works and behaves identically (it runs
2253
- as \`run: <name>\`) \u2014 reach for it only when the dashboard query doesn't belong
2254
- to any one source.
2255
-
2256
- **Deep-link a cell** to an external system \u2014 tag any \`group_by:\`/\`select:\`
2257
- field \`# link\` (the value is a full URL) or
2258
- \`# link { url_template="https://\u2026/$$" }\` (\`$$\` = the cell value; add
2259
- \`field=id\` to link on a separate, usually \`# hidden\`, id column). Common in a
2260
- nested detail table so each row jumps to its record. \`# image { url_template=\u2026 }\`
2261
- renders a cell as an inline image. Links open in a new browser tab.
2262
-
2263
- Two dashboards can share a given but start on different values \u2014 a \`givens\`
2264
- block in the tag sets PER-DASHBOARD defaults (given values, i.e. filter
2265
- expressions; URL params still win):
2266
-
2267
- \`\`\`malloy
2268
- # artifact { name="manufacturer" title="Manufacturer Recall Profile" givens { MANUFACTURER="Ford Motor Company" } }
2269
- \`\`\`
2270
-
2271
- This replaces the "declare the given's default per dashboard" role the old
2272
- manifests had: declare the given once with a neutral default (often \`f''\` =
2273
- no filter), and let each tag pick its landing state.
2274
-
2275
- **2. Declare the filters as \`filter<T>\` givens** \u2014 never raw strings/numbers.
2276
- A \`filter<string>\` value accepts one value ('NY'), alternatives ('NY, CA'),
2277
- wildcards ('Ann%'), negation ('-NY'); a \`filter<number>\` accepts ranges
2278
- ('[1910 to 1930]') and comparisons ('> 200'); a \`filter<timestamp>\` /
2279
- \`filter<date>\` accepts relative windows ('7 days' = the last 7 days, 'today',
2280
- 'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO \`@\` in
2281
- filter literals). Apply with \`~\`; \`f''\` = empty = no filter (the natural
2282
- "All"/"all time" \u2014 just \`col ~ $X\`, no \`$X = '' or \u2026\` dance):
2283
-
2284
- \`\`\`malloy
2285
- ##! experimental { givens }
2286
- given:
2287
- # label="State" control=select suggest { source=baby_names dimension=state }
2288
- STATE :: filter<string> is f'NY'
2289
- # label="Brand" suggest { query=brand_suggest dimension=product_brand }
2290
- BRAND :: filter<string> is f''
2291
- # label="Years" range_min=1910 range_max=2025
2292
- YEAR_RANGE :: filter<number> is f'[1910 to 1930]'
2293
- # label="Time period"
2294
- PERIOD :: filter<timestamp> is f''
2295
- # label="Include rare names"
2296
- INCLUDE_RARE :: boolean is false
2297
- \`\`\`
2298
-
2299
- Tags on the declaration drive the control (tag syntax is \`key="value"\` \u2014
2300
- equals, not colon):
2301
- - \`label\` \u2014 control caption (defaults to the given's name)
2302
- - \`suggest { \u2026 }\` \u2014 where the control's options come from. NO Malloy code in
2303
- strings \u2014 just names:
2304
- - \`suggest { query=brand_suggest dimension=product_brand }\` \u2014 the FIRST
2305
- COLUMN of a named query (declare the query in the model \u2014 governed,
2306
- reviewable, and only that query needs exporting). PREFER THIS FORM.
2307
- - \`suggest { source=baby_names dimension=state }\` \u2014 the DISTINCT VALUES of
2308
- a dimension on a source (the whole source must be exported)
2309
- A \`dimension\` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the
2310
- runtime refines the base query with what the user has typed
2311
- (\`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }\`, case-insensitive,
2312
- escaped). Without a dimension the fetched list is filtered client-side.
2313
- Runs as a restricted query; lint checks the declaration compiles.
2314
-
2315
- **RELATED (faceted) filters** \u2014 query-form only: a suggest query may
2316
- reference the OTHER givens, and the runtime runs it with the dashboard's
2317
- current values (the suggested given itself is excluded, so the list never
2318
- collapses to the current pick). Brand suggestions narrow when Category is
2319
- set:
2320
-
2321
- \`\`\`malloy
2322
- query: brand_suggest is inventory_items -> product_brand + {
2323
- where:
2324
- product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND
2325
- product_department ~ $DEPARTMENT
2326
- limit: 500
2327
- }
2328
- \`\`\`
2329
-
2330
- Declare one \`*_suggest\` per filter, each referencing the others; \`f''\`
2331
- defaults mean unset filters don't constrain. \`source=\` suggests can't do
2332
- this (no place for a \`where:\`) \u2014 another reason to prefer \`query=\`.
2333
- - \`control=select\` \u2014 a fixed dropdown instead of a typeahead search box
2334
- - \`range_min\` / \`range_max\` \u2014 bounds; makes a filter<number> given a
2335
- dual-thumb range slider
2336
- - anything else passes through in \`spec.tags\` for custom components
2337
-
2338
- Control picked from the declaration automatically: numeric range tags \u2192
2339
- dual-thumb slider; \`filter<timestamp|timestamptz|date>\` \u2192 the TimeRange
2340
- widget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a
2341
- "Custom range\u2026" from/to date picker); suggest + control=select \u2192 dropdown;
2342
- boolean \u2192 checkbox; anything else \u2192 committing search box with typeahead.
2343
- The suggest-driven options are DATA VALUES only \u2014 options that aren't column
2344
- values (custom time presets, threshold buckets) need a custom component
2345
- (below) with explicit \`{value, text}\` options where value is a filter
2346
- expression built with \`filters.*\`.
2347
-
2348
- ## Custom components (optional): ./dashboards/<slug>/Dashboard.tsx
2349
-
2350
- When the default UI isn't enough, add ONE file. It composes the runtime's
2351
- widgets/hooks with your own React \u2014 you own layout, copy, and theming; the
2352
- model still owns every query and filter:
2353
-
2354
- \`\`\`tsx
2355
- import React from "react";
2356
- import { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";
2357
-
2358
- export default function Dashboard({ dashboard, givens }) {
2359
- return (
2360
- <div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>
2361
- <h1>{dashboard.title}</h1>
2362
- <Controls>
2363
- <Given name="STATE" /> {/* picks the control from the declaration */}
2364
- <Search given="NAME" /> {/* committing input + typeahead + validation */}
2365
- <TimeRange given="PERIOD" presets={[
2366
- { value: "", text: "All time" },
2367
- { value: filters.lastN(1, "day"), text: "Last day" },
2368
- { value: filters.lastN(1, "week"), text: "Last week" },
2369
- { value: filters.lastN(1, "month"), text: "Last month" },
2370
- ]} /> {/* "Custom range\u2026" is always appended */}
2371
- <Select given="MIN_SAMPLE"
2372
- options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: \`> \${n}\` }))} />
2373
- </Controls>
2374
- <Panel givens={givens} /> {/* the tagged query, Malloy renderer */}
2375
- <Panel malloy="baby_names -> births_by_decade" givens={givens} /> {/* restricted text */}
2376
- </div>
2377
- );
2378
- }
2379
- \`\`\`
2380
-
2381
- From \`@malloyyo/dashboard\` (also handed to the component as props):
2382
- - **Widgets** (headless-ish; restyle via className/style or CSS vars
2383
- \`--dash-fg/-muted/-border/-accent/-control-bg/-controls-bg\`):
2384
- \`<Controls/>\` (all givens, or compose children), \`<Given name/>\`,
2385
- \`<Select given [options]/>\`, \`<Search given/>\`, \`<Range given [min max]/>\`,
2386
- \`<TimeRange given [presets]/>\` (temporal presets + custom range),
2387
- \`<Checkbox given/>\` (bound to a boolean given)
2388
- - **Hooks**: \`useGiven(name)\` \u2192 {value, set, spec};
2389
- \`useOptions(name, typed?)\` \u2192 {options, loading} (typeahead);
2390
- \`useQuery({query|malloy, givens})\` \u2192 {rows, loading, error} \u2014 plain rows
2391
- for your own visuals
2392
- - **Helpers**: \`filters.oneOf/contains/between/atLeast/\u2026\` build
2393
- filter-expression strings with correct escaping; temporal:
2394
- \`filters.lastN(7, "day")\` \u2192 \`'7 days'\`, \`filters.dateRange("2026-01-01",
2395
- "2026-07-01")\`, \`filters.afterDate/beforeDate\`; read back with
2396
- \`filters.values/numberRange/threshold/inLast/temporalRange\`;
2397
- \`filters.isValid(type, src)\` checks typed input.
2398
- Never hand-concatenate a filter string.
2399
- **Escaping rule for custom controls:** a filter given's value is an
2400
- EXPRESSION, so committing a raw column value is wrong the moment it contains
2401
- a comma/percent/dash ('Tesla, Inc.' parses as two alternatives and matches
2402
- nothing). Commit \`filters.oneOf(value)\` (exact) or
2403
- \`filters.contains(term)\` (substring), and unwrap for display with
2404
- \`filters.values(src)\`. The stock \`<Select/>\` does this automatically;
2405
- \`<Search/>\` deliberately commits raw text (its input IS a filter
2406
- expression).
2407
- - \`<Panel/>\` and \`runData(text, givens)\` \u2014 named queries are the primary
2408
- form; arbitrary Malloy runs as a RESTRICTED query (no import / given: /
2409
- connection.* / raw SQL / ##! flags \u2014 the model's published surface only).
2410
-
2411
- ## Rules
2412
- - Declare data in the model: givens are \`filter<T>\`, options come from
2413
- \`# suggest {\u2026}\` declarations, dashboards are \`# artifact\` tags. If a query or given you
2414
- need is missing, add it to the \`.malloy\` file first (check with
2415
- \`describe_source\`).
2416
- - Surface everything through the entry model (see the top section).
2417
- - Only React + \`@malloyyo/dashboard\` are importable. No other imports, no
2418
- network \u2014 the runtime sandboxes the component.
2419
- - Interactivity = setting given values (filter-expression strings), not
2420
- rewriting query text per interaction.
2421
-
2422
- ## Preview & validate
2423
- \`malloyyo dashboard dev\` \u2192 open the printed URL. Edits to \`.malloy\` (tags,
2424
- givens, queries) and \`Dashboard.tsx\` hot-reload. \`malloyyo lint\` validates
2425
- the tagged queries, given \`suggest\` declarations, and any Dashboard.tsx \u2014
2426
- but only for dashboards REACHABLE FROM THE ENTRY: "no dashboards to lint"
2427
- usually means the \`# artifact\` queries aren't exported through
2428
- \`index.malloy\`, not that they don't exist.
2429
-
2430
- Validation loop that works well: the local \`malloyyo mcp\` server hot-reloads
2431
- working-directory edits \u2014 \`query(execute:false)\` to compile-check,
2432
- \`execute:true\` to run. A \`# artifact\` view runs as
2433
- \`run: <source> -> <view>\`; a top-level \`# artifact\` query runs as
2434
- \`run: <name>\`. Either is only visible once surfaced through the entry (export
2435
- the source for a view, the query for a top-level query). Don't validate local
2436
- edits against a hosted/claude.ai connector \u2014 that serves the PUBLISHED model,
2437
- which is stale until \`malloyyo publish\`.
2438
- `;
2439
-
2440
2613
  // src/mcp.ts
2441
2614
  var ENTRY2 = "index.malloy";
2615
+ var MODE_STUB = {
2616
+ develop: "\n\n# DEVELOP (author) mode\nYou can author this Malloy model \u2014 compile / compile_file / prettify / query the files in this project (any .malloy path; no index.malloy required). For how-to, call yo_help \u2014 start with `dashboards/authoring`, then `dashboards/grid-layout`, `dashboards/vega-charts`, and the `develop/*` topics. To preview exactly what claude.ai web will see, relaunch as `malloyyo test`.",
2617
+ explore: "\n\n# TEST (explore) mode\nThis mirrors the claude.ai web experience \u2014 the same tools a hosted consumer gets, over this project's published entry model (index.malloy). Call yo_help for guidance. To author the model (compile / edit / dashboards), relaunch as `malloyyo author`."
2618
+ };
2442
2619
  function defaultConfig(rootUrl) {
2443
2620
  return new MalloyConfig2({ includeDefaultConnections: true }, {
2444
2621
  rootDirectory: rootUrl.toString()
@@ -2532,16 +2709,21 @@ function makeExploreHost(root, currentConfig) {
2532
2709
  }
2533
2710
  };
2534
2711
  }
2712
+ function makeDevelopHost(root, currentConfig) {
2713
+ return { withRuntime: makeWithRuntime(root, currentConfig) };
2714
+ }
2535
2715
  async function serveMcp(opts) {
2536
2716
  await import("@malloydata/malloy-connections");
2537
2717
  const root = path3.resolve(opts.root ?? process.cwd());
2718
+ const mode = opts.mode ?? "explore";
2538
2719
  const currentConfig = makeConfigSource(root);
2539
- const surface = exploreSurface(makeExploreHost(root, currentConfig));
2720
+ const surface = mode === "develop" ? developSurface(makeDevelopHost(root, currentConfig)) : exploreSurface(makeExploreHost(root, currentConfig));
2540
2721
  const instanceName = process.env.INSTANCE_NAME || "Malloyyo";
2722
+ const serverName = mode === "develop" ? "malloyyo-develop" : "malloyyo-explore";
2541
2723
  const server = new McpServer(
2542
- { name: "malloyyo-explore", version: opts.version },
2724
+ { name: serverName, version: opts.version },
2543
2725
  {
2544
- instructions: renderInstructions(surface.instructions, instanceName) + DASHBOARD_GUIDANCE,
2726
+ instructions: renderInstructions(surface.instructions, instanceName) + MODE_STUB[mode],
2545
2727
  capabilities: { tools: {}, prompts: {}, resources: {} }
2546
2728
  }
2547
2729
  );
@@ -2595,12 +2777,20 @@ function resolveRuntimeDir() {
2595
2777
  }
2596
2778
  var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
2597
2779
  async function discoverDashboards(root, runner) {
2598
- const result = await runner.artifacts();
2599
- if (!result.ok) throw new Error(`model error: ${result.error}`);
2600
- return result.artifacts.map((a) => {
2601
- const tsxPath = path4.join(root, "dashboards", a.name, "Dashboard.tsx");
2602
- return { ...a, tsxPath: fs3.existsSync(tsxPath) ? tsxPath : void 0 };
2603
- });
2780
+ const dir = path4.join(root, "dashboards");
2781
+ if (!fs3.existsSync(dir)) return [];
2782
+ const files = fs3.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
2783
+ const dashboards = [];
2784
+ for (const file of files) {
2785
+ const base = file.slice(0, -".malloy".length);
2786
+ const entryFile = path4.join("dashboards", file);
2787
+ const res = await runner.artifactForFile(entryFile, base);
2788
+ if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
2789
+ if (!res.artifact) continue;
2790
+ const component = ["jsx", "tsx"].map((ext) => path4.join(dir, `${base}.${ext}`)).find((p) => fs3.existsSync(p));
2791
+ dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
2792
+ }
2793
+ return dashboards;
2604
2794
  }
2605
2795
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2606
2796
  function makeBundler() {
@@ -2675,15 +2865,22 @@ window.addEventListener('message',async(e)=>{
2675
2865
  if(m&&m.type==='givens'){
2676
2866
  const u=new URL(location.href); u.search='';
2677
2867
  u.searchParams.set('d',${d});
2678
- for(const [k,v] of Object.entries(m.givens)) u.searchParams.set(k,String(v));
2868
+ for(const [k,v] of Object.entries(m.givens)) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
2679
2869
  history.replaceState(null,'',u.pathname+u.search);
2680
2870
  return;
2681
2871
  }
2872
+ if(m&&m.type==='navigate'&&typeof m.dashboard==='string'){
2873
+ const u=new URL(location.href); u.search='';
2874
+ u.searchParams.set('d',m.dashboard);
2875
+ for(const [k,v] of Object.entries(m.givens||{})) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
2876
+ location.href=u.pathname+u.search;
2877
+ return;
2878
+ }
2682
2879
  if(!m||m.type!=='run')return;
2683
2880
  let out;
2684
2881
  try{
2685
2882
  const res=await fetch('/api/run',{method:'POST',headers:{'content-type':'application/json'},
2686
- body:JSON.stringify({d:${d},query:m.query,malloy:m.malloy,givens:m.givens})});
2883
+ body:JSON.stringify({d:${d},query:m.query,malloy:m.malloy,givens:m.givens,dashboard:m.dashboard})});
2687
2884
  out=await res.json();
2688
2885
  }catch(err){ out={ok:false,problems:[{message:String(err)}]}; }
2689
2886
  f.contentWindow.postMessage({type:'result',id:m.id,...out},${fb});
@@ -2703,7 +2900,10 @@ function frameDoc(dash, givenSpecs, initialGivens) {
2703
2900
  query: dash.query,
2704
2901
  title: dash.title,
2705
2902
  description: dash.description,
2706
- givens: dash.givens
2903
+ tiles: dash.tiles,
2904
+ dashboard_columns: dash.dashboard_columns,
2905
+ givens: dash.givens,
2906
+ autorun: dash.autorun
2707
2907
  };
2708
2908
  return html(
2709
2909
  `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
@@ -2767,7 +2967,7 @@ async function serveDashboard(opts) {
2767
2967
  if (onFramePort) {
2768
2968
  if (url4.pathname === "/frame") {
2769
2969
  const dash = pick(url4);
2770
- const specs = await runner.givensForQuery(dash.query);
2970
+ const specs = dash.tiles && dash.entryFile ? await runner.dashboardGivens(dash.entryFile, dash.tiles) : await runner.givensForQuery(dash.query);
2771
2971
  if (!specs.ok) {
2772
2972
  return send(
2773
2973
  200,
@@ -2793,10 +2993,11 @@ async function serveDashboard(opts) {
2793
2993
  return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards, givensFromUrl(url4)));
2794
2994
  }
2795
2995
  if (url4.pathname === "/api/run" && req.method === "POST") {
2796
- const { d, query, malloy, givens } = JSON.parse(await readBody(req));
2996
+ const { d, query, malloy, givens, dashboard } = JSON.parse(await readBody(req));
2797
2997
  const dash = byName.get(d);
2798
2998
  if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
2799
- const out = typeof malloy === "string" ? await runner.runText(malloy, givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
2999
+ const entry = dash.entryFile;
3000
+ const out = dashboard && dash.tiles && entry ? await runner.runDashboard(entry, dash.tiles, { columns: dash.dashboard_columns, givens: givens ?? {} }) : 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 ?? {});
2800
3001
  return send(200, "application/json", JSON.stringify(out));
2801
3002
  }
2802
3003
  send(404, "text/plain", "not found");
@@ -2821,8 +3022,126 @@ async function serveDashboard(opts) {
2821
3022
  });
2822
3023
  }
2823
3024
 
3025
+ // src/init.ts
3026
+ import fs4 from "node:fs";
3027
+ import path5 from "node:path";
3028
+ var AUTHOR_MCP = {
3029
+ mcpServers: {
3030
+ // No -C: the server roots at the launch cwd (the project dir), so this file
3031
+ // is portable/committable — no absolute paths baked in.
3032
+ malloyyo_author: { command: "malloyyo", args: ["mcp", "--develop"] }
3033
+ }
3034
+ };
3035
+ function exportableNames(src) {
3036
+ const names = /* @__PURE__ */ new Set();
3037
+ const code = src.replace(/\/\/[^\n]*/g, "");
3038
+ for (const m of code.matchAll(/^\s*(?:source|query)\s*:\s*([A-Za-z_]\w*)\s+is\b/gm)) {
3039
+ names.add(m[1]);
3040
+ }
3041
+ for (const m of code.matchAll(/^\s*([A-Za-z_]\w*)\s*::/gm)) {
3042
+ names.add(m[1]);
3043
+ }
3044
+ return [...names];
3045
+ }
3046
+ function scaffoldIndex(root) {
3047
+ const indexPath = path5.join(root, "index.malloy");
3048
+ if (fs4.existsSync(indexPath)) {
3049
+ return { wrote: false, note: "index.malloy already exists \u2014 left as-is" };
3050
+ }
3051
+ const models = fs4.readdirSync(root).filter((f) => f.endsWith(".malloy") && f !== "index.malloy").sort();
3052
+ if (models.length === 0) {
3053
+ return { wrote: false, note: "no .malloy files found \u2014 skipped index.malloy" };
3054
+ }
3055
+ const blocks = [
3056
+ "// Generated by `malloyyo init` \u2014 the entry model. `dashboard dev`, `lint`,",
3057
+ "// `malloyyo test`, and the hosted app only see what THIS file exports.",
3058
+ "// Review the re-exports below (add givens / suggest queries your dashboards",
3059
+ "// reference), then validate with `malloyyo mcp --develop` or `dashboard dev`.",
3060
+ ""
3061
+ ];
3062
+ let anyNames = false;
3063
+ for (const file of models) {
3064
+ const names = exportableNames(fs4.readFileSync(path5.join(root, file), "utf8"));
3065
+ if (names.length === 0) {
3066
+ blocks.push(`// ${file}: no top-level source/query/given detected \u2014 add exports by hand`);
3067
+ continue;
3068
+ }
3069
+ anyNames = true;
3070
+ const list = names.join(", ");
3071
+ blocks.push(`import { ${list} } from './${file}'`);
3072
+ blocks.push(`export { ${list} }`);
3073
+ blocks.push("");
3074
+ }
3075
+ fs4.writeFileSync(indexPath, blocks.join("\n") + "\n");
3076
+ return {
3077
+ wrote: true,
3078
+ note: anyNames ? `wrote index.malloy re-exporting ${models.length} model file(s) \u2014 REVIEW it` : "wrote index.malloy skeleton \u2014 no exports detected, fill them in by hand"
3079
+ };
3080
+ }
3081
+ async function initCmd(dir) {
3082
+ const root = path5.resolve(dir);
3083
+ if (!fs4.existsSync(root) || !fs4.statSync(root).isDirectory()) {
3084
+ throw new Error(`not a directory: ${root}`);
3085
+ }
3086
+ const mcpPath = path5.join(root, ".mcp.json");
3087
+ if (fs4.existsSync(mcpPath)) {
3088
+ console.log(`\u2022 .mcp.json exists \u2014 leaving it. For author-by-default it should be:`);
3089
+ console.log(` ${JSON.stringify(AUTHOR_MCP.mcpServers.malloyyo_author)}`);
3090
+ console.log(` (server key "malloyyo_author", command "malloyyo mcp --develop").`);
3091
+ } else {
3092
+ fs4.writeFileSync(mcpPath, JSON.stringify(AUTHOR_MCP, null, 2) + "\n");
3093
+ console.log(`\u2713 wrote .mcp.json \u2014 \`cd ${dir} && claude\` now opens in AUTHOR mode`);
3094
+ }
3095
+ const idx = scaffoldIndex(root);
3096
+ console.log(`${idx.wrote ? "\u2713" : "\u2022"} ${idx.note}`);
3097
+ console.log("");
3098
+ console.log("Next:");
3099
+ console.log(" claude # author mode (mcp__malloyyo_author__* tools)");
3100
+ console.log(" malloyyo test # preview exactly what claude.ai web will see");
3101
+ console.log(" malloyyo dashboard dev # see dashboards render in a browser");
3102
+ }
3103
+
3104
+ // src/launch.ts
3105
+ import { spawn as spawn2 } from "node:child_process";
3106
+ import fs5 from "node:fs";
3107
+ import os from "node:os";
3108
+ import path6 from "node:path";
3109
+ var SURFACE_FLAG = { author: "--develop", test: "--explore" };
3110
+ var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
3111
+ async function launchCmd(mode, opts) {
3112
+ const root = path6.resolve(opts.root ?? process.cwd());
3113
+ const tmpDir = fs5.mkdtempSync(path6.join(os.tmpdir(), "malloyyo-launch-"));
3114
+ const cfgPath = path6.join(tmpDir, "mcp.json");
3115
+ const cfg = {
3116
+ mcpServers: {
3117
+ // Absolute -C: an ephemeral config, so pinning the root is robust.
3118
+ [SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
3119
+ }
3120
+ };
3121
+ fs5.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
3122
+ const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
3123
+ process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
3124
+ `);
3125
+ const child = spawn2("claude", ["--strict-mcp-config", "--mcp-config", cfgPath], {
3126
+ stdio: "inherit",
3127
+ cwd: root
3128
+ });
3129
+ await new Promise((resolve3) => {
3130
+ child.on("error", (e) => {
3131
+ process.stderr.write(
3132
+ `\u2717 could not launch \`claude\`: ${e.message}
3133
+ (is Claude Code installed and on PATH?)
3134
+ `
3135
+ );
3136
+ resolve3();
3137
+ });
3138
+ child.on("exit", () => resolve3());
3139
+ });
3140
+ fs5.rmSync(tmpDir, { recursive: true, force: true });
3141
+ }
3142
+
2824
3143
  // package.json
2825
- var version = "0.2.15";
3144
+ var version = "0.2.17";
2826
3145
 
2827
3146
  // src/index.ts
2828
3147
  function shortSha(sha) {
@@ -2908,10 +3227,26 @@ program.command("lint").argument("[dir]", "directory to lint", ".").description(
2908
3227
  if (!report.ok) process.exit(1);
2909
3228
  });
2910
3229
  program.command("status").argument("<target>", "named target from the `malloyyo` config block").option("--token <token>", "bearer token (overrides login/env)").description("show what's live on <target>: version, commit, compile state").action(status);
2911
- program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").description(
2912
- "run a local stdio MCP server (the explore / test-window surface) over the Malloy model in the current directory"
3230
+ program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").option("--develop", "author surface: compile/prettify/query any .malloy in the project").option("--explore", "explore surface: the claude.ai web preview (index.malloy only) [default]").description(
3231
+ "run a local stdio MCP server over the Malloy model in the current directory. --develop for authoring, --explore (default) to preview the web experience"
2913
3232
  ).action(async (opts) => {
2914
- await serveMcp({ root: opts.root, version });
3233
+ if (opts.develop && opts.explore) {
3234
+ throw new Error("pass only one of --develop / --explore");
3235
+ }
3236
+ await serveMcp({
3237
+ root: opts.root,
3238
+ version,
3239
+ mode: opts.develop ? "develop" : "explore"
3240
+ });
3241
+ });
3242
+ program.command("init").argument("[dir]", "model repo to set up", ".").description(
3243
+ "set up a model repo: write .mcp.json so `cd <repo> && claude` opens in author mode, and scaffold index.malloy if missing"
3244
+ ).action(initCmd);
3245
+ program.command("author").option("-C, --root <dir>", "project root (default: current directory)").description("launch Claude wired ONLY to the author surface (compile/edit the model)").action(async (opts) => {
3246
+ await launchCmd("author", opts);
3247
+ });
3248
+ program.command("test").option("-C, --root <dir>", "project root (default: current directory)").description("launch Claude wired ONLY to the explore surface \u2014 the claude.ai web preview").action(async (opts) => {
3249
+ await launchCmd("test", opts);
2915
3250
  });
2916
3251
  program.command("dashboard").argument("<action>", "action to run (currently: dev)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on", "4173").description("preview dashboard artifacts in ./dashboards against the local Malloy model").action(async (action, opts) => {
2917
3252
  if (action !== "dev") throw new Error(`unknown dashboard action '${action}' (expected: dev)`);