diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4370677d..90011103a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,16 +288,6 @@ jobs: - name: Verify bundled SDKs in both template locks run: pnpm exec tsx tools/check-template-artifact.ts pr-template - # The scaffold's own vitest suite, run against the tarballs built above - # rather than the published versions template/package.json pins. This is - # the only CI check that exercises the template's example test, and the - # only one that consumes @databricks/appkit/testing the way a customer - # does — through a real npm install of a packed tarball. Before the zip, - # so a broken example fails the build instead of shipping. - - name: Run the template's own tests - working-directory: pr-template - run: npm test - # npm install above runs under the JFrog .npmrc (setup-jfrog-npm), which # bakes internal registry URLs into the regenerated locks. Rewrite them back # to public npm and fail-closed if any non-public registry remains, so the @@ -312,6 +302,20 @@ jobs: working-directory: pr-template run: zip -r "../appkit-template-${{ steps.version.outputs.version }}.zip" . -x 'node_modules/*' + # The scaffold's own vitest suite, run against the tarballs built above + # rather than the published versions template/package.json pins. This is + # the only CI check that exercises the template's example test, and the + # only one that consumes @databricks/appkit/testing the way a customer + # does — through a real npm install of a packed tarball. The example test + # is wrapped in a Go template conditional (`.skipExamples` leaves it + # out), so render it first: after the zip, which must keep the + # conditional, and before the upload, so a broken example fails the build + # instead of shipping. + - name: Run the template's own tests + run: | + pnpm exec tsx tools/template-render.ts pr-template/server/example.test.ts + cd pr-template && npm test + - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/docs/docs/development/templates.md b/docs/docs/development/templates.md index ac71a1617..88dbdc1cc 100644 --- a/docs/docs/development/templates.md +++ b/docs/docs/development/templates.md @@ -30,6 +30,7 @@ Files named with a `_` prefix are renamed to `.` prefix (e.g. `_gitignore` → ` | `.dotEnv.example` | Generated `.env.example` content with placeholders | | `.bundle.*` | Generated `databricks.yml` sections (variables, resources, target variables) | | `.appEnv` | Generated `app.yaml` env entries | +| `.skipExamples` | When `true`, the template leaves out its example queries (`config/queries/hello_world.sql`, `mocked_sales.sql`) and example test (`server/example.test.ts`), and the Analytics page reads no queries. Meant for app builders that replace the starter files. No released Databricks CLI sets it yet (a `databricks apps init` flag for it is planned), so it's unset and the examples are kept. Test it as `{{if not .skipExamples}}` so an unset variable keeps them. | ### Conditional content diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index d811f061d..63e016105 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -31,7 +31,7 @@ "Before init, ensure the SQL Warehouse passed via --set analytics.sql-warehouse.id is running" ], "should": [ - "After init, ensure config/queries/ has at least one .sql file before running npm run typegen" + "Add .sql files to config/queries/ only for queries the app runs; typegen and build work without the folder" ] } }, diff --git a/template/README.md.tmpl b/template/README.md.tmpl index c6564cb81..bfa330f07 100644 --- a/template/README.md.tmpl +++ b/template/README.md.tmpl @@ -152,6 +152,49 @@ There are a few commands to help you with code quality: {{or .packageManager "pnpm"}} run format {{or .packageManager "pnpm"}} run format:fix ``` +{{- if .plugins.analytics}} + +## SQL Queries + +The Analytics plugin runs the `.sql` files in `config/queries/`. The file name is the query key: + +- `config/queries/.sql` runs as the app's service principal, with one cache shared by all users +- `config/queries/.obo.sql` runs as the signed-in user, with a cache per user + +Declare each parameter in a `-- @param ` comment and reference it as `:name`: + +```sql +-- @param max_month INT +SELECT month, revenue +FROM my_catalog.my_schema.sales +WHERE month_num <= :max_month +ORDER BY month_num; +``` + +Read the result in the client with `useAnalyticsQuery`, or pass the query key to a chart: + +```tsx +import { AreaChart, useAnalyticsQuery } from '@databricks/appkit-ui/react'; +import { sql } from '@databricks/appkit-ui/js'; + +const params = { max_month: sql.number(6) }; +const { data, loading, error } = useAnalyticsQuery('sales', params); + +; +``` + +`{{or .packageManager "pnpm"}} run typegen` generates the result types of every query into `shared/appkit-types/`. See the [Analytics plugin documentation](https://developers.databricks.com/docs/appkit/v0/plugins/analytics). +{{- end}} + +## Testing + +`{{or .packageManager "pnpm"}} test` runs the [Vitest](https://vitest.dev/) suite. `@databricks/appkit/testing` tests plugins with no Databricks workspace, credentials or network: + +- `createTestApp({ plugins })` boots the app on an ephemeral port so you can call its routes over HTTP. Call `close()` when done. +- `createTestPluginContext()` attaches a plugin to a real context with faked edges, for unit tests without a server. +- `expectStream(source).toEmit(...)` asserts the ordered events of a streaming handler. + +See the [testing documentation](https://developers.databricks.com/docs/appkit/v0/plugins/testing). ## Deployment with Databricks Asset Bundles diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index e32eb3ccb..7c01917a2 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -196,7 +196,7 @@ "Before init, ensure the SQL Warehouse passed via --set analytics.sql-warehouse.id is running" ], "should": [ - "After init, ensure config/queries/ has at least one .sql file before running npm run typegen" + "Add .sql files to config/queries/ only for queries the app runs; typegen and build work without the folder" ] } } diff --git a/template/client/src/pages/analytics/AnalyticsPage.tsx b/template/client/src/pages/analytics/AnalyticsPage.tsx index e1ca7a9df..ddd3e6755 100644 --- a/template/client/src/pages/analytics/AnalyticsPage.tsx +++ b/template/client/src/pages/analytics/AnalyticsPage.tsx @@ -1,4 +1,23 @@ {{if .plugins.analytics -}} +{{if .skipExamples -}} +import { Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; + +export function AnalyticsPage() { + return ( +
+ + + Analytics + + + No queries yet. Add a SQL file such as config/queries/my_query.sql and read it with{' '} + {"useAnalyticsQuery('my_query')"}. + + +
+ ); +} +{{- else -}} import { useAnalyticsQuery, AreaChart, @@ -106,3 +125,4 @@ export function AnalyticsPage() { ); } {{- end}} +{{- end}} diff --git a/template/config/queries/hello_world.sql b/template/config/queries/hello_world.sql index a09ef265a..ae8f13159 100644 --- a/template/config/queries/hello_world.sql +++ b/template/config/queries/hello_world.sql @@ -1,4 +1,4 @@ -{{if .plugins.analytics -}} +{{if and .plugins.analytics (not .skipExamples) -}} -- @param message STRING SELECT :message AS value; {{- end}} diff --git a/template/config/queries/mocked_sales.sql b/template/config/queries/mocked_sales.sql index bab03dd7e..4d19a15c7 100644 --- a/template/config/queries/mocked_sales.sql +++ b/template/config/queries/mocked_sales.sql @@ -1,4 +1,4 @@ -{{if .plugins.analytics -}} +{{if and .plugins.analytics (not .skipExamples) -}} -- @param max_month_num INT WITH mock_data AS ( SELECT 'January' AS month, 1 AS month_num, 65000 AS revenue, 45000 AS expenses, 850 AS customers diff --git a/template/server/example.test.ts b/template/server/example.test.ts index 535f800fd..80894aa14 100644 --- a/template/server/example.test.ts +++ b/template/server/example.test.ts @@ -1,3 +1,4 @@ +{{if not .skipExamples -}} import { Plugin, type PluginManifest, toPlugin } from '@databricks/appkit'; import { createTestApp, createTestPluginContext, expectStream } from '@databricks/appkit/testing'; import { describe, expect, test } from 'vitest'; @@ -34,15 +35,17 @@ class GreeterPlugin extends Plugin { resources: { required: [], optional: [] }, } as PluginManifest<'greeter'>; - async setup() { + setup() { // Routes registered here are captured by createTestPluginContext().routes. this.context?.addRoute('get', '/hello', (_req, res) => { res.end(); }); + return Promise.resolve(); } // A stand-in for a streaming handler: yields SSE-style event objects. - async *greet(name: string) { + // expectStream also takes async iterables and SSE responses. + *greet(name: string) { yield { type: 'greeting_start', name }; yield { type: 'greeting_end', message: `Hello, ${name}!` }; } @@ -53,9 +56,10 @@ class GreeterPlugin extends Plugin { name: 'greet', method: 'post', path: '/greet', - handler: async (req, res) => { + handler: (req, res) => { const { name } = req.body as { name: string }; res.json({ message: `Hello, ${name}!` }); + return Promise.resolve(); }, }); } @@ -98,3 +102,4 @@ describe('testing kit example', () => { } }); }); +{{- end}} diff --git a/tools/smoke-test-template.ts b/tools/smoke-test-template.ts index 889ec6c2a..5ff40c647 100644 --- a/tools/smoke-test-template.ts +++ b/tools/smoke-test-template.ts @@ -10,9 +10,10 @@ * - {{.projectName}} → smoke-test-app * - {{.appDescription}} → Smoke test app * - {{.appEnv}} → (empty, which means the conditional block is omitted) + * - {{.skipExamples}} → the --skip-examples flag * * Usage: - * tsx tools/smoke-test-template.ts [--output-dir ] [--package-manager ] [--run] + * tsx tools/smoke-test-template.ts [--output-dir ] [--package-manager ] [--run] [--skip-examples] * * Options: * --output-dir Optional. Scratch directory for the scaffolded app. @@ -20,6 +21,8 @@ * --package-manager Optional. Package manager to scaffold (pnpm or npm). * Defaults to "pnpm". (short: -p) * --run Install, build with prebuild, and check HTTP startup. + * --skip-examples Set the `.skipExamples` template variable, as the Builder + * will: no example queries or example test. */ import { @@ -34,6 +37,10 @@ import { import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; +import { + renderTemplateConditionals, + type TemplateVars, +} from "./template-render"; import { runTemplateSmoke, selectSmokePackageManager, @@ -46,6 +53,7 @@ const { values } = parseArgs({ "output-dir": { type: "string", default: ".smoke-test/app" }, "package-manager": { type: "string", default: "pnpm", short: "p" }, run: { type: "boolean", default: false }, + "skip-examples": { type: "boolean", default: false }, }, strict: true, }); @@ -76,6 +84,13 @@ console.log(`✓ Copied template → ${outputDir}`); selectSmokePackageManager(SCRATCH_DIR, packageManager); +// Server-only scaffold: no plugins selected, so every plugin block is dropped. +const templateVars: TemplateVars = { + plugins: {}, + appEnv: "", + skipExamples: values["skip-examples"], +}; + // Render placeholders in key files const placeholders = { "{{.projectName}}": "smoke-test-app", @@ -86,7 +101,7 @@ const placeholders = { /** * Recursively process all files in the scaffold directory to: * 1. Replace simple placeholders ({{.projectName}}, {{.appDescription}}) - * 2. Remove Go template conditionals for plugins and appEnv + * 2. Render Go template conditionals on plugins, appEnv and skipExamples */ function processAllFiles(dir: string): void { const entries = readdirSync(dir, { withFileTypes: true }); @@ -111,6 +126,7 @@ function processAllFiles(dir: string): void { fullPath.endsWith(".ts") || fullPath.endsWith(".json") || fullPath.endsWith(".html") || + fullPath.endsWith(".sql") || fullPath.endsWith(".tmpl") || fullPath.endsWith(".yaml") || fullPath.endsWith(".yml")) @@ -126,26 +142,11 @@ function processAllFiles(dir: string): void { } } - // Remove Go template conditionals: {{- if .appEnv}} blocks - if (content.includes("{{") && content.includes(".appEnv")) { - const beforeLen = content.length; - content = content.replace( - /{{-?\s*if\s+\.appEnv\s*}}[\s\S]*?{{-?\s*end\s*}}/g, - "", - ); - if (beforeLen !== content.length) { - modified = true; - } - } - - // Remove Go template conditionals: {{- if .plugins.*}} blocks - if (content.includes("{{") && content.includes(".plugins.")) { - const beforeLen = content.length; - content = content.replace( - /{{-?\s*if\s+\.plugins\.[a-zA-Z0-9_-]+\s*-?\s*}}[\s\S]*?{{-?\s*end\s*-?\s*}}/g, - "", - ); - if (beforeLen !== content.length) { + // Render the {{if}} chains on plugins, appEnv and skipExamples + if (content.includes("{{")) { + const rendered = renderTemplateConditionals(content, templateVars); + if (rendered !== content) { + content = rendered; modified = true; } } diff --git a/tools/template-render.test.ts b/tools/template-render.test.ts new file mode 100644 index 000000000..a03ed4888 --- /dev/null +++ b/tools/template-render.test.ts @@ -0,0 +1,145 @@ +import { globSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + renderTemplateConditionals, + type TemplateVars, +} from "./template-render"; + +const root = resolve(import.meta.dirname, ".."); +const template = join(root, "template"); + +describe("renderTemplateConditionals", () => { + const render = (source: string, vars: TemplateVars = {}) => + renderTemplateConditionals(source, vars); + + test("renders plugin conditionals", () => { + const source = "a{{if .plugins.analytics}}b{{end}}c"; + expect(render(source, { plugins: { analytics: {} } })).toBe("abc"); + expect(render(source, { plugins: {} })).toBe("ac"); + }); + + test("applies trim markers", () => { + const source = "a\n{{- if .plugins.x}}\nb\n{{- end}}\nc"; + expect(render(source, { plugins: { x: {} } })).toBe("a\nb\nc"); + expect(render(source)).toBe("a\nc"); + expect(render("{{if .plugins.x -}}\n b\n{{- end}}\n", {})).toBe("\n"); + }); + + test("evaluates not, and, or and parentheses with Go truthiness", () => { + const vars = { plugins: { a: {} }, appEnv: "", skipExamples: true }; + expect(render("{{if not .skipExamples}}x{{end}}", vars)).toBe(""); + expect(render("{{if not .skipExamples}}x{{end}}", {})).toBe("x"); + expect( + render("{{if and .plugins.a (not .skipExamples)}}x{{end}}", {}), + ).toBe(""); + expect( + render("{{if and .plugins.a (not .skipExamples)}}x{{end}}", { + plugins: { a: {} }, + }), + ).toBe("x"); + expect(render("{{if or .appEnv .plugins.b}}x{{end}}", vars)).toBe(""); + expect(render("{{if or .appEnv .plugins.a}}x{{end}}", vars)).toBe("x"); + }); + + test("picks the first matching branch of an else-if chain", () => { + const source = "{{if .plugins.a}}A{{else if .plugins.b}}B{{else}}C{{end}}"; + expect(render(source, { plugins: { a: {}, b: {} } })).toBe("A"); + expect(render(source, { plugins: { b: {} } })).toBe("B"); + expect(render(source, { plugins: {} })).toBe("C"); + }); + + test("keeps other actions verbatim but renders conditionals nested in them", () => { + const source = + "{{- if .bundle.x}}{{.projectName}}{{- if .plugins.a}}A{{- end}}{{- end}}"; + expect(render(source, { plugins: {} })).toBe( + "{{- if .bundle.x}}{{.projectName}}{{- end}}", + ); + expect( + render( + '{{range $n, $p := .plugins}}{{if eq $p.S "beta"}}b{{end}}{{end}}', + ), + ).toBe('{{range $n, $p := .plugins}}{{if eq $p.S "beta"}}b{{end}}{{end}}'); + }); + + test("rejects unbalanced blocks", () => { + expect(() => render("{{if .plugins.a}}x")).toThrow(/Unclosed/); + expect(() => render("x{{end}}")).toThrow(/Unexpected/); + }); +}); + +describe("template scaffold", () => { + const plugins = Object.fromEntries( + Object.keys( + ( + JSON.parse( + readFileSync(join(template, "appkit.plugins.json"), "utf-8"), + ) as { plugins: Record } + ).plugins, + ).map((name) => [name, {}]), + ); + const files = globSync("**/*", { + cwd: template, + exclude: (name) => name === "node_modules", + }).filter((file) => /\.(tsx?|sql|json|tmpl|ya?ml|html)$/.test(file)); + + /** Renders the template like `apps init`: files that render empty are skipped. */ + function scaffold(vars: TemplateVars): Map { + const out = new Map(); + for (const file of files) { + const rendered = renderTemplateConditionals( + readFileSync(join(template, file), "utf-8"), + vars, + ); + if (rendered.trim()) out.set(file, rendered); + } + return out; + } + + const examples = [ + "config/queries/hello_world.sql", + "config/queries/mocked_sales.sql", + "server/example.test.ts", + ]; + + test("keeps the example queries and test by default", () => { + const app = scaffold({ plugins }); + for (const file of examples) expect(app.has(file), file).toBe(true); + expect(app.get("client/src/pages/analytics/AnalyticsPage.tsx")).toContain( + "useAnalyticsQuery('hello_world'", + ); + }); + + test("leaves the example queries and test out with skipExamples", () => { + const app = scaffold({ plugins, skipExamples: true }); + for (const file of examples) expect(app.has(file), file).toBe(false); + for (const [file, content] of app) { + expect(content, file).not.toMatch(/hello_world|mocked_sales/); + } + expect(app.get("client/src/pages/analytics/AnalyticsPage.tsx")).toContain( + "export function AnalyticsPage()", + ); + }); + + test("renders no example queries without the analytics plugin", () => { + const app = scaffold({ plugins: {} }); + expect(app.has("config/queries/hello_world.sql")).toBe(false); + expect(app.has("config/queries/mocked_sales.sql")).toBe(false); + expect(app.has("server/example.test.ts")).toBe(true); + }); + + test.each([false, true])( + "leaves no template syntax in the gated files (skipExamples: %s)", + (skipExamples) => { + const app = scaffold({ plugins, skipExamples }); + for (const file of [ + ...examples, + "client/src/pages/analytics/AnalyticsPage.tsx", + ]) { + expect(app.get(file) ?? "", file).not.toContain("{{"); + } + }, + ); +}); diff --git a/tools/template-render.ts b/tools/template-render.ts new file mode 100644 index 000000000..c990a1171 --- /dev/null +++ b/tools/template-render.ts @@ -0,0 +1,325 @@ +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Renders the conditional blocks of the template's Go `text/template` syntax so + * the smoke test and template tests can scaffold the way `databricks apps init` + * does, without the Databricks CLI. + * + * Only `{{if}}` / `{{else if}}` / `{{else}}` / `{{end}}` chains whose conditions + * read the variables in `RENDERED_ROOTS` are evaluated, with Go's trim markers + * and truthiness. Every other action (`{{.projectName}}`, `{{range}}`, an `if` + * on `$vars` or `.bundle`) is kept verbatim, while `if` chains nested inside it + * are still rendered. Conditions may use `not`, `and`, `or` and parentheses. + */ + +/** Template variables whose conditions are rendered; others are left as-is. */ +export const RENDERED_ROOTS = ["plugins", "appEnv", "skipExamples"] as const; + +export interface TemplateVars { + /** Selected plugins, as `apps init` passes them (`{{if .plugins.analytics}}`). */ + plugins?: Record; + appEnv?: string; + /** Builder mode: omit the example queries and example test. */ + skipExamples?: boolean; +} + +interface Text { + type: "text"; + value: string; +} + +interface Action { + type: "action"; + raw: string; + body: string; + trimLeft: boolean; + trimRight: boolean; + /** Set when the action is consumed by rendering, so its trim markers apply. */ + rendered?: boolean; +} + +type Token = Text | Action; + +interface Branch { + /** `undefined` for a plain `{{else}}`. */ + condition: string | undefined; + action: Action; + children: Node[]; +} + +interface Block { + type: "block"; + /** `if` blocks are a chain of branches; other blocks have exactly one. */ + keyword: string; + branches: Branch[]; + end: Action; +} + +type Node = Text | Action | Block; + +const ACTION = /{{(-\s)?([\s\S]*?)(\s-)?}}/g; +const BLOCK_KEYWORDS = new Set(["if", "range", "with", "define", "block"]); + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let last = 0; + for (const match of source.matchAll(ACTION)) { + if (match.index > last) { + tokens.push({ type: "text", value: source.slice(last, match.index) }); + } + tokens.push({ + type: "action", + raw: match[0], + body: match[2].trim(), + trimLeft: match[1] !== undefined, + trimRight: match[3] !== undefined, + }); + last = match.index + match[0].length; + } + if (last < source.length) { + tokens.push({ type: "text", value: source.slice(last) }); + } + return tokens; +} + +function keywordOf(body: string): string { + return body.split(/\s+/, 1)[0]; +} + +function parse(tokens: Token[]): Node[] { + let index = 0; + + function parseNodes(): { nodes: Node[]; stop?: Action } { + const nodes: Node[] = []; + while (index < tokens.length) { + const token = tokens[index++]; + if (token.type === "text") { + nodes.push(token); + continue; + } + const keyword = keywordOf(token.body); + if (keyword === "end" || keyword === "else") { + return { nodes, stop: token }; + } + if (!BLOCK_KEYWORDS.has(keyword)) { + nodes.push(token); + continue; + } + const branches: Branch[] = []; + let action = token; + let condition: string | undefined = token.body.slice(keyword.length); + for (;;) { + const { nodes: children, stop } = parseNodes(); + branches.push({ condition, action, children }); + if (!stop) throw new Error(`Unclosed {{${token.body}}}`); + if (keywordOf(stop.body) === "end") { + nodes.push({ type: "block", keyword, branches, end: stop }); + break; + } + // `{{else}}` or `{{else if cond}}` + const rest = stop.body.slice("else".length).trim(); + action = stop; + condition = rest.startsWith("if ") ? rest.slice(3) : undefined; + } + } + return { nodes }; + } + + const { nodes, stop } = parseNodes(); + if (stop) throw new Error(`Unexpected {{${stop.body}}}`); + return nodes; +} + +class Unrendered extends Error {} + +/** Splits a pipeline into its terms, keeping parenthesised groups and strings whole. */ +function terms(source: string): string[] { + const out: string[] = []; + let i = 0; + while (i < source.length) { + const char = source[i]; + if (/\s/.test(char)) { + i++; + continue; + } + let j = i; + if (char === "(") { + let depth = 0; + for (; j < source.length; j++) { + if (source[j] === "(") depth++; + if (source[j] === ")" && --depth === 0) break; + } + j++; + } else if (char === '"') { + j = source.indexOf('"', i + 1) + 1; + if (j === 0) throw new Unrendered(); + } else { + while (j < source.length && !/[\s()]/.test(source[j])) j++; + } + out.push(source.slice(i, j)); + i = j; + } + return out; +} + +/** + * Go's `text/template` truthiness. Objects count as true like the non-nil + * `*pluginVar` pointers `apps init` passes for selected plugins. + */ +function truthy(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value.length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; +} + +function evaluateTerm(term: string, vars: TemplateVars): unknown { + if (term.startsWith("(") && term.endsWith(")")) { + return evaluate(term.slice(1, -1), vars); + } + if (term.startsWith('"')) return term.slice(1, -1); + if (!term.startsWith(".")) throw new Unrendered(); + const path = term.slice(1).split("."); + if (!(RENDERED_ROOTS as readonly string[]).includes(path[0])) { + throw new Unrendered(); + } + let value: unknown = vars; + for (const key of path) { + value = + value && typeof value === "object" + ? (value as Record)[key] + : undefined; + } + return value; +} + +function evaluate(pipeline: string, vars: TemplateVars): unknown { + const [head, ...args] = terms(pipeline); + if (head === undefined) throw new Unrendered(); + const values = () => args.map((arg) => evaluateTerm(arg, vars)); + switch (head) { + case "not": + if (args.length !== 1) throw new Unrendered(); + return !truthy(evaluateTerm(args[0], vars)); + case "and": { + let value: unknown = true; + for (const arg of values()) if (!truthy((value = arg))) return arg; + return value; + } + case "or": { + let value: unknown = false; + for (const arg of values()) if (truthy((value = arg))) return arg; + return value; + } + default: + if (args.length) throw new Unrendered(); + return evaluateTerm(head, vars); + } +} + +/** The branch an `if` chain renders, `null` for none, or `undefined` if it can't be decided. */ +function chooseBranch( + block: Block, + vars: TemplateVars, +): Branch | null | undefined { + try { + // Evaluate every condition so a chain mixing rendered and other variables stays verbatim. + const results = block.branches.map( + (branch) => + branch.condition === undefined || + truthy(evaluate(branch.condition, vars)), + ); + const index = results.indexOf(true); + return index === -1 ? null : block.branches[index]; + } catch (error) { + if (error instanceof Unrendered) return undefined; + throw error; + } +} + +/** Marks the actions of every renderable `if` chain so their trim markers apply. */ +function mark(nodes: Node[], vars: TemplateVars): void { + for (const node of nodes) { + if (node.type !== "block") continue; + if (node.keyword === "if" && chooseBranch(node, vars) !== undefined) { + for (const branch of node.branches) branch.action.rendered = true; + node.end.rendered = true; + } + for (const branch of node.branches) mark(branch.children, vars); + } +} + +function applyTrims(tokens: Token[]): void { + tokens.forEach((token, i) => { + if (token.type !== "action" || !token.rendered) return; + const before = tokens[i - 1]; + const after = tokens[i + 1]; + if (token.trimLeft && before?.type === "text") { + before.value = before.value.trimEnd(); + } + if (token.trimRight && after?.type === "text") { + after.value = after.value.trimStart(); + } + }); +} + +function render(nodes: Node[], vars: TemplateVars): string { + let out = ""; + for (const node of nodes) { + if (node.type === "text") { + out += node.value; + } else if (node.type === "action") { + out += node.raw; + } else { + const chosen = + node.keyword === "if" ? chooseBranch(node, vars) : undefined; + if (chosen === undefined) { + for (const branch of node.branches) { + out += branch.action.raw + render(branch.children, vars); + } + out += node.end.raw; + } else if (chosen) { + out += render(chosen.children, vars); + } + } + } + return out; +} + +/** + * Renders the `if` chains of a template file that read `RENDERED_ROOTS`. + * Like `apps init`, callers should skip a file whose result is only whitespace. + */ +export function renderTemplateConditionals( + source: string, + vars: TemplateVars, +): string { + const tokens = tokenize(source); + const nodes = parse(tokens); + mark(nodes, vars); + applyTrims(tokens); + return render(nodes, vars); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + // Renders template files in place with the default `apps init` variables, e.g. + // so the raw template's own tests can run. Files that render empty are removed. + const files = process.argv.slice(2); + if (!files.length) { + throw new Error("Usage: tsx tools/template-render.ts ..."); + } + for (const file of files) { + const rendered = renderTemplateConditionals(readFileSync(file, "utf-8"), { + plugins: {}, + }); + if (rendered.trim()) writeFileSync(file, rendered); + else rmSync(file); + } +}