diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index da492db..e1caab9 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -65,18 +65,25 @@ jobs: if: github.ref == 'refs/heads/master' needs: test runs-on: ubuntu-latest + permissions: + contents: read + pages: read steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: lts/* + - name: Setup Pages + id: pages + uses: actions/configure-pages@v6 - name: Install dependencies run: npm ci - name: Build website run: npm run build - # For gh pages deployment, base path is repository name + # GitHub Pages configuration (supports custom domains) env: - BASE_PUBLIC_PATH: /${{ github.event.repository.name }}/ + BASE_PUBLIC_PATH: ${{ steps.pages.outputs.base_path }}/ + SITE_URL: ${{ steps.pages.outputs.base_url }} - name: Upload Build Artifact # only run on pushes to master or workflow_dispatch diff --git a/README.md b/README.md index 2e6429d..47a7730 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,35 @@ sharing a link. A notebook is a sequence of markdown and Malloy results that are rendered together in a single page to explain data. +## URL Query Parameters + +Control the UI and behavior using URL query parameters: + +### Explorer (`/#/model/{model}/explorer/{source}`) + +- **`query`** - Malloy query string (URL-encoded) +- **`run=true`** - Auto-execute the query on page load +- **`includeTopValues=true`** - Load top 10 values for field autocomplete (slower) +- **`showQueryPanel=true`** - Expand query editor panel +- **`showSourcePanel=true`** - Expand source/schema panel + +**Examples:** + +- [Run custom query](https://aszenz.github.io/data-explorer/#/model/sales_orders/explorer/sales_orders?query=run%3A%20sales_orders%20-%3E%20%7B%20select%3A%20*%20limit%3A%2010%20%7D&run=true)
+ `/#/model/sales_orders/explorer/sales_orders?query=run%3A%20sales_orders%20-%3E%20%7B%20select%3A%20*%20limit%3A%2010%20%7D&run=true` + +- [Open with panels expanded](https://aszenz.github.io/data-explorer/#/model/sales_orders/explorer/sales_orders?showQueryPanel=true&showSourcePanel=true)
+ `/#/model/sales_orders/explorer/sales_orders?showQueryPanel=true&showSourcePanel=true` + +### Model Schema (`/#/model/{model}`) + +- **`tab`** - Active tab name +- **`expanded`** - Comma-separated list of sources to expand (e.g., `?expanded=users,orders`) + +### Notebook (`/#/notebook/{notebook}`) + +- **`cell-expanded`** - Cell index to show fullscreen (e.g., `?cell-expanded=2`) + ## Publishing your own Malloy models and notebooks 1. Clone the repo `git clone https://github.com/aszenz/data-explorer.git` diff --git a/playwright.config.ts b/playwright.config.ts index 4c20331..c07bc15 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -91,6 +91,9 @@ const config: PlaywrightTestConfig = defineConfig({ ? "http://localhost:3000" : "http://localhost:5173", reuseExistingServer: !process.env["CI"], + env: { + SITE_URL: "http://localhost:5173", + }, }, }), }); diff --git a/plugins/vite-plugin-llms-txt.ts b/plugins/vite-plugin-llms-txt.ts index 7556782..b5de04b 100644 --- a/plugins/vite-plugin-llms-txt.ts +++ b/plugins/vite-plugin-llms-txt.ts @@ -20,16 +20,28 @@ import { export interface LlmsTxtPluginOptions { siteTitle?: string; modelsDir?: string; + siteUrl: string; } -export default function llmsTxtPlugin( - options: LlmsTxtPluginOptions = {}, -): Plugin { - const { siteTitle = "Malloy Data Explorer", modelsDir = "models" } = options; +export default function llmsTxtPlugin(options: LlmsTxtPluginOptions): Plugin { + const { + siteTitle = "Malloy Data Explorer", + modelsDir = "models", + siteUrl, + } = options; let config: ResolvedConfig; async function generateContent(): Promise { + // Validate siteUrl is provided + if (!siteUrl || siteUrl.trim() === "") { + throw new Error( + "[llms.txt] SITE_URL environment variable is required. " + + "Set it in your build command or CI/CD workflow. " + + "Example: SITE_URL=https://example.com npm run build", + ); + } + const modelsDirPath = path.join(config.root, modelsDir); const [models, dataFiles, notebooks] = await Promise.all([ @@ -41,6 +53,7 @@ export default function llmsTxtPlugin( return generateLlmsTxtContent({ siteTitle, basePath: config.base, + siteUrl, models, dataFiles, notebooks, diff --git a/src/llms-txt/generator.ts b/src/llms-txt/generator.ts index 24e0f63..d730606 100644 --- a/src/llms-txt/generator.ts +++ b/src/llms-txt/generator.ts @@ -9,46 +9,63 @@ import type { ExtractedModel } from "./types"; export interface GeneratorOptions { siteTitle: string; basePath: string; + siteUrl: string; models: ExtractedModel[]; dataFiles: string[]; notebooks: string[]; } export function generateLlmsTxtContent(options: GeneratorOptions): string { - const { siteTitle, basePath, models, dataFiles, notebooks } = options; + const { siteTitle, basePath, siteUrl, models, dataFiles, notebooks } = + options; const sections = [ - generateHeader(siteTitle, basePath), - generateOverview(siteTitle, basePath, models, dataFiles, notebooks), + generateHeader(siteTitle, basePath, siteUrl), + generateOverview( + siteTitle, + basePath, + siteUrl, + models, + dataFiles, + notebooks, + ), generateModelsSection(models, basePath), + generateQueryParametersSection(), generateMalloyQueryGuide(), ]; return sections.join("\n\n"); } -function generateHeader(siteTitle: string, basePath: string): string { +function generateHeader( + siteTitle: string, + basePath: string, + siteUrl: string, +): string { const base = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + const fullUrl = `${siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl}${base}/`; return `# ${siteTitle} > Malloy Data Explorer - Static web app for exploring semantic data models > All queries run in-browser using DuckDB WASM -**Site URL:** \`${base}/\``; +**Site URL:** \`${fullUrl}\``; } function generateOverview( _siteTitle: string, basePath: string, + siteUrl: string, models: ExtractedModel[], dataFiles: string[], notebooks: string[], ): string { const base = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + const fullBase = `${siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl}${base}`; // Content summary const contentItems = [ - `${String(models.length)} Malloy model${models.length !== 1 ? "s" : ""}`, + `${String(models.length)} model${models.length !== 1 ? "s" : ""}`, `${String(dataFiles.length)} data file${dataFiles.length !== 1 ? "s" : ""}`, ...(notebooks.length > 0 ? [ @@ -57,35 +74,64 @@ function generateOverview( : []), ]; - // Data files list (compact) - const dataFilesList = - dataFiles.length > 0 ? `\n\n**Data Files:** ${dataFiles.join(", ")}` : ""; - - // Notebooks list (compact - just names) - const notebooksList = - notebooks.length > 0 ? `\n\n**Notebooks:** ${notebooks.join(", ")}` : ""; + // Pick first model with data for examples + const exampleModel = models.find((m) => m.sources.length > 0 && m.sources[0]); + let examples = ""; + if (exampleModel?.sources[0]) { + const modelName = encodeURIComponent(exampleModel.name); + const source = exampleModel.sources[0]; + const sourceName = encodeURIComponent(source.name); + + const examplesList = []; + + // Named query example + if (exampleModel.queries.length > 0 && exampleModel.queries[0]) { + const queryName = encodeURIComponent(exampleModel.queries[0].name); + examplesList.push( + `\`${fullBase}/#/model/${modelName}/query/${queryName}\` - Named query`, + ); + } + + // View example + if (source.views.length > 0 && source.views[0]) { + const viewQuery = encodeURIComponent( + `run: ${source.name} -> ${source.views[0].name}`, + ); + examplesList.push( + `\`${fullBase}/#/model/${modelName}/explorer/${sourceName}?query=${viewQuery}&run=true\` - Run view`, + ); + } + + // Custom query example + const customQuery = encodeURIComponent( + `run: ${source.name} -> { select: * limit: 10 }`, + ); + examplesList.push( + `\`${fullBase}/#/model/${modelName}/explorer/${sourceName}?query=${customQuery}&run=true\` - Custom query`, + ); + + if (examplesList.length > 0) { + examples = `\n\n**Example URLs:**\n${examplesList.join("\n")}`; + } + } return `## Overview -**Content:** ${contentItems.join(" • ")} -**Capabilities:** Browse schemas • Preview data • Build queries • Download results (CSV/JSON)${dataFilesList}${notebooksList} +**Content:** ${contentItems.join(" • ")}${examples} ## URL Patterns -All URLs with \`/#/\` prefix return HTML pages. \`/downloads/\` URLs return raw files. - -| Pattern | Returns | Description | -|---------|---------|-------------| -| \`${base}/#/\` | HTML | Home - list all models | -| \`${base}/#/model/{model}\` | HTML | Model schema browser | -| \`${base}/#/model/{model}/preview/{source}\` | HTML | Preview source data (50 rows) | -| \`${base}/#/model/{model}/explorer/{source}\` | HTML | Interactive query builder | -| \`${base}/#/model/{model}/explorer/{source}?query={malloy}&run=true\` | HTML | Execute query, show results | -| \`${base}/#/model/{model}/query/{queryName}\` | HTML | Run named query, show results | -| \`${base}/#/notebook/{notebook}\` | HTML | View notebook with queries/visualizations | -| \`${base}/downloads/models/{model}.malloy\` | Text | Download model source file | -| \`${base}/downloads/notebooks/{notebook}.malloynb\` | Text | Download notebook file | -| \`${base}/downloads/data/{file}\` | File | Download data file (CSV/Parquet/JSON/Excel) |`; +| Pattern | Description | +|---------|-------------| +| \`/#/\` | Home - list all models | +| \`/#/model/{model}\` | Model schema | +| \`/#/model/{model}/preview/{source}\` | Preview data (50 rows) | +| \`/#/model/{model}/explorer/{source}\` | Interactive query builder | +| \`/#/model/{model}/explorer/{source}?query={malloy}&run=true\` | Execute query | +| \`/#/model/{model}/query/{queryName}\` | Run named query | +| \`/#/notebook/{notebook}\` | View notebook | +| \`/downloads/models/{model}.malloy\` | Download model file | +| \`/downloads/data/{file}\` | Download data file |`; } function generateModelsSection( @@ -164,6 +210,28 @@ ${sourceSections}`; ${modelSections.join("\n\n---\n\n")}`; } +function generateQueryParametersSection(): string { + return `## URL Query Parameters + +Control UI behavior with these query parameters: + +**Explorer (\`/model/{model}/explorer/{source}\`):** +- \`query\` - Malloy query string (URL-encoded) +- \`run=true\` - Auto-execute the query +- \`includeTopValues=true\` - Load field top values +- \`showQueryPanel=true\` - Expand query panel +- \`showSourcePanel=true\` - Expand source/schema panel + +**Model Schema (\`/model/{model}\`):** +- \`tab\` - Active tab name +- \`expanded\` - Comma-separated list of expanded explores + +**Notebook (\`/notebook/{notebook}\`):** +- \`cell-expanded\` - Index of fullscreen cell + +**Note:** Malloy queries must be URL-encoded. Space becomes \`%20\`, \`:\` becomes \`%3A\`, etc.`; +} + function generateMalloyQueryGuide(): string { return `## Malloy Query Syntax diff --git a/vite.config.ts b/vite.config.ts index 68616ac..acaabb5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,47 @@ /// -import { defineConfig, type UserConfig } from "vite"; +import { defineConfig } from "vite"; +import type { UserConfigFnObject } from "vite"; import react from "@vitejs/plugin-react"; import svgr from "vite-plugin-svgr"; import copyDownloadsPlugin from "./plugins/vite-plugin-copy-downloads"; import llmsTxtPlugin from "./plugins/vite-plugin-llms-txt"; // https://vite.dev/config/ -const config: UserConfig = defineConfig({ - // NOTE: THIS PATH MUST END WITH A TRAILING SLASH - base: process.env["BASE_PUBLIC_PATH"] ?? "/", - plugins: [react(), svgr(), copyDownloadsPlugin(), llmsTxtPlugin()], - define: { - "process.env": {}, - }, - optimizeDeps: { - esbuildOptions: { - target: "esnext", +const config: ReturnType = defineConfig((({ mode }) => { + const siteUrl = + process.env["SITE_URL"] ?? + (mode === "development" ? "http://localhost:5173" : null); + + if (null === siteUrl) { + throw new Error( + "SITE_URL environment variable is required for production builds. " + + "Set it in your build command or CI/CD workflow.", + ); + } + + return { + // NOTE: THIS PATH MUST END WITH A TRAILING SLASH + base: process.env["BASE_PUBLIC_PATH"] ?? "/", + plugins: [ + react(), + svgr(), + copyDownloadsPlugin(), + llmsTxtPlugin({ + siteUrl, + }), + ], + define: { + "process.env": {}, + }, + optimizeDeps: { + esbuildOptions: { + target: "esnext", + }, }, - }, - test: { - include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], - }, -}); + test: { + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], + }, + }; +}) satisfies UserConfigFnObject); + export default config;