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;