@malloydata/malloyyo 0.2.23 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/frame-inpage-entry.tsx +23 -3
- package/dist/frame-runtime/index.ts +20 -2
- package/dist/frame-runtime/runtime.tsx +128 -19
- package/dist/frame-wasm-entry.tsx +36 -9
- package/dist/index.js +191 -51
- package/dist/shared/givens-url.ts +49 -2
- package/dist/templates/skills/malloyyo-auto-update/SKILL.md +117 -0
- package/dist/templates/skills/malloyyo-data-site/SKILL.md +174 -0
- package/dist/templates/skills/malloyyo-data-site/reference/data-to-parquet.md +109 -0
- package/dist/templates/skills/malloyyo-data-site/reference/publish-to-github-pages.md +61 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -99,8 +99,8 @@ function resolveTarget(dir, name) {
|
|
|
99
99
|
}
|
|
100
100
|
function resolveInstance(dir, arg) {
|
|
101
101
|
if (arg && /^https?:\/\//i.test(arg)) {
|
|
102
|
-
const
|
|
103
|
-
return { name:
|
|
102
|
+
const url5 = normalizeUrl(arg);
|
|
103
|
+
return { name: url5, url: url5 };
|
|
104
104
|
}
|
|
105
105
|
const targets = readTargetMap(dir);
|
|
106
106
|
const entries = Object.entries(targets);
|
|
@@ -148,7 +148,7 @@ import url from "node:url";
|
|
|
148
148
|
var HOST_ONLY = "host_only";
|
|
149
149
|
var contentFiles = {
|
|
150
150
|
"dashboards/authoring.md": '---\ndescription: How to author a dashboard \u2014 a self-contained file in the dashboards/ directory that defines its own query; the entry point for dashboard how-to\n---\n\n# Authoring dashboards\n\nA dashboard is a **self-contained `.malloy` file in the `dashboards/`\ndirectory**. The file IS the dashboard: it imports the model parts it needs,\ndefines its query (with the filtering it applies), and tags it. No manifest, and\nfor the basic case no JavaScript. Preview with `malloyyo dashboard dev`; check\nwith `malloyyo lint`. Requires `@malloydata/malloy` 0.0.423+.\n\nRelated: `yo_help dashboards/givens-and-controls` (filter controls),\n`dashboards/grid-layout` (columns/colspan/break), `dashboards/custom-components`\n(a flat `<name>.jsx`), `dashboards/vega-charts` (`<VegaChart>`).\n\n> **Need a chart the `# bar_chart`/`# line_chart`/`# shape_map` tags can\'t do?**\n> Use the `<VegaChart>` COMPONENT (a Vega-Lite spec over query rows) \u2014 NOT a `#`\n> tag; there is no `# vega_lite`. See `yo_help dashboards/vega-charts`.\n\n## The layout\n\n```\nmodel/\n ecommerce.malloy # sources, reusable views/measures, # drill tags\n givens.malloy # given: declarations (the filter controls)\n index.malloy # imports/exports sources \u2014 the MCP/data surface ONLY\n dashboards/\n overview.malloy # one dashboard; the FILENAME is its name/slug\n overview.jsx # optional custom component for overview\n```\n\n**The filename is the dashboard\'s name** \u2014 its URL slug, its `# drill` target,\nand the basename of its optional component. Discovery globs\n`dashboards/*.malloy` and compiles EACH as its own entry \u2014 so dashboards are NOT\ndeclared in, or exported through, `index.malloy` (`index.malloy` is just the\n`query`/`describe_source` data surface).\n\n## Preferred: put the query IN the dashboard file\n\nDefine the query right in `dashboards/<name>.malloy` and tag it `# artifact`, so\nthe given mapping (the `where: \u2026 ~ $GIVEN`) is visible next to the dashboard:\n\n```malloy\n// dashboards/overview.malloy\n##! experimental.givens\nimport "../ecommerce.malloy" // BARE import: source + givens in scope\n\n#" Business health at a glance \u2014 sales, margin, orders.\n# artifact { title="Business Overview" } dashboard {columns=6}\nquery: overview is order_items -> {\n where: // the given mapping, HERE\n inventory_items.product_brand ~ $BRAND, // multi-filter where: is\n inventory_items.product_category ~ $CATEGORY, // COMMA separated\n created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, total_gross_margin, order_count\n # colspan=3\n nest:\n # line_chart\n sales_trend is by_month\n top_brands\n # shape_map\n sales_by_state\n}\n```\n\nThat\'s a complete dashboard: the runtime auto-renders a title (the tag\'s\n`title`, else the `#"` doc), a control for every given the query references, and\nthe result panel. `# artifact` DECLARES the dashboard; `# dashboard {columns=6}`\nis the renderer tag that draws it as KPI tiles + a card grid \u2014 partners, on the\nsame line. Grid rules: `yo_help dashboards/grid-layout`.\n\n**You know you\'re doing it right when the `where: foo ~ $FOO` is in the DASHBOARD\nfile, not the model.** Keep the model\'s sources/views reusable and given-free;\neach dashboard decides its own filtering.\n\n**The bare import is required for controls.** A control renders only when the\ngiven\'s DECLARATION is in the dashboard file\'s scope \u2014 a bare\n`import "../ecommerce.malloy"` (or `import "../givens.malloy"`) brings them all;\nthe runtime shows a control for exactly the givens the query references. A\nselective `import { order_items } from \u2026` brings the filter but NOT the control.\n\nKeep the FILENAME as the name \u2014 don\'t set `name=`, so the URL, the\n`# drill { to=\u2026 }`, and the component basename all agree (one source of truth).\n\n## Other forms\n\n- **A view of a source you extend in the file** \u2014 tag the dashboard `view:` with\n `# artifact` (runs as `<source> -> <view>`). Good when the dashboard needs\n helper views defined alongside it.\n- **Compose existing views**: a model-level `## artifact { tiles=["a -> b", "c -> d"]\n dashboard_columns=6 }` (`##`, ONE line) names several views. Each tile runs as\n its own query (in parallel), and the results are combined into ONE\n `# dashboard` that Malloy\'s dashboard renderer lays out \u2014 so it looks exactly\n like the equivalent single-query dashboard: `dashboard_columns=N` sets the grid\n and `# colspan=N` / `# break` on the tile VIEWS place them. A tile that returns\n a SINGLE ROW with no group-by (an aggregate view) is merged in as top-level KPI\n tiles rather than a card (its `# colspan` is spread across those KPIs). The\n dashboard paints once the tiles are ready, with a single early paint if one tile\n straggles so a slow tile can\'t hold up the rest. Use for multi-tile /\n cross-source; prefer the inline query whenever a dashboard has its own filtering.\n- A `dashboards/*.malloy` with NO `# artifact`/`## artifact` is a shared INCLUDE\n (skipped by discovery) \u2014 put helper sources/views there for several dashboards\n to import.\n\n## Givens (filter controls)\n\nDeclare givens as `filter<T>` in the MODEL (`givens.malloy` or the source file) \u2014\nthey\'re shared and used by the MCP surface too; each dashboard APPLIES them in\nits `where:`. Full control reference: `yo_help dashboards/givens-and-controls`.\nPer-dashboard starting values go in the tag:\n\n```malloy\n# artifact { title="Ford recalls" givens { MANUFACTURER=f\'Ford Motor Company\' } }\n```\n\n## Drill from a dimension\n\n`# drill` on a source `dimension:` (in the model) makes its cells clickable \u2014\nopening another dashboard (seeding the value) or filtering in place:\n\n```malloy\ndimension:\n # drill { to=[category_explorer, self] }\n category is inventory_items.product_category\n```\n\n`to` is a list; each is a **dashboard slug** (a `dashboards/<slug>.malloy`\nfilename) \u2192 opens it, seeding the value into the given named like the dimension\nUPPER-cased (`category` \u2192 `CATEGORY`), or **`self`** \u2192 filter the current\ndashboard in place. Add `given=` when the target given differs. `lint` VERIFIES\nevery `to=` slug resolves to a real dashboard file (a typo/renamed dashboard\nfails loudly, not at click time).\n\n> **malloy#2979 (fixed in 0.0.423):** a `# drill` on a bare `group_by: name` was\n> dropped when nested through `+ {\u2026}`. Put it on the source `dimension:`, or use\n> `group_by: name is concat(name,\'\')`.\n\n## Custom component (optional)\n\nFor bespoke layout/charts, add a flat sibling `dashboards/<name>.jsx` (or\n`.tsx`). Only React + `@malloyyo/dashboard` importable (sandboxed). A bare\n`<Panel/>` renders the whole dashboard; a `<Panel query="\u2026"/>` /\n`<VegaChart query="\u2026"/>` runs a query DEFINED in this dashboard file (by name) or\na `source -> view`. `lint` checks each `query="\u2026"` still resolves. See `yo_help\ndashboards/custom-components`.\n\n## Rules\n- Each dashboard is one `dashboards/<name>.malloy`; the filename is the slug.\n Prefer the inline `query: \u2026 # artifact` form \u2014 the `where: ~ $GIVEN` lives in\n the dashboard file.\n- Bare-import the model (and/or `givens.malloy`) so the controls render.\n- Givens are `filter<T>` declared in the model; options come from `# suggest {\u2026}`;\n interactivity = setting given values, not rewriting query text.\n- `index.malloy` is the data surface, NOT where dashboards live.\n- If a query/given you need is missing, add it (check with `describe_source`).\n\n## Preview & validate\n`malloyyo dashboard dev` \u2192 open the URL; `.malloy`/`.jsx` edits hot-reload.\n`malloyyo lint` checks each dashboard file on its own: it compiles as its entry;\neach tile/query and `# suggest` compiles; `dashboard_columns` is a positive int;\nthe component compiles and its `query="\u2026"` resolve; no duplicate names, no\norphaned component; every `# drill { to=\u2026 }` resolves. Tight loop: the local\n`malloyyo mcp --develop` server hot-reloads edits \u2014 `query(execute:false)` to\ncompile-check, `execute:true` to run. Don\'t validate against a hosted/claude.ai\nconnector \u2014 it serves the PUBLISHED model (stale until `malloyyo publish`).\n',
|
|
151
|
-
"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',
|
|
151
|
+
"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 `useUrlState(key, initial)` \u2192 [value, setValue] \u2014 shareable view-state (below)\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## Shareable view-state: `useUrlState`\n\n`useState` is invisible to the page that owns the URL, so a component built on\nit has an address bar that never changes \u2014 the result can\'t be shared or\nbookmarked. **`useUrlState(key, initial)` is a `useState` twin whose value lives\nin the URL**, under a `~key` param:\n\n```jsx\nimport { useUrlState } from "@malloyyo/dashboard";\n\nconst [rack, setRack] = useUrlState("rack", ""); // string\nconst [reuse, setReuse] = useUrlState("reuse", false); // boolean\nconst [board, setBoard] = useUrlState("board", "........"); // string\nconst [topN, setTopN] = useUrlState("n", 20); // number\n```\n\n- Same shape as `useState`: `[value, setValue]`, and `setValue` takes a value\n **or** an updater fn (`setBoard(b => \u2026)`).\n- The value comes from the URL on load, else `initial`. Every change is written\n back (debounced, `replaceState` \u2014 no history spam), so the address bar is\n always a shareable link.\n- Typed by `initial`: string / number / boolean / any JSON-serializable value.\n Strings stay readable in the URL (`~rack=retinas`); objects and arrays are\n JSON. A value equal to `initial` is dropped from the URL, so defaults never\n clutter it, and a malformed value falls back to `initial` instead of throwing.\n- Works identically in `malloyyo dashboard dev`, on a bundled static site, and\n on a hosted instance \u2014 including inside the sandboxed iframe, which cannot\n reach the top-level URL on its own. That\'s why this is a hook and not\n something a component can do with `history.replaceState`.\n\n**Use it for view-state, not query parameters.** A `given:` is the governed,\nfilter-typed query contract: it\'s declared in the model, drives the default\ncontrols, and is visible over MCP \u2014 bind those with `useGiven` and they already\nround-trip through the URL as `$NAME`. `useUrlState` is for everything else a\ncustom component needs to make shareable: a letter rack whose real query inputs\n(allowed letters, min/max length) are computed from it in JS, a board layout, a\nmode toggle. The two namespaces (`$NAME` vs `~key`) never collide.\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',
|
|
152
152
|
"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",
|
|
153
153
|
"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",
|
|
154
154
|
"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',
|
|
@@ -2374,24 +2374,24 @@ function readAll() {
|
|
|
2374
2374
|
return {};
|
|
2375
2375
|
}
|
|
2376
2376
|
}
|
|
2377
|
-
function loadCreds(
|
|
2378
|
-
return readAll()[
|
|
2377
|
+
function loadCreds(url5) {
|
|
2378
|
+
return readAll()[url5];
|
|
2379
2379
|
}
|
|
2380
|
-
function saveCreds(
|
|
2380
|
+
function saveCreds(url5, creds) {
|
|
2381
2381
|
const p = credsPath();
|
|
2382
2382
|
mkdirSync(dirname(p), { recursive: true });
|
|
2383
2383
|
const all = readAll();
|
|
2384
|
-
all[
|
|
2384
|
+
all[url5] = creds;
|
|
2385
2385
|
writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
2386
2386
|
try {
|
|
2387
2387
|
chmodSync(p, 384);
|
|
2388
2388
|
} catch {
|
|
2389
2389
|
}
|
|
2390
2390
|
}
|
|
2391
|
-
function clearCreds(
|
|
2391
|
+
function clearCreds(url5) {
|
|
2392
2392
|
const all = readAll();
|
|
2393
|
-
if (!(
|
|
2394
|
-
delete all[
|
|
2393
|
+
if (!(url5 in all)) return false;
|
|
2394
|
+
delete all[url5];
|
|
2395
2395
|
writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
2396
2396
|
return true;
|
|
2397
2397
|
}
|
|
@@ -2424,8 +2424,8 @@ async function registerClient(registrationEndpoint, redirectUri) {
|
|
|
2424
2424
|
if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
|
|
2425
2425
|
return (await res.json()).client_id;
|
|
2426
2426
|
}
|
|
2427
|
-
function openBrowser(
|
|
2428
|
-
const [cmd, args] = process.platform === "darwin" ? ["open", [
|
|
2427
|
+
function openBrowser(url5) {
|
|
2428
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url5]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url5]] : ["xdg-open", [url5]];
|
|
2429
2429
|
try {
|
|
2430
2430
|
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
2431
2431
|
} catch {
|
|
@@ -2765,13 +2765,21 @@ import path5 from "node:path";
|
|
|
2765
2765
|
import * as esbuild2 from "esbuild";
|
|
2766
2766
|
|
|
2767
2767
|
// src/shared/givens-url.ts
|
|
2768
|
+
var URL_STATE_PREFIX = "~";
|
|
2768
2769
|
function givensFromSearch(search) {
|
|
2769
2770
|
const g = {};
|
|
2770
2771
|
for (const [k, v] of new URLSearchParams(search)) {
|
|
2771
|
-
if (k !== "d") g[k] = v;
|
|
2772
|
+
if (k !== "d" && k.charAt(0) !== URL_STATE_PREFIX) g[k] = v;
|
|
2772
2773
|
}
|
|
2773
2774
|
return g;
|
|
2774
2775
|
}
|
|
2776
|
+
function urlStateFromSearch(search) {
|
|
2777
|
+
const s = {};
|
|
2778
|
+
for (const [k, v] of new URLSearchParams(search)) {
|
|
2779
|
+
if (k.charAt(0) === URL_STATE_PREFIX) s[k] = v;
|
|
2780
|
+
}
|
|
2781
|
+
return s;
|
|
2782
|
+
}
|
|
2775
2783
|
|
|
2776
2784
|
// src/shared/nav.ts
|
|
2777
2785
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -2957,7 +2965,7 @@ var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><t
|
|
|
2957
2965
|
function navHtml2(dash, all) {
|
|
2958
2966
|
return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
|
|
2959
2967
|
}
|
|
2960
|
-
function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
|
|
2968
|
+
function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
2961
2969
|
const info = {
|
|
2962
2970
|
name: dash.name,
|
|
2963
2971
|
query: dash.query,
|
|
@@ -2970,12 +2978,12 @@ function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
|
|
|
2970
2978
|
autorun: dash.autorun
|
|
2971
2979
|
};
|
|
2972
2980
|
return html(
|
|
2973
|
-
navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
2981
|
+
navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)};window.__INITIAL_URLSTATE__=${JSON.stringify(initialUrlState)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
2974
2982
|
dash.title
|
|
2975
2983
|
);
|
|
2976
2984
|
}
|
|
2977
|
-
function parentShell(dash, frameBase, all, initialGivens) {
|
|
2978
|
-
const givensQs = Object.entries(initialGivens).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
|
|
2985
|
+
function parentShell(dash, frameBase, all, initialGivens, initialUrlState) {
|
|
2986
|
+
const givensQs = Object.entries({ ...initialGivens, ...initialUrlState }).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
|
|
2979
2987
|
const d = JSON.stringify(dash.name);
|
|
2980
2988
|
const fb = JSON.stringify(frameBase);
|
|
2981
2989
|
const nav = navHtml2(dash, all);
|
|
@@ -2985,21 +2993,37 @@ function parentShell(dash, frameBase, all, initialGivens) {
|
|
|
2985
2993
|
`<iframe id="f" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" src="${frameBase}/frame?d=${encodeURIComponent(dash.name)}${givensQs}" style="border:0;flex:1;width:100%"></iframe></div><script>
|
|
2986
2994
|
const f=document.getElementById('f');
|
|
2987
2995
|
try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}
|
|
2996
|
+
// The shareable URL has TWO namespaces the frame syncs independently:
|
|
2997
|
+
// '$NAME' = a given (the governed query contract), '~key' = a custom
|
|
2998
|
+
// component's useUrlState view-state. Each write must re-emit the other's
|
|
2999
|
+
// params, so both are cached and every write rebuilds the whole query string.
|
|
3000
|
+
let G=${JSON.stringify(Object.fromEntries(Object.entries(initialGivens).map(([k, v]) => [k.replace(/^\$/, ""), v])))};
|
|
3001
|
+
let U=${JSON.stringify(Object.fromEntries(Object.entries(initialUrlState).map(([k, v]) => [k.replace(/^~/, ""), v])))};
|
|
3002
|
+
function shareUrl(dashboard){
|
|
3003
|
+
const u=new URL(location.href); u.search='';
|
|
3004
|
+
u.searchParams.set('d',dashboard);
|
|
3005
|
+
for(const [k,v] of Object.entries(G)) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
|
|
3006
|
+
for(const [k,v] of Object.entries(U)) if(v!=null) u.searchParams.set('~'+k,String(v));
|
|
3007
|
+
return u.pathname+u.search;
|
|
3008
|
+
}
|
|
2988
3009
|
window.addEventListener('message',async(e)=>{
|
|
2989
3010
|
if(e.source!==f.contentWindow||e.origin!==${fb})return;
|
|
2990
3011
|
const m=e.data;
|
|
2991
3012
|
if(m&&m.type==='givens'){
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
3013
|
+
G=m.givens||{};
|
|
3014
|
+
history.replaceState(null,'',shareUrl(${d}));
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
if(m&&m.type==='urlstate'){
|
|
3018
|
+
U=m.state||{};
|
|
3019
|
+
history.replaceState(null,'',shareUrl(${d}));
|
|
2996
3020
|
return;
|
|
2997
3021
|
}
|
|
2998
3022
|
if(m&&m.type==='navigate'&&typeof m.dashboard==='string'){
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
location.href=
|
|
3023
|
+
// A drill leaves this dashboard: carry the givens it seeded, but NOT this
|
|
3024
|
+
// component's view-state \u2014 that belongs to the component being left.
|
|
3025
|
+
G=m.givens||{}; U={};
|
|
3026
|
+
location.href=shareUrl(m.dashboard);
|
|
3003
3027
|
return;
|
|
3004
3028
|
}
|
|
3005
3029
|
if(!m||m.type!=='run')return;
|
|
@@ -3015,10 +3039,13 @@ window.addEventListener('message',async(e)=>{
|
|
|
3015
3039
|
dash.title
|
|
3016
3040
|
);
|
|
3017
3041
|
}
|
|
3018
|
-
function givensFromUrl(
|
|
3019
|
-
return givensFromSearch(
|
|
3042
|
+
function givensFromUrl(url5) {
|
|
3043
|
+
return givensFromSearch(url5.search);
|
|
3044
|
+
}
|
|
3045
|
+
function urlStateFromUrl(url5) {
|
|
3046
|
+
return urlStateFromSearch(url5.search);
|
|
3020
3047
|
}
|
|
3021
|
-
function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
|
|
3048
|
+
function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
3022
3049
|
const info = {
|
|
3023
3050
|
name: dash.name,
|
|
3024
3051
|
query: dash.query,
|
|
@@ -3033,7 +3060,7 @@ function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
|
|
|
3033
3060
|
autorun: dash.autorun
|
|
3034
3061
|
};
|
|
3035
3062
|
return html(
|
|
3036
|
-
`<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>`,
|
|
3063
|
+
`<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)};window.__INITIAL_URLSTATE__=${JSON.stringify(initialUrlState)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
3037
3064
|
dash.title
|
|
3038
3065
|
);
|
|
3039
3066
|
}
|
|
@@ -3061,7 +3088,7 @@ async function serveDashboard(opts) {
|
|
|
3061
3088
|
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
3062
3089
|
const bundle = makeBundler();
|
|
3063
3090
|
const inPageBundle = makeInPageBundler();
|
|
3064
|
-
const pick = (
|
|
3091
|
+
const pick = (url5) => byName.get(url5.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
3065
3092
|
async function resolveGivens(dash) {
|
|
3066
3093
|
if (dash.tiles && dash.entryFile) {
|
|
3067
3094
|
const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
|
|
@@ -3095,15 +3122,15 @@ async function serveDashboard(opts) {
|
|
|
3095
3122
|
}
|
|
3096
3123
|
const handler = async (req, res) => {
|
|
3097
3124
|
const onFramePort = (req.socket.localPort ?? port) === framePort;
|
|
3098
|
-
const
|
|
3125
|
+
const url5 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
|
|
3099
3126
|
const send = (code, type, body, extra = {}) => {
|
|
3100
3127
|
res.writeHead(code, { "content-type": type, ...extra });
|
|
3101
3128
|
res.end(body);
|
|
3102
3129
|
};
|
|
3103
3130
|
try {
|
|
3104
3131
|
if (onFramePort) {
|
|
3105
|
-
if (
|
|
3106
|
-
const dash = pick(
|
|
3132
|
+
if (url5.pathname === "/frame") {
|
|
3133
|
+
const dash = pick(url5);
|
|
3107
3134
|
const g = await resolveGivens(dash);
|
|
3108
3135
|
if (!g.ok) {
|
|
3109
3136
|
return send(
|
|
@@ -3112,22 +3139,26 @@ async function serveDashboard(opts) {
|
|
|
3112
3139
|
html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
|
|
3113
3140
|
);
|
|
3114
3141
|
}
|
|
3115
|
-
return send(
|
|
3142
|
+
return send(
|
|
3143
|
+
200,
|
|
3144
|
+
"text/html; charset=utf-8",
|
|
3145
|
+
frameDoc(dash, g.union, givensFromUrl(url5), urlStateFromUrl(url5), g.tiles)
|
|
3146
|
+
);
|
|
3116
3147
|
}
|
|
3117
|
-
if (
|
|
3118
|
-
return send(200, "application/javascript; charset=utf-8", await bundle(pick(
|
|
3148
|
+
if (url5.pathname === "/bundle.js") {
|
|
3149
|
+
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url5)));
|
|
3119
3150
|
}
|
|
3120
3151
|
return send(404, "text/plain", "not found");
|
|
3121
3152
|
}
|
|
3122
|
-
if (
|
|
3153
|
+
if (url5.pathname === "/events") {
|
|
3123
3154
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
3124
3155
|
res.write("retry: 1000\n\n");
|
|
3125
3156
|
sseClients.add(res);
|
|
3126
3157
|
req.on("close", () => sseClients.delete(res));
|
|
3127
3158
|
return;
|
|
3128
3159
|
}
|
|
3129
|
-
if (
|
|
3130
|
-
const dash = pick(
|
|
3160
|
+
if (url5.pathname === "/") {
|
|
3161
|
+
const dash = pick(url5);
|
|
3131
3162
|
if (!dash.tsxPath) {
|
|
3132
3163
|
const g = await resolveGivens(dash);
|
|
3133
3164
|
if (!g.ok) {
|
|
@@ -3137,14 +3168,22 @@ async function serveDashboard(opts) {
|
|
|
3137
3168
|
html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
|
|
3138
3169
|
);
|
|
3139
3170
|
}
|
|
3140
|
-
return send(
|
|
3171
|
+
return send(
|
|
3172
|
+
200,
|
|
3173
|
+
"text/html; charset=utf-8",
|
|
3174
|
+
inPageShell(dash, dashboards, g.union, givensFromUrl(url5), urlStateFromUrl(url5), g.tiles)
|
|
3175
|
+
);
|
|
3141
3176
|
}
|
|
3142
|
-
return send(
|
|
3177
|
+
return send(
|
|
3178
|
+
200,
|
|
3179
|
+
"text/html; charset=utf-8",
|
|
3180
|
+
parentShell(dash, frameBase, dashboards, givensFromUrl(url5), urlStateFromUrl(url5))
|
|
3181
|
+
);
|
|
3143
3182
|
}
|
|
3144
|
-
if (
|
|
3183
|
+
if (url5.pathname === "/inpage.js") {
|
|
3145
3184
|
return send(200, "application/javascript; charset=utf-8", await inPageBundle());
|
|
3146
3185
|
}
|
|
3147
|
-
if (
|
|
3186
|
+
if (url5.pathname === "/api/run" && req.method === "POST") {
|
|
3148
3187
|
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
3149
3188
|
const dash = byName.get(d);
|
|
3150
3189
|
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
@@ -3624,6 +3663,7 @@ Publish: deploy ${path7.basename(outDir)}/ to Vercel (vercel.json written: clean
|
|
|
3624
3663
|
// src/init.ts
|
|
3625
3664
|
import fs7 from "node:fs";
|
|
3626
3665
|
import path8 from "node:path";
|
|
3666
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3627
3667
|
var AUTHOR_MCP = {
|
|
3628
3668
|
mcpServers: {
|
|
3629
3669
|
// No -C: the server roots at the launch cwd (the project dir), so this file
|
|
@@ -3677,6 +3717,31 @@ function scaffoldIndex(root) {
|
|
|
3677
3717
|
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"
|
|
3678
3718
|
};
|
|
3679
3719
|
}
|
|
3720
|
+
function installSkills(root) {
|
|
3721
|
+
const distDir = path8.dirname(fileURLToPath2(import.meta.url));
|
|
3722
|
+
const candidates = [
|
|
3723
|
+
path8.join(distDir, "templates", "skills"),
|
|
3724
|
+
path8.join(distDir, "..", "src", "templates", "skills")
|
|
3725
|
+
];
|
|
3726
|
+
const srcSkills = candidates.find((p) => fs7.existsSync(p));
|
|
3727
|
+
if (!srcSkills) return { wrote: [], skipped: [], note: "no skill templates found \u2014 skipped" };
|
|
3728
|
+
const destSkills = path8.join(root, ".claude", "skills");
|
|
3729
|
+
fs7.mkdirSync(destSkills, { recursive: true });
|
|
3730
|
+
const wrote = [];
|
|
3731
|
+
const skipped = [];
|
|
3732
|
+
for (const name of fs7.readdirSync(srcSkills)) {
|
|
3733
|
+
const from = path8.join(srcSkills, name);
|
|
3734
|
+
if (!fs7.statSync(from).isDirectory()) continue;
|
|
3735
|
+
const to = path8.join(destSkills, name);
|
|
3736
|
+
if (fs7.existsSync(to)) {
|
|
3737
|
+
skipped.push(name);
|
|
3738
|
+
continue;
|
|
3739
|
+
}
|
|
3740
|
+
fs7.cpSync(from, to, { recursive: true });
|
|
3741
|
+
wrote.push(name);
|
|
3742
|
+
}
|
|
3743
|
+
return { wrote, skipped };
|
|
3744
|
+
}
|
|
3680
3745
|
async function initCmd(dir) {
|
|
3681
3746
|
const root = path8.resolve(dir);
|
|
3682
3747
|
if (!fs7.existsSync(root) || !fs7.statSync(root).isDirectory()) {
|
|
@@ -3693,6 +3758,17 @@ async function initCmd(dir) {
|
|
|
3693
3758
|
}
|
|
3694
3759
|
const idx = scaffoldIndex(root);
|
|
3695
3760
|
console.log(`${idx.wrote ? "\u2713" : "\u2022"} ${idx.note}`);
|
|
3761
|
+
const sk = installSkills(root);
|
|
3762
|
+
if (sk.note) {
|
|
3763
|
+
console.log(`\u2022 ${sk.note}`);
|
|
3764
|
+
} else {
|
|
3765
|
+
if (sk.wrote.length) {
|
|
3766
|
+
console.log(`\u2713 installed skill(s) into .claude/skills/: ${sk.wrote.join(", ")}`);
|
|
3767
|
+
}
|
|
3768
|
+
if (sk.skipped.length) {
|
|
3769
|
+
console.log(`\u2022 skill(s) already present \u2014 left as-is: ${sk.skipped.join(", ")}`);
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3696
3772
|
console.log("");
|
|
3697
3773
|
console.log("Next:");
|
|
3698
3774
|
console.log(" claude # author mode (mcp__malloyyo_author__* tools)");
|
|
@@ -3700,24 +3776,81 @@ async function initCmd(dir) {
|
|
|
3700
3776
|
console.log(" malloyyo dashboard dev # see dashboards render in a browser");
|
|
3701
3777
|
}
|
|
3702
3778
|
|
|
3779
|
+
// src/sql.ts
|
|
3780
|
+
import fs8 from "node:fs";
|
|
3781
|
+
import path9 from "node:path";
|
|
3782
|
+
import url4 from "node:url";
|
|
3783
|
+
import { MalloyConfig as MalloyConfig3, discoverConfig as discoverConfig3 } from "@malloydata/malloy";
|
|
3784
|
+
function fileReader() {
|
|
3785
|
+
return {
|
|
3786
|
+
readURL: async (u) => {
|
|
3787
|
+
if (u.protocol !== "file:") {
|
|
3788
|
+
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
3789
|
+
}
|
|
3790
|
+
return fs8.promises.readFile(u, "utf8");
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
async function loadConfig3(rootDir) {
|
|
3795
|
+
const rootUrl = url4.pathToFileURL(rootDir.endsWith(path9.sep) ? rootDir : rootDir + path9.sep);
|
|
3796
|
+
const discovered = await discoverConfig3(rootUrl, rootUrl, fileReader()).catch(() => null);
|
|
3797
|
+
return discovered ?? new MalloyConfig3({ includeDefaultConnections: true }, {
|
|
3798
|
+
rootDirectory: rootUrl.toString()
|
|
3799
|
+
});
|
|
3800
|
+
}
|
|
3801
|
+
async function readStdin() {
|
|
3802
|
+
const chunks = [];
|
|
3803
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3804
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3805
|
+
}
|
|
3806
|
+
async function resolveSql(opts) {
|
|
3807
|
+
if (opts.execute != null) return opts.execute;
|
|
3808
|
+
if (opts.file) return fs8.promises.readFile(opts.file, "utf8");
|
|
3809
|
+
return readStdin();
|
|
3810
|
+
}
|
|
3811
|
+
async function sqlCmd(connection, opts) {
|
|
3812
|
+
const name = connection ?? "duckdb";
|
|
3813
|
+
const rootDir = path9.resolve(opts.root ?? ".");
|
|
3814
|
+
const sql = (await resolveSql(opts)).trim();
|
|
3815
|
+
if (!sql) {
|
|
3816
|
+
throw new Error("no SQL provided \u2014 pass -e <sql>, -f <file>, or pipe it via stdin");
|
|
3817
|
+
}
|
|
3818
|
+
await import("@malloydata/malloy-connections");
|
|
3819
|
+
const cfg = await loadConfig3(rootDir);
|
|
3820
|
+
try {
|
|
3821
|
+
const conn = await cfg.connections.lookupConnection(name);
|
|
3822
|
+
const result = await conn.runSQL(sql);
|
|
3823
|
+
const rows = result?.rows ?? [];
|
|
3824
|
+
if (opts.json) {
|
|
3825
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
3826
|
+
} else if (rows.length === 0) {
|
|
3827
|
+
console.log(`ok \u2014 statement ran on connection "${name}" (no result rows)`);
|
|
3828
|
+
} else {
|
|
3829
|
+
console.table(rows);
|
|
3830
|
+
}
|
|
3831
|
+
} finally {
|
|
3832
|
+
await cfg.shutdown?.();
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3703
3836
|
// src/launch.ts
|
|
3704
3837
|
import { spawn as spawn2 } from "node:child_process";
|
|
3705
|
-
import
|
|
3838
|
+
import fs9 from "node:fs";
|
|
3706
3839
|
import os from "node:os";
|
|
3707
|
-
import
|
|
3840
|
+
import path10 from "node:path";
|
|
3708
3841
|
var SURFACE_FLAG = { author: "--develop", test: "--explore" };
|
|
3709
3842
|
var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
|
|
3710
3843
|
async function launchCmd(mode, opts) {
|
|
3711
|
-
const root =
|
|
3712
|
-
const tmpDir =
|
|
3713
|
-
const cfgPath =
|
|
3844
|
+
const root = path10.resolve(opts.root ?? process.cwd());
|
|
3845
|
+
const tmpDir = fs9.mkdtempSync(path10.join(os.tmpdir(), "malloyyo-launch-"));
|
|
3846
|
+
const cfgPath = path10.join(tmpDir, "mcp.json");
|
|
3714
3847
|
const cfg = {
|
|
3715
3848
|
mcpServers: {
|
|
3716
3849
|
// Absolute -C: an ephemeral config, so pinning the root is robust.
|
|
3717
3850
|
[SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
|
|
3718
3851
|
}
|
|
3719
3852
|
};
|
|
3720
|
-
|
|
3853
|
+
fs9.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
3721
3854
|
const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
|
|
3722
3855
|
process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
|
|
3723
3856
|
`);
|
|
@@ -3736,11 +3869,11 @@ async function launchCmd(mode, opts) {
|
|
|
3736
3869
|
});
|
|
3737
3870
|
child.on("exit", () => resolve3());
|
|
3738
3871
|
});
|
|
3739
|
-
|
|
3872
|
+
fs9.rmSync(tmpDir, { recursive: true, force: true });
|
|
3740
3873
|
}
|
|
3741
3874
|
|
|
3742
3875
|
// package.json
|
|
3743
|
-
var version = "0.2.
|
|
3876
|
+
var version = "0.2.25";
|
|
3744
3877
|
|
|
3745
3878
|
// src/index.ts
|
|
3746
3879
|
function shortSha(sha) {
|
|
@@ -3841,6 +3974,13 @@ program.command("mcp").option("-C, --root <dir>", "project root (default: curren
|
|
|
3841
3974
|
program.command("init").argument("[dir]", "model repo to set up", ".").description(
|
|
3842
3975
|
"set up a model repo: write .mcp.json so `cd <repo> && claude` opens in author mode, and scaffold index.malloy if missing"
|
|
3843
3976
|
).action(initCmd);
|
|
3977
|
+
program.command("sql").argument("[connection]", "connection name from malloy-config.json", "duckdb").option("-e, --execute <sql>", "SQL to run (else read from -f <file> or stdin)").option("-f, --file <path>", "read SQL from a file").option("-C, --root <dir>", "project root for malloy-config.json discovery (default: current directory)").option("-j, --json", "print result rows as JSON").description(
|
|
3978
|
+
"run raw SQL against a configured connection using the embedded DuckDB \u2014 e.g. COPY a web CSV into docs/*.parquet, no standalone duckdb needed"
|
|
3979
|
+
).action(
|
|
3980
|
+
async (connection, opts) => {
|
|
3981
|
+
await sqlCmd(connection, opts);
|
|
3982
|
+
}
|
|
3983
|
+
);
|
|
3844
3984
|
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) => {
|
|
3845
3985
|
await launchCmd("author", opts);
|
|
3846
3986
|
});
|
|
@@ -10,16 +10,63 @@
|
|
|
10
10
|
// Deliberately dependency-free (no React, no node builtins) so the Node dev
|
|
11
11
|
// server and the browser bundle can share the same file.
|
|
12
12
|
|
|
13
|
+
// Two namespaces share the query string and must never collide:
|
|
14
|
+
// `$NAME` a GIVEN — the governed, filter-typed query contract (declared in
|
|
15
|
+
// the model, drives the auto-rendered controls, MCP-visible).
|
|
16
|
+
// `~key` a custom component's VIEW-STATE (useUrlState): a rack string, a
|
|
17
|
+
// board layout, a checkbox. Not a query parameter; the runtime
|
|
18
|
+
// round-trips it verbatim so JS-driven components get shareable links.
|
|
19
|
+
export const URL_STATE_PREFIX = "~";
|
|
20
|
+
|
|
13
21
|
/** Query string -> given values. Keys KEEP their `$` prefix — the runtime
|
|
14
|
-
requires it —
|
|
22
|
+
requires it — while `d` (the dashboard selector) and the `~` view-state
|
|
23
|
+
namespace are dropped. */
|
|
15
24
|
export function givensFromSearch(search: string): Record<string, string> {
|
|
16
25
|
const g: Record<string, string> = {};
|
|
17
26
|
for (const [k, v] of new URLSearchParams(search)) {
|
|
18
|
-
if (k !== "d") g[k] = v;
|
|
27
|
+
if (k !== "d" && k.charAt(0) !== URL_STATE_PREFIX) g[k] = v;
|
|
19
28
|
}
|
|
20
29
|
return g;
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/** Query string -> view-state values (`useUrlState`). Keys KEEP their `~`
|
|
33
|
+
prefix, mirroring givensFromSearch: the runtime strips it itself. */
|
|
34
|
+
export function urlStateFromSearch(search: string): Record<string, string> {
|
|
35
|
+
const s: Record<string, string> = {};
|
|
36
|
+
for (const [k, v] of new URLSearchParams(search)) {
|
|
37
|
+
if (k.charAt(0) === URL_STATE_PREFIX) s[k] = v;
|
|
38
|
+
}
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** View-state -> `~`-prefixed query params. Accepts keys with or without the
|
|
43
|
+
prefix. Unlike givens, an EMPTY STRING is kept: a component whose default is
|
|
44
|
+
non-empty needs `~rack=` to mean "the user cleared it" (the runtime already
|
|
45
|
+
drops keys that equal their default, so nothing pointless reaches here). */
|
|
46
|
+
export function urlStateToParams(state: Record<string, unknown>): URLSearchParams {
|
|
47
|
+
const p = new URLSearchParams();
|
|
48
|
+
for (const [k, v] of Object.entries(state ?? {})) {
|
|
49
|
+
if (v == null) continue;
|
|
50
|
+
p.set(k.charAt(0) === URL_STATE_PREFIX ? k : URL_STATE_PREFIX + k, String(v));
|
|
51
|
+
}
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The one shareable query string: `d` (when the host uses a selector) +
|
|
56
|
+
`$givens` + `~view-state`, in that order. Returns "" or "?…". */
|
|
57
|
+
export function shareSearch(opts: {
|
|
58
|
+
d?: string;
|
|
59
|
+
givens?: Record<string, unknown>;
|
|
60
|
+
urlState?: Record<string, unknown>;
|
|
61
|
+
}): string {
|
|
62
|
+
const p = new URLSearchParams();
|
|
63
|
+
if (opts.d != null) p.set("d", opts.d);
|
|
64
|
+
for (const [k, v] of givensToParams(opts.givens ?? {})) p.set(k, v);
|
|
65
|
+
for (const [k, v] of urlStateToParams(opts.urlState ?? {})) p.set(k, v);
|
|
66
|
+
const s = p.toString();
|
|
67
|
+
return s ? "?" + s : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
23
70
|
/** Given values -> `$`-prefixed query params, skipping empties. Accepts keys
|
|
24
71
|
with or without the prefix so callers can pass either the runtime's bare
|
|
25
72
|
names or values already read out of a URL. */
|