@malloydata/malloyyo 0.2.22 → 0.2.24

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/index.js CHANGED
@@ -7,19 +7,80 @@ import { resolve as resolve2 } from "node:path";
7
7
  // src/config.ts
8
8
  import { readFileSync, existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
+ var KNOWN_KEYS = /* @__PURE__ */ new Set(["analytics", "targets"]);
11
+ function isTarget(v) {
12
+ return typeof v === "object" && v !== null && typeof v.url === "string";
13
+ }
14
+ var warned = /* @__PURE__ */ new Set();
15
+ function warnOnce(key, msg) {
16
+ if (warned.has(key)) return;
17
+ warned.add(key);
18
+ console.warn(msg);
19
+ }
20
+ function parseMalloyyoConfig(raw) {
21
+ const declared = raw.targets;
22
+ const targets = typeof declared === "object" && declared !== null ? { ...declared } : {};
23
+ const legacy = [];
24
+ const unknown = [];
25
+ for (const [k, v] of Object.entries(raw)) {
26
+ if (KNOWN_KEYS.has(k)) continue;
27
+ if (isTarget(v)) {
28
+ if (!(k in targets)) targets[k] = v;
29
+ legacy.push(k);
30
+ } else {
31
+ unknown.push(k);
32
+ }
33
+ }
34
+ if (legacy.length) {
35
+ warnOnce(
36
+ "legacy-targets",
37
+ `malloy-config.json: publish target(s) ${legacy.map((t) => `"${t}"`).join(", ")} are at the top of the "malloyyo" block. Move them under "targets":
38
+ "malloyyo": { "targets": { ${legacy.map((t) => `"${t}": { \u2026 }`).join(", ")} } }
39
+ The old shape still works for now.`
40
+ );
41
+ }
42
+ if (unknown.length) {
43
+ warnOnce(
44
+ "unknown-keys",
45
+ `malloy-config.json: ignoring unknown key(s) in the "malloyyo" block: ${unknown.map((k) => `"${k}"`).join(", ")}. Known keys: ${[...KNOWN_KEYS].join(", ")}.`
46
+ );
47
+ }
48
+ const analytics = raw.analytics;
49
+ return {
50
+ analytics: typeof analytics === "string" && analytics ? analytics : void 0,
51
+ targets
52
+ };
53
+ }
10
54
  function readTargetMap(dir) {
55
+ const raw = readMalloyyoBlock(dir);
56
+ if (raw) return parseMalloyyoConfig(raw).targets;
57
+ throw new Error(
58
+ `No \`malloyyo\` config found in ${dir} (looked for a "malloyyo" block in malloy-config.json, then malloyyo.json).`
59
+ );
60
+ }
61
+ function readMalloyyoBlock(dir) {
11
62
  const malloyConfig = join(dir, "malloy-config.json");
12
63
  if (existsSync(malloyConfig)) {
13
- const json = JSON.parse(readFileSync(malloyConfig, "utf8"));
14
- if (json.malloyyo && typeof json.malloyyo === "object") return json.malloyyo;
64
+ try {
65
+ const json = JSON.parse(readFileSync(malloyConfig, "utf8"));
66
+ if (json.malloyyo && typeof json.malloyyo === "object") return json.malloyyo;
67
+ } catch {
68
+ }
15
69
  }
16
70
  const standalone = join(dir, "malloyyo.json");
17
71
  if (existsSync(standalone)) {
18
- return JSON.parse(readFileSync(standalone, "utf8"));
72
+ try {
73
+ return JSON.parse(readFileSync(standalone, "utf8"));
74
+ } catch {
75
+ }
19
76
  }
20
- throw new Error(
21
- `No \`malloyyo\` config found in ${dir} (looked for a "malloyyo" block in malloy-config.json, then malloyyo.json).`
22
- );
77
+ return null;
78
+ }
79
+ function readSiteConfig(dir) {
80
+ const raw = readMalloyyoBlock(dir);
81
+ if (!raw) return {};
82
+ const { analytics } = parseMalloyyoConfig(raw);
83
+ return analytics ? { analytics } : {};
23
84
  }
24
85
  var normalizeUrl = (u) => u.replace(/\/+$/, "");
25
86
  function resolveTarget(dir, name) {
@@ -38,8 +99,8 @@ function resolveTarget(dir, name) {
38
99
  }
39
100
  function resolveInstance(dir, arg) {
40
101
  if (arg && /^https?:\/\//i.test(arg)) {
41
- const url4 = normalizeUrl(arg);
42
- return { name: url4, url: url4 };
102
+ const url5 = normalizeUrl(arg);
103
+ return { name: url5, url: url5 };
43
104
  }
44
105
  const targets = readTargetMap(dir);
45
106
  const entries = Object.entries(targets);
@@ -2313,24 +2374,24 @@ function readAll() {
2313
2374
  return {};
2314
2375
  }
2315
2376
  }
2316
- function loadCreds(url4) {
2317
- return readAll()[url4];
2377
+ function loadCreds(url5) {
2378
+ return readAll()[url5];
2318
2379
  }
2319
- function saveCreds(url4, creds) {
2380
+ function saveCreds(url5, creds) {
2320
2381
  const p = credsPath();
2321
2382
  mkdirSync(dirname(p), { recursive: true });
2322
2383
  const all = readAll();
2323
- all[url4] = creds;
2384
+ all[url5] = creds;
2324
2385
  writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
2325
2386
  try {
2326
2387
  chmodSync(p, 384);
2327
2388
  } catch {
2328
2389
  }
2329
2390
  }
2330
- function clearCreds(url4) {
2391
+ function clearCreds(url5) {
2331
2392
  const all = readAll();
2332
- if (!(url4 in all)) return false;
2333
- delete all[url4];
2393
+ if (!(url5 in all)) return false;
2394
+ delete all[url5];
2334
2395
  writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
2335
2396
  return true;
2336
2397
  }
@@ -2363,8 +2424,8 @@ async function registerClient(registrationEndpoint, redirectUri) {
2363
2424
  if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
2364
2425
  return (await res.json()).client_id;
2365
2426
  }
2366
- function openBrowser(url4) {
2367
- const [cmd, args] = process.platform === "darwin" ? ["open", [url4]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url4]] : ["xdg-open", [url4]];
2427
+ function openBrowser(url5) {
2428
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url5]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url5]] : ["xdg-open", [url5]];
2368
2429
  try {
2369
2430
  spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
2370
2431
  } catch {
@@ -2954,8 +3015,8 @@ window.addEventListener('message',async(e)=>{
2954
3015
  dash.title
2955
3016
  );
2956
3017
  }
2957
- function givensFromUrl(url4) {
2958
- return givensFromSearch(url4.search);
3018
+ function givensFromUrl(url5) {
3019
+ return givensFromSearch(url5.search);
2959
3020
  }
2960
3021
  function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
2961
3022
  const info = {
@@ -3000,7 +3061,7 @@ async function serveDashboard(opts) {
3000
3061
  let byName = new Map(dashboards.map((d) => [d.name, d]));
3001
3062
  const bundle = makeBundler();
3002
3063
  const inPageBundle = makeInPageBundler();
3003
- const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
3064
+ const pick = (url5) => byName.get(url5.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
3004
3065
  async function resolveGivens(dash) {
3005
3066
  if (dash.tiles && dash.entryFile) {
3006
3067
  const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
@@ -3034,15 +3095,15 @@ async function serveDashboard(opts) {
3034
3095
  }
3035
3096
  const handler = async (req, res) => {
3036
3097
  const onFramePort = (req.socket.localPort ?? port) === framePort;
3037
- const url4 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
3098
+ const url5 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
3038
3099
  const send = (code, type, body, extra = {}) => {
3039
3100
  res.writeHead(code, { "content-type": type, ...extra });
3040
3101
  res.end(body);
3041
3102
  };
3042
3103
  try {
3043
3104
  if (onFramePort) {
3044
- if (url4.pathname === "/frame") {
3045
- const dash = pick(url4);
3105
+ if (url5.pathname === "/frame") {
3106
+ const dash = pick(url5);
3046
3107
  const g = await resolveGivens(dash);
3047
3108
  if (!g.ok) {
3048
3109
  return send(
@@ -3051,22 +3112,22 @@ async function serveDashboard(opts) {
3051
3112
  html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
3052
3113
  );
3053
3114
  }
3054
- return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url4), g.tiles));
3115
+ return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url5), g.tiles));
3055
3116
  }
3056
- if (url4.pathname === "/bundle.js") {
3057
- return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
3117
+ if (url5.pathname === "/bundle.js") {
3118
+ return send(200, "application/javascript; charset=utf-8", await bundle(pick(url5)));
3058
3119
  }
3059
3120
  return send(404, "text/plain", "not found");
3060
3121
  }
3061
- if (url4.pathname === "/events") {
3122
+ if (url5.pathname === "/events") {
3062
3123
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
3063
3124
  res.write("retry: 1000\n\n");
3064
3125
  sseClients.add(res);
3065
3126
  req.on("close", () => sseClients.delete(res));
3066
3127
  return;
3067
3128
  }
3068
- if (url4.pathname === "/") {
3069
- const dash = pick(url4);
3129
+ if (url5.pathname === "/") {
3130
+ const dash = pick(url5);
3070
3131
  if (!dash.tsxPath) {
3071
3132
  const g = await resolveGivens(dash);
3072
3133
  if (!g.ok) {
@@ -3076,14 +3137,14 @@ async function serveDashboard(opts) {
3076
3137
  html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
3077
3138
  );
3078
3139
  }
3079
- return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url4), g.tiles));
3140
+ return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url5), g.tiles));
3080
3141
  }
3081
- return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url4)));
3142
+ return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url5)));
3082
3143
  }
3083
- if (url4.pathname === "/inpage.js") {
3144
+ if (url5.pathname === "/inpage.js") {
3084
3145
  return send(200, "application/javascript; charset=utf-8", await inPageBundle());
3085
3146
  }
3086
- if (url4.pathname === "/api/run" && req.method === "POST") {
3147
+ if (url5.pathname === "/api/run" && req.method === "POST") {
3087
3148
  const { d, query, malloy, givens } = JSON.parse(await readBody(req));
3088
3149
  const dash = byName.get(d);
3089
3150
  if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
@@ -3366,6 +3427,7 @@ async function bundleDashboards(opts = {}) {
3366
3427
  const outDir = path7.resolve(root, opts.out ?? "docs");
3367
3428
  const title = opts.title ?? path7.basename(root);
3368
3429
  const target = opts.target ?? "pages";
3430
+ const analytics = opts.analytics ?? readSiteConfig(root).analytics;
3369
3431
  const cleanUrls = target === "vercel";
3370
3432
  const selfHostDuckdb = opts.duckdb === "bundled";
3371
3433
  const runner = await makeRunner(root);
@@ -3502,9 +3564,9 @@ boot(Dashboard);
3502
3564
  if (!got.ok) throw new Error(`dashboard ${d.name}: ${got.error}`);
3503
3565
  specs = got.givens;
3504
3566
  }
3505
- fs6.writeFileSync(path7.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls, opts.analytics));
3567
+ fs6.writeFileSync(path7.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls, analytics));
3506
3568
  }
3507
- fs6.writeFileSync(path7.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls, opts.analytics));
3569
+ fs6.writeFileSync(path7.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls, analytics));
3508
3570
  if (landing) {
3509
3571
  await esbuild3.build({
3510
3572
  stdin: {
@@ -3542,6 +3604,7 @@ bundled ${dashboards.length} dashboard(s) \u2192 ${path7.relative(process.cwd(),
3542
3604
  selfHostDuckdb ? ` duckdb ${duck.length} assets (self-hosted)` : ` duckdb jsDelivr CDN (nothing copied)`
3543
3605
  );
3544
3606
  console.log(` model ${Object.keys(modelFiles).length} .malloy files inlined`);
3607
+ if (analytics) console.log(` analytics ${analytics}`);
3545
3608
  if (copiedData.length) {
3546
3609
  const mb = copiedData.reduce((a, r) => a + fs6.statSync(path7.join(root, r)).size, 0) / 1048576;
3547
3610
  console.log(` data ${copiedData.length} file(s) copied, ${mb.toFixed(1)} MB (same-origin)`);
@@ -3561,6 +3624,7 @@ Publish: deploy ${path7.basename(outDir)}/ to Vercel (vercel.json written: clean
3561
3624
  // src/init.ts
3562
3625
  import fs7 from "node:fs";
3563
3626
  import path8 from "node:path";
3627
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3564
3628
  var AUTHOR_MCP = {
3565
3629
  mcpServers: {
3566
3630
  // No -C: the server roots at the launch cwd (the project dir), so this file
@@ -3614,6 +3678,31 @@ function scaffoldIndex(root) {
3614
3678
  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"
3615
3679
  };
3616
3680
  }
3681
+ function installSkills(root) {
3682
+ const distDir = path8.dirname(fileURLToPath2(import.meta.url));
3683
+ const candidates = [
3684
+ path8.join(distDir, "templates", "skills"),
3685
+ path8.join(distDir, "..", "src", "templates", "skills")
3686
+ ];
3687
+ const srcSkills = candidates.find((p) => fs7.existsSync(p));
3688
+ if (!srcSkills) return { wrote: [], skipped: [], note: "no skill templates found \u2014 skipped" };
3689
+ const destSkills = path8.join(root, ".claude", "skills");
3690
+ fs7.mkdirSync(destSkills, { recursive: true });
3691
+ const wrote = [];
3692
+ const skipped = [];
3693
+ for (const name of fs7.readdirSync(srcSkills)) {
3694
+ const from = path8.join(srcSkills, name);
3695
+ if (!fs7.statSync(from).isDirectory()) continue;
3696
+ const to = path8.join(destSkills, name);
3697
+ if (fs7.existsSync(to)) {
3698
+ skipped.push(name);
3699
+ continue;
3700
+ }
3701
+ fs7.cpSync(from, to, { recursive: true });
3702
+ wrote.push(name);
3703
+ }
3704
+ return { wrote, skipped };
3705
+ }
3617
3706
  async function initCmd(dir) {
3618
3707
  const root = path8.resolve(dir);
3619
3708
  if (!fs7.existsSync(root) || !fs7.statSync(root).isDirectory()) {
@@ -3630,6 +3719,17 @@ async function initCmd(dir) {
3630
3719
  }
3631
3720
  const idx = scaffoldIndex(root);
3632
3721
  console.log(`${idx.wrote ? "\u2713" : "\u2022"} ${idx.note}`);
3722
+ const sk = installSkills(root);
3723
+ if (sk.note) {
3724
+ console.log(`\u2022 ${sk.note}`);
3725
+ } else {
3726
+ if (sk.wrote.length) {
3727
+ console.log(`\u2713 installed skill(s) into .claude/skills/: ${sk.wrote.join(", ")}`);
3728
+ }
3729
+ if (sk.skipped.length) {
3730
+ console.log(`\u2022 skill(s) already present \u2014 left as-is: ${sk.skipped.join(", ")}`);
3731
+ }
3732
+ }
3633
3733
  console.log("");
3634
3734
  console.log("Next:");
3635
3735
  console.log(" claude # author mode (mcp__malloyyo_author__* tools)");
@@ -3637,24 +3737,81 @@ async function initCmd(dir) {
3637
3737
  console.log(" malloyyo dashboard dev # see dashboards render in a browser");
3638
3738
  }
3639
3739
 
3740
+ // src/sql.ts
3741
+ import fs8 from "node:fs";
3742
+ import path9 from "node:path";
3743
+ import url4 from "node:url";
3744
+ import { MalloyConfig as MalloyConfig3, discoverConfig as discoverConfig3 } from "@malloydata/malloy";
3745
+ function fileReader() {
3746
+ return {
3747
+ readURL: async (u) => {
3748
+ if (u.protocol !== "file:") {
3749
+ throw new Error(`unsupported URL scheme for import: ${u.href}`);
3750
+ }
3751
+ return fs8.promises.readFile(u, "utf8");
3752
+ }
3753
+ };
3754
+ }
3755
+ async function loadConfig3(rootDir) {
3756
+ const rootUrl = url4.pathToFileURL(rootDir.endsWith(path9.sep) ? rootDir : rootDir + path9.sep);
3757
+ const discovered = await discoverConfig3(rootUrl, rootUrl, fileReader()).catch(() => null);
3758
+ return discovered ?? new MalloyConfig3({ includeDefaultConnections: true }, {
3759
+ rootDirectory: rootUrl.toString()
3760
+ });
3761
+ }
3762
+ async function readStdin() {
3763
+ const chunks = [];
3764
+ for await (const chunk of process.stdin) chunks.push(chunk);
3765
+ return Buffer.concat(chunks).toString("utf8");
3766
+ }
3767
+ async function resolveSql(opts) {
3768
+ if (opts.execute != null) return opts.execute;
3769
+ if (opts.file) return fs8.promises.readFile(opts.file, "utf8");
3770
+ return readStdin();
3771
+ }
3772
+ async function sqlCmd(connection, opts) {
3773
+ const name = connection ?? "duckdb";
3774
+ const rootDir = path9.resolve(opts.root ?? ".");
3775
+ const sql = (await resolveSql(opts)).trim();
3776
+ if (!sql) {
3777
+ throw new Error("no SQL provided \u2014 pass -e <sql>, -f <file>, or pipe it via stdin");
3778
+ }
3779
+ await import("@malloydata/malloy-connections");
3780
+ const cfg = await loadConfig3(rootDir);
3781
+ try {
3782
+ const conn = await cfg.connections.lookupConnection(name);
3783
+ const result = await conn.runSQL(sql);
3784
+ const rows = result?.rows ?? [];
3785
+ if (opts.json) {
3786
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
3787
+ } else if (rows.length === 0) {
3788
+ console.log(`ok \u2014 statement ran on connection "${name}" (no result rows)`);
3789
+ } else {
3790
+ console.table(rows);
3791
+ }
3792
+ } finally {
3793
+ await cfg.shutdown?.();
3794
+ }
3795
+ }
3796
+
3640
3797
  // src/launch.ts
3641
3798
  import { spawn as spawn2 } from "node:child_process";
3642
- import fs8 from "node:fs";
3799
+ import fs9 from "node:fs";
3643
3800
  import os from "node:os";
3644
- import path9 from "node:path";
3801
+ import path10 from "node:path";
3645
3802
  var SURFACE_FLAG = { author: "--develop", test: "--explore" };
3646
3803
  var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
3647
3804
  async function launchCmd(mode, opts) {
3648
- const root = path9.resolve(opts.root ?? process.cwd());
3649
- const tmpDir = fs8.mkdtempSync(path9.join(os.tmpdir(), "malloyyo-launch-"));
3650
- const cfgPath = path9.join(tmpDir, "mcp.json");
3805
+ const root = path10.resolve(opts.root ?? process.cwd());
3806
+ const tmpDir = fs9.mkdtempSync(path10.join(os.tmpdir(), "malloyyo-launch-"));
3807
+ const cfgPath = path10.join(tmpDir, "mcp.json");
3651
3808
  const cfg = {
3652
3809
  mcpServers: {
3653
3810
  // Absolute -C: an ephemeral config, so pinning the root is robust.
3654
3811
  [SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
3655
3812
  }
3656
3813
  };
3657
- fs8.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
3814
+ fs9.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
3658
3815
  const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
3659
3816
  process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
3660
3817
  `);
@@ -3673,11 +3830,11 @@ async function launchCmd(mode, opts) {
3673
3830
  });
3674
3831
  child.on("exit", () => resolve3());
3675
3832
  });
3676
- fs8.rmSync(tmpDir, { recursive: true, force: true });
3833
+ fs9.rmSync(tmpDir, { recursive: true, force: true });
3677
3834
  }
3678
3835
 
3679
3836
  // package.json
3680
- var version = "0.2.22";
3837
+ var version = "0.2.24";
3681
3838
 
3682
3839
  // src/index.ts
3683
3840
  function shortSha(sha) {
@@ -3778,13 +3935,20 @@ program.command("mcp").option("-C, --root <dir>", "project root (default: curren
3778
3935
  program.command("init").argument("[dir]", "model repo to set up", ".").description(
3779
3936
  "set up a model repo: write .mcp.json so `cd <repo> && claude` opens in author mode, and scaffold index.malloy if missing"
3780
3937
  ).action(initCmd);
3938
+ 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(
3939
+ "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"
3940
+ ).action(
3941
+ async (connection, opts) => {
3942
+ await sqlCmd(connection, opts);
3943
+ }
3944
+ );
3781
3945
  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) => {
3782
3946
  await launchCmd("author", opts);
3783
3947
  });
3784
3948
  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) => {
3785
3949
  await launchCmd("test", opts);
3786
3950
  });
3787
- program.command("dashboard").argument("<action>", "action to run (dev | bundle)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on (dev)", "4173").option("-o, --out <dir>", "output directory (bundle)", "docs").option("--title <title>", "site title (bundle; default: project directory name)").option("--target <target>", "deploy target: pages | vercel (bundle)", "pages").option("--duckdb <source>", "DuckDB binaries: cdn | bundled (bundle)", "cdn").option("--analytics <id>", "Google Analytics 4 Measurement ID, e.g. G-XXXXXXXXXX (bundle)").option("--no-serve", "bundle only; don't serve the result (bundle)").description("preview dashboards locally (dev), or build a static site from them (bundle)").action(
3951
+ program.command("dashboard").argument("<action>", "action to run (dev | bundle)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on (dev)", "4173").option("-o, --out <dir>", "output directory (bundle)", "docs").option("--title <title>", "site title (bundle; default: project directory name)").option("--target <target>", "deploy target: pages | vercel (bundle)", "pages").option("--duckdb <source>", "DuckDB binaries: cdn | bundled (bundle)", "cdn").option("--analytics <id>", "GA4 Measurement ID, overriding malloyyo.analytics in malloy-config.json (bundle)").option("--no-serve", "bundle only; don't serve the result (bundle)").description("preview dashboards locally (dev), or build a static site from them (bundle)").action(
3788
3952
  async (action, opts) => {
3789
3953
  if (action === "dev") {
3790
3954
  await serveDashboard({ root: opts.root, port: Number(opts.port) });
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: malloyyo-auto-update
3
+ description: Keep a malloyyo GitHub-Pages data site current automatically with a weekly GitHub Actions job — download from the source URL, transform, export parquet, commit. Use after a site is built (see malloyyo-data-site) when the data comes from a public URL that refreshes over time and you want the published site to track it with no manual steps. Worked example: malloydata/malloyyo-imdb.
4
+ ---
5
+
6
+ # Auto-update a data site weekly
7
+
8
+ Add a scheduled GitHub Actions job that rebuilds the data and commits it, so the
9
+ published Pages site stays current on its own. Use this **after** the site works
10
+ (built with `malloyyo-data-site`). If the data never changes, skip this.
11
+
12
+ The worked example is `malloydata/malloyyo-imdb` — fetch and read its
13
+ `scripts/build_data.sh` and `.github/workflows/refresh-data.yml`; adapt, don't
14
+ copy blindly.
15
+
16
+ ## Precondition: one command that rebuilds the data
17
+
18
+ The whole thing rests on a single script that turns the source URL(s) into the
19
+ committed parquet — the same script you'd run by hand. Keep it linear so it
20
+ reads as a recipe:
21
+
22
+ ```bash
23
+ # scripts/build_data.sh
24
+ set -euo pipefail
25
+ cd "$(dirname "$0")/.."
26
+ bash data/get.sh # 1. download source URLs -> data/
27
+ rm -f data/build.duckdb
28
+ malloy-cli -c malloy-build.json build transform.malloy # 2. transform
29
+ echo "ATTACH 'data/build.duckdb' AS b (READ_ONLY); -- 3. export -> docs/*.parquet
30
+ COPY b.thing TO 'docs/thing.parquet' (FORMAT parquet)" | malloyyo sql
31
+ ```
32
+
33
+ (If you used Path A — direct — the whole script is just the one
34
+ `echo "COPY (FROM read_..('https://…')) TO 'docs/…'" | malloyyo sql` command.
35
+ No `data/get.sh`, `malloy-build.json`, `malloy-cli`, or transform needed — and
36
+ no standalone `duckdb` either, since `malloyyo sql` uses the embedded one.)
37
+
38
+ Gitignore the working files: `data/*.gz`, `data/*.duckdb`, `MANIFESTS/`.
39
+
40
+ ## The workflow
41
+
42
+ `.github/workflows/refresh-data.yml`:
43
+
44
+ ```yaml
45
+ name: refresh-data
46
+ on:
47
+ schedule:
48
+ - cron: "0 6 * * 0" # weekly, Sunday 06:00 UTC
49
+ workflow_dispatch: # ...and on demand from the Actions tab
50
+ jobs:
51
+ refresh:
52
+ runs-on: ubuntu-latest
53
+ permissions:
54
+ contents: write # so the job can commit + push
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ - uses: actions/setup-node@v4
58
+ with: { node-version: "20" }
59
+ # malloyyo carries its own DuckDB (`malloyyo sql`), so no duckdb CLI to install.
60
+ # Add `@malloydata/cli` only for a `#@ persist` transform (malloy-cli build).
61
+ - run: npm install -g @malloydata/malloyyo @malloydata/cli
62
+ - uses: actions/setup-python@v5 # only if an enrichment step needs it
63
+ with: { python-version: "3.12" }
64
+ - name: Build data
65
+ run: bash scripts/build_data.sh
66
+ - name: Commit refreshed data
67
+ run: |
68
+ git config user.name github-actions
69
+ git config user.email github-actions@github.com
70
+ git add docs/*.parquet
71
+ git commit -m "refresh data $(date -u +%F)" || exit 0 # unchanged = clean no-op
72
+ git push
73
+ ```
74
+
75
+ ## Hard-won details (these came from a real first run)
76
+
77
+ - **`git commit … || exit 0`** — an unchanged week means nothing new upstream.
78
+ That is success, not a failed job.
79
+ - **Secondary/enrichment steps must be best-effort.** If the build has a step
80
+ that can fail independently (an external API lookup, an optional metadata
81
+ enrichment) and it isn't essential to the data, mark it
82
+ `continue-on-error: true`. Otherwise one flaky API call throws away an
83
+ otherwise-good data refresh — the commit step never runs and the fresh parquet
84
+ is lost. (In `malloyyo-imdb` this bit us: a poster-image lookup with a missing
85
+ secret failed the whole job until it was made non-blocking.)
86
+ - **Secrets** go in repo settings (Settings → Secrets and variables → Actions,
87
+ or `gh secret set NAME`), referenced as `${{ secrets.NAME }}`. A step reading
88
+ a secret that isn't set gets an empty string — pair that with best-effort.
89
+ - **No re-bundle needed for data changes.** The dashboards fetch parquet at
90
+ runtime, so committing fresh parquet updates the live site. Only install
91
+ `@malloydata/malloyyo` and run `malloyyo dashboard bundle` in CI if the job
92
+ also needs to regenerate the HTML (i.e. dashboard code changed — rare for a
93
+ data refresh).
94
+ - **Test before trusting the schedule.** Push, then Actions tab → the workflow →
95
+ **Run workflow** (that's `workflow_dispatch`). Or `gh workflow run
96
+ refresh-data.yml`, then `gh run watch <id> --exit-status`.
97
+
98
+ ## Git growth, and the flatten
99
+
100
+ Each committed refresh adds the full parquet to history (~its file size per
101
+ run). That is intentional simplicity, not a leak. When history gets too big,
102
+ flatten it to a single commit by hand — **not** as part of the weekly job:
103
+
104
+ ```bash
105
+ git checkout --orphan flat && git add -A && git commit -m "flatten history"
106
+ git branch -M flat main && git push -f origin main
107
+ ```
108
+
109
+ Only do this on a repo you solely own (it force-pushes; other clones must
110
+ `git reset --hard origin/main`). Run it as often or as rarely as you like.
111
+ Document this in the repo's README so future-you remembers it's available.
112
+
113
+ ## Done when
114
+
115
+ - The workflow file is on the default branch (so `workflow_dispatch` shows up)
116
+ - A manual run goes green end-to-end and commits fresh `docs/*.parquet`
117
+ - The Pages site shows the new data
@@ -0,0 +1,174 @@
1
+ ---
2
+ name: malloyyo-data-site
3
+ description: Turn a public data URL into a browsable, interactive dashboard site hosted on GitHub Pages, using Malloy (malloyyo). Use when someone points at data on the web (CSV/TSV/Parquet/JSON at an https URL) and wants a public web interface to explore it — scaffold the repo with `malloyyo init`, transform the data into parquet under docs/, write the Malloy model + dashboards, preview with `malloyyo dashboard dev`, build with `malloyyo dashboard bundle`, and publish on GitHub Pages. Worked examples: malloydata/malloyyo-babynames (base) and malloydata/malloyyo-imdb (adds a transform + weekly auto-update).
4
+ ---
5
+
6
+ # Build a public data site from a web data pointer
7
+
8
+ Goal: given one or more **public data URLs**, produce a **GitHub Pages site** of
9
+ interactive Malloy dashboards that anyone can open in a browser. The data ends
10
+ up as parquet in `docs/`; the dashboards are static HTML that query it
11
+ client-side with DuckDB-WASM — so there is no server, no database to run.
12
+
13
+ Two reference repos — read them, they are the ground truth for structure:
14
+
15
+ - **`malloydata/malloyyo-babynames`** — the clean base pattern (one model file,
16
+ a few dashboards, `docs/`). Data is static.
17
+ - **`malloydata/malloyyo-imdb`** — the same, plus a `transform.malloy` that
18
+ cleans raw source files into parquet, plus a **weekly auto-refresh** (that
19
+ part is the separate `malloyyo-auto-update` skill).
20
+
21
+ Fetch either with `gh api repos/<repo>/git/trees/HEAD?recursive=1` and read the
22
+ files you need. Match their layout rather than inventing your own.
23
+
24
+ ## The shape of a finished repo
25
+
26
+ ```
27
+ malloy-config.json connection(s): duckdb (local), optionally md/gs mirrors
28
+ index.malloy the EXPORT SURFACE — only what this file exports is live
29
+ <model>.malloy sources, measures, joins, givens (parameters)
30
+ storage.malloy sources point at the parquet — docs-local OR an https URL
31
+ (or gs.malloy / md.malloy) same source names, swappable hosting (step 3)
32
+ dashboards/*.malloy the query behind each dashboard
33
+ dashboards/*.jsx the dashboard layout (grid of charts/tables)
34
+ docs/ PUBLISHED SITE — bundled HTML (+ the *.parquet if docs-local)
35
+ *.parquet the data, when hosted docs-local (committed; served by Pages)
36
+ *.html .nojekyll written by `malloyyo dashboard bundle`
37
+ .mcp.json written by `malloyyo init` (author-mode Claude)
38
+ ```
39
+
40
+ ## Prerequisites (install once)
41
+
42
+ ```bash
43
+ npm install -g @malloydata/malloyyo # the `malloyyo` command
44
+ ```
45
+
46
+ That's the whole toolchain for the common case. `malloyyo` has **DuckDB built
47
+ in** — `malloyyo sql` runs SQL (read a URL, `COPY` to parquet) through it, so you
48
+ do **not** need a standalone `duckdb` CLI.
49
+
50
+ Add `@malloydata/cli` (`malloy-cli`) **only** if you do a heavier transform with
51
+ Malloy `#@ persist` (see Path B in `reference/data-to-parquet.md`):
52
+
53
+ ```bash
54
+ npm install -g @malloydata/cli # only for #@ persist transforms
55
+ ```
56
+
57
+ ## Recipe
58
+
59
+ Work top to bottom. After each step, prove it before moving on.
60
+
61
+ ### 1. Scaffold
62
+
63
+ ```bash
64
+ mkdir my-site && cd my-site && git init
65
+ malloyyo init # writes .mcp.json (author-mode Claude) + index.malloy stub
66
+ ```
67
+
68
+ Create `malloy-config.json` with a local DuckDB connection (this is what reads
69
+ the parquet):
70
+
71
+ ```json
72
+ { "connections": { "duckdb": { "is": "duckdb" } } }
73
+ ```
74
+
75
+ ### 2. Get the web data into `docs/*.parquet`
76
+
77
+ This is the one dataset-specific step. Two paths — pick the simpler one that
78
+ works. Full detail and copy-paste commands: **`reference/data-to-parquet.md`**.
79
+
80
+ - **Direct** (default): `malloyyo sql` reads the URL and writes parquet in one
81
+ shot, through the embedded DuckDB:
82
+ ```bash
83
+ echo "COPY (FROM read_csv_auto('https://…/thing.csv')) TO 'docs/thing.parquet'" | malloyyo sql
84
+ ```
85
+ Use when the web data is already close to what you want to show.
86
+ - **Transform** (when you need to clean / join / rank / reshape): write a
87
+ `transform.malloy` with `#@ persist` sources and build it with `malloy-cli`,
88
+ then export the tables to `docs/*.parquet`. This is the `malloyyo-imdb`
89
+ pattern.
90
+
91
+ Verify: `malloyyo sql -e "DESCRIBE SELECT * FROM 'docs/thing.parquet'"` and a
92
+ `SELECT count(*)`.
93
+
94
+ ### 3. Write the model
95
+
96
+ A storage file — one source per parquet file — that the rest of the model builds
97
+ on. **Where the parquet lives is a choice** (the two examples differ here):
98
+
99
+ - **docs-local** (the `malloyyo-imdb` way) — parquet committed in `docs/`,
100
+ served same-origin by Pages. Self-contained; git carries the data. Address it
101
+ by project-relative path so the same spelling works locally and published:
102
+ ```malloy
103
+ // storage.malloy
104
+ source: thing_table is duckdb.table('docs/thing.parquet') extend {}
105
+ ```
106
+ - **External URL** (the `malloyyo-babynames` way — it uses `import "gs.malloy"`)
107
+ — parquet hosted on GCS / a CDN / any https URL; `docs/` holds only the HTML,
108
+ so git stays small. DuckDB-WASM fetches the URL at runtime:
109
+ ```malloy
110
+ // gs.malloy
111
+ source: thing_table is duckdb.table('https://storage.googleapis.com/…/thing.parquet') extend {}
112
+ ```
113
+
114
+ Keep the **source names identical** across storage files (as babynames does with
115
+ `gs.malloy` / `md.malloy`) so switching hosting is a one-line import change in
116
+ the model. Start docs-local (simplest); move to a URL if git growth bites.
117
+
118
+ `<model>.malloy` — build real sources on top: primary keys, measures, joins,
119
+ and **givens** (the parameters that become the dashboard's filter controls).
120
+ `index.malloy` — re-export exactly the sources/queries/givens the dashboards
121
+ use. **Only what `index.malloy` exports is visible** to dashboards, `dashboard
122
+ dev`, and the hosted app.
123
+
124
+ For Malloy modeling and givens specifics, lean on the author MCP rather than
125
+ guessing: the repo's `.mcp.json` wires `mcp__malloyyo_author__*`. Call
126
+ `mcp__malloyyo_author__compile` to check files and
127
+ `mcp__malloyyo_author__yo_help` for topics (`develop/working-with-models`).
128
+
129
+ ### 4. Author dashboards and preview live
130
+
131
+ Each dashboard is a `.malloy` (the query/view) + a `.jsx` (the layout) under
132
+ `dashboards/`. Author them with the `malloyyo_author` MCP and its `yo_help`
133
+ topics — **read these, don't guess the JSX/grid API**:
134
+ `dashboards/authoring`, `dashboards/grid-layout`, `dashboards/vega-charts`.
135
+
136
+ Preview in a browser with live reload:
137
+
138
+ ```bash
139
+ malloyyo dashboard dev # serves at http://localhost:4173
140
+ ```
141
+
142
+ Iterate here until the dashboards look right. `malloyyo lint` validates the
143
+ dashboards against the model; `malloyyo test` previews what the hosted claude.ai
144
+ app would see.
145
+
146
+ ### 5. Build the static site
147
+
148
+ ```bash
149
+ malloyyo dashboard bundle --out docs
150
+ # optional: add analytics + a title
151
+ malloyyo dashboard bundle --out docs --title "My Site" --analytics G-XXXXXXXXXX
152
+ ```
153
+
154
+ `bundle` writes the HTML, `.nojekyll`, and assets into `docs/`, next to the
155
+ parquet from step 2. The pages fetch `./*.parquet` relative to the site root, so
156
+ the data must already be in `docs/` — always run the data step before bundling.
157
+
158
+ ### 6. Publish on GitHub Pages
159
+
160
+ Commit `docs/` and turn on Pages (Settings → Pages → Deploy from a branch →
161
+ `main` / `docs`). Full steps + gotchas: **`reference/publish-to-github-pages.md`**.
162
+
163
+ ### 7. (Optional) Keep it fresh automatically
164
+
165
+ If the data comes from a URL that updates over time and you want the site to
166
+ track it, add a weekly GitHub Actions refresh — see the **`malloyyo-auto-update`**
167
+ skill (worked example: `malloydata/malloyyo-imdb`).
168
+
169
+ ## Done when
170
+
171
+ - `malloyyo sql -e "SELECT count(*) FROM 'docs/<file>.parquet'"` returns real rows
172
+ - `malloyyo dashboard dev` renders every dashboard with no errors
173
+ - `docs/` has the bundled `*.html` + `.nojekyll` alongside the parquet
174
+ - the Pages URL loads and the dashboards populate (data fetch succeeds)
@@ -0,0 +1,109 @@
1
+ # Getting web data into `docs/*.parquet`
2
+
3
+ The site queries parquet files (in `docs/`, or at an https URL — see the model
4
+ step). This is how you turn a public data URL into that parquet. Pick the
5
+ simplest path that produces clean data.
6
+
7
+ `malloyyo sql` runs SQL through malloyyo's **embedded DuckDB** — no standalone
8
+ `duckdb` binary. It takes SQL from `-e`, `-f <file>`, or stdin, and runs against
9
+ a connection from `malloy-config.json` (default `duckdb`).
10
+
11
+ ## Path A — Direct (default)
12
+
13
+ One command reads the URL and writes parquet. Best when the web data is already
14
+ close to what you want to display.
15
+
16
+ ```bash
17
+ # CSV / TSV
18
+ echo "COPY (SELECT * FROM read_csv_auto('https://example.com/data.csv'))
19
+ TO 'docs/data.parquet' (FORMAT parquet)" | malloyyo sql
20
+
21
+ # TSV, gzipped, tab-delimited, header row (IMDb-style)
22
+ echo "COPY (SELECT * FROM read_csv_auto('https://example.com/data.tsv.gz',
23
+ delim='\t', header=true, all_varchar=true))
24
+ TO 'docs/data.parquet' (FORMAT parquet)" | malloyyo sql
25
+
26
+ # JSON / NDJSON
27
+ echo "COPY (SELECT * FROM read_json_auto('https://example.com/data.json'))
28
+ TO 'docs/data.parquet' (FORMAT parquet)" | malloyyo sql
29
+
30
+ # Already parquet on the web — just fetch it
31
+ curl -fsSL -o docs/data.parquet 'https://example.com/data.parquet'
32
+ ```
33
+
34
+ For anything longer than a line or two, put the SQL in a file and run it with
35
+ `-f`, so it's re-runnable and diffable:
36
+
37
+ ```bash
38
+ malloyyo sql -f scripts/build.sql # a file of ;-separated statements
39
+ ```
40
+
41
+ Notes:
42
+ - DuckDB autoloads `httpfs` for `https://` reads.
43
+ - Keep only the columns/rows the dashboards need — `SELECT` the columns and add a
44
+ `WHERE` to drop noise. Smaller parquet = faster page loads.
45
+ - Cast types here if the source is all-strings: `col::INT`, `col::DOUBLE`, etc.
46
+
47
+ ## Path B — Transform with Malloy (when you need to reshape)
48
+
49
+ Use when the data needs cleaning, joining across files, ranking, or nesting —
50
+ the `malloydata/malloyyo-imdb` case. You write the transform once in Malloy;
51
+ `malloy-cli build` materializes it; you export the tables to parquet. This path
52
+ also needs `@malloydata/cli` (`npm install -g @malloydata/cli`).
53
+
54
+ **1. A build connection** — `malloy-build.json` (a DuckDB db just for building):
55
+
56
+ ```json
57
+ { "connections": { "build": { "is": "duckdb", "databasePath": "data/build.duckdb" } } }
58
+ ```
59
+
60
+ **2. `transform.malloy`** — read the raw source(s) through the `build`
61
+ connection and mark each output source with `#@ persist name="…"`:
62
+
63
+ ```malloy
64
+ ##! experimental.persistence experimental.virtual_source
65
+
66
+ source: raw is build.sql("""
67
+ SELECT * FROM read_csv_auto('data/raw.csv.gz', delim='\t', all_varchar=true, header=true)
68
+ """)
69
+
70
+ #@ persist name="thing"
71
+ source: thing_base is raw -> {
72
+ where: some_count::number > 100
73
+ select: id, name, value is value::number
74
+ calculate: rank is rank() { order_by: value::number desc }
75
+ }
76
+ ```
77
+
78
+ Download the raw files first (a `data/get.sh` that `wget`s the URLs into `data/`,
79
+ gitignored). Gitignore `data/`, `data/*.duckdb`, and `MANIFESTS/`.
80
+
81
+ **3. Build, then export the persisted tables to `docs/`** — the export still
82
+ goes through `malloyyo sql` (no standalone duckdb needed):
83
+
84
+ ```bash
85
+ rm -f data/build.duckdb
86
+ malloy-cli -c malloy-build.json build transform.malloy # -> tables in build.duckdb
87
+ echo "ATTACH 'data/build.duckdb' AS b (READ_ONLY);
88
+ COPY b.thing TO 'docs/thing.parquet' (FORMAT parquet)" | malloyyo sql
89
+ ```
90
+
91
+ The persist `name=` is the table name inside `build.duckdb`; the `COPY` names the
92
+ served file (they can differ — e.g. `thing` → `docs/mysite_thing.parquet`).
93
+
94
+ Wrap steps 1–3 in a single `scripts/build_data.sh` so it is one command, by hand
95
+ and in CI. That's exactly what `malloyyo-auto-update` automates weekly.
96
+
97
+ ## Either way, verify
98
+
99
+ ```bash
100
+ malloyyo sql -e "DESCRIBE SELECT * FROM 'docs/thing.parquet'"
101
+ malloyyo sql -e "SELECT count(*) FROM 'docs/thing.parquet'"
102
+ ```
103
+
104
+ Then point a storage source at it (docs-local shown; an https URL works too —
105
+ see the model step in SKILL.md):
106
+
107
+ ```malloy
108
+ source: thing_table is duckdb.table('docs/thing.parquet') extend {}
109
+ ```
@@ -0,0 +1,61 @@
1
+ # Publishing the site on GitHub Pages
2
+
3
+ The published site is just the `docs/` directory: bundled HTML plus the parquet
4
+ it queries. GitHub Pages serves it directly — no build step on GitHub's side.
5
+
6
+ ## One-time setup
7
+
8
+ ```bash
9
+ # from the repo root, after `malloyyo dashboard bundle --out docs`
10
+ gh repo create <owner>/<name> --public --source=. --remote=origin # or an existing repo
11
+ git add -A
12
+ git commit -m "initial data site"
13
+ git push -u origin main
14
+ ```
15
+
16
+ Turn on Pages, pointing at the `docs/` folder on the default branch:
17
+
18
+ ```bash
19
+ gh api -X POST repos/<owner>/<name>/pages \
20
+ -f 'source[branch]=main' -f 'source[path]=/docs'
21
+ ```
22
+
23
+ (Or in the UI: Settings → Pages → Source: **Deploy from a branch** → Branch
24
+ `main`, folder `/docs`.)
25
+
26
+ The site appears at `https://<owner>.github.io/<name>/` within a minute or two.
27
+
28
+ ## Why `docs/` and why `.nojekyll`
29
+
30
+ - Serving from `/docs` on the main branch keeps the data committed **once**
31
+ (it's the same directory you build into), not duplicated on a separate branch.
32
+ - `malloyyo dashboard bundle` writes a `.nojekyll` file so Pages serves the
33
+ bundled assets as-is instead of running them through Jekyll (which would drop
34
+ files beginning with `_`). Keep it committed.
35
+
36
+ ## Updating the site
37
+
38
+ Re-run the data step and the bundle, then commit `docs/`:
39
+
40
+ ```bash
41
+ bash scripts/build_data.sh # or your Path-A duckdb command → docs/*.parquet
42
+ malloyyo dashboard bundle --out docs
43
+ git add docs && git commit -m "update" && git push
44
+ ```
45
+
46
+ Because the dashboards fetch the parquet client-side, **committing fresh parquet
47
+ is enough to update the live data** — you only need to re-`bundle` when you
48
+ change dashboard code. To automate the data refresh weekly, see the
49
+ `malloyyo-auto-update` skill.
50
+
51
+ ## Gotchas
52
+
53
+ - **Data must be in `docs/` before you bundle** — the pages fetch `./*.parquet`
54
+ relative to the site root; `bundle` also reads the parquet to get schemas.
55
+ - **Big parquet grows git history.** Each committed refresh adds the full file.
56
+ Fine for occasional updates; for frequent auto-refresh, see the flatten note
57
+ in `malloyyo-auto-update`.
58
+ - **Git LFS does not work with Pages "deploy from a branch"** — Pages serves the
59
+ LFS pointer text, not the file. Commit parquet as normal git objects.
60
+ - **Private repos**: Pages on private repos needs a paid plan. Use a public repo
61
+ for a public site.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {