@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 +208 -44
- 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
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(standalone, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
19
76
|
}
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
42
|
-
return { name:
|
|
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(
|
|
2317
|
-
return readAll()[
|
|
2377
|
+
function loadCreds(url5) {
|
|
2378
|
+
return readAll()[url5];
|
|
2318
2379
|
}
|
|
2319
|
-
function saveCreds(
|
|
2380
|
+
function saveCreds(url5, creds) {
|
|
2320
2381
|
const p = credsPath();
|
|
2321
2382
|
mkdirSync(dirname(p), { recursive: true });
|
|
2322
2383
|
const all = readAll();
|
|
2323
|
-
all[
|
|
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(
|
|
2391
|
+
function clearCreds(url5) {
|
|
2331
2392
|
const all = readAll();
|
|
2332
|
-
if (!(
|
|
2333
|
-
delete all[
|
|
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(
|
|
2367
|
-
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]];
|
|
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(
|
|
2958
|
-
return givensFromSearch(
|
|
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 = (
|
|
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
|
|
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 (
|
|
3045
|
-
const dash = pick(
|
|
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(
|
|
3115
|
+
return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url5), g.tiles));
|
|
3055
3116
|
}
|
|
3056
|
-
if (
|
|
3057
|
-
return send(200, "application/javascript; charset=utf-8", await bundle(pick(
|
|
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 (
|
|
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 (
|
|
3069
|
-
const dash = pick(
|
|
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(
|
|
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(
|
|
3142
|
+
return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url5)));
|
|
3082
3143
|
}
|
|
3083
|
-
if (
|
|
3144
|
+
if (url5.pathname === "/inpage.js") {
|
|
3084
3145
|
return send(200, "application/javascript; charset=utf-8", await inPageBundle());
|
|
3085
3146
|
}
|
|
3086
|
-
if (
|
|
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,
|
|
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,
|
|
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
|
|
3799
|
+
import fs9 from "node:fs";
|
|
3643
3800
|
import os from "node:os";
|
|
3644
|
-
import
|
|
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 =
|
|
3649
|
-
const tmpDir =
|
|
3650
|
-
const cfgPath =
|
|
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
|
-
|
|
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
|
-
|
|
3833
|
+
fs9.rmSync(tmpDir, { recursive: true, force: true });
|
|
3677
3834
|
}
|
|
3678
3835
|
|
|
3679
3836
|
// package.json
|
|
3680
|
-
var version = "0.2.
|
|
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>", "
|
|
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.
|