Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<br>
`/#/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)<br>
`/#/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`)
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent terminology between README and generator. This line says "sources" but the implementation in Schema.tsx uses "expandedExplores" and the generator.ts says "expanded explores" (line 231). Consider changing "sources" to "explores" here to match the actual implementation and be consistent with the llms.txt documentation.

Suggested change
- **`expanded`** - Comma-separated list of sources to expand (e.g., `?expanded=users,orders`)
- **`expanded`** - Comma-separated list of explores to expand (e.g., `?expanded=users,orders`)

Copilot uses AI. Check for mistakes.

### 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`
Expand Down
3 changes: 3 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ const config: PlaywrightTestConfig = defineConfig({
? "http://localhost:3000"
: "http://localhost:5173",
reuseExistingServer: !process.env["CI"],
env: {
SITE_URL: "http://localhost:5173",
},
},
}),
});
Expand Down
21 changes: 17 additions & 4 deletions plugins/vite-plugin-llms-txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the default value {} from the options parameter makes this a breaking API change. Previously, calling llmsTxtPlugin() without arguments was valid, but now it will cause a TypeScript error since options.siteUrl is required. Consider either keeping the default empty object and making siteUrl optional (with runtime validation), or document this as a breaking change if intentional.

Copilot uses AI. Check for mistakes.
const {
siteTitle = "Malloy Data Explorer",
modelsDir = "models",
siteUrl,
} = options;

let config: ResolvedConfig;

async function generateContent(): Promise<string> {
// 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([
Expand All @@ -41,6 +53,7 @@ export default function llmsTxtPlugin(
return generateLlmsTxtContent({
siteTitle,
basePath: config.base,
siteUrl,
models,
dataFiles,
notebooks,
Expand Down
126 changes: 97 additions & 29 deletions src/llms-txt/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generateModelsSection function uses relative URLs (basePath only) for model Browse and Download links, while the header now shows the full absolute "Site URL". For consistency and to help LLMs better understand the URLs, consider updating generateModelsSection to also use full absolute URLs by passing siteUrl to it and using the same URL construction pattern as in generateHeader and generateOverview.

Suggested change
generateModelsSection(models, basePath),
generateModelsSection(models, basePath, siteUrl),

Copilot uses AI. Check for mistakes.
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}/`;
Comment on lines +40 to +46
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new URL construction logic and siteUrl integration lack test coverage. Given that the repository has comprehensive unit tests for other utilities (download-utils, notebook-parser, schema-utils), consider adding tests for the generator module to verify URL construction works correctly with various siteUrl and basePath combinations, especially edge cases like trailing slashes and empty basePath.

Copilot uses AI. Check for mistakes.
Comment on lines 45 to +46
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL construction logic that removes trailing slashes from both siteUrl and basePath is duplicated across generateHeader (lines 45-46) and generateOverview (lines 63-64). Consider extracting this into a shared utility function like buildFullUrl(siteUrl: string, basePath: string): string to reduce duplication and ensure consistency.

Copilot uses AI. Check for mistakes.
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
? [
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
54 changes: 38 additions & 16 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
/// <reference types="vitest/config" />
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<typeof defineConfig> = 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;
Loading