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
43 changes: 43 additions & 0 deletions .github/workflows/js-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Check - Shared Viz Assets

on:
workflow_dispatch:
push:
branches: ["main", "rc-*"]
paths:
- "js/**"
- "pkg-py/src/querychat/static/css/viz.css"
- "pkg-py/src/querychat/static/js/viz.js"
- "Makefile"
- ".github/workflows/js-check.yml"
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "js/**"
- "pkg-py/src/querychat/static/css/viz.css"
- "pkg-py/src/querychat/static/js/viz.js"
- "Makefile"
- ".github/workflows/js-check.yml"

permissions:
contents: read

jobs:
js-check:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: js/package-lock.json

- name: Install shared asset dependencies
run: make js-setup

- name: Check shared assets
run: make js-check
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -276,5 +276,9 @@ pkg-py/docs/_screenshots/

# Git worktrees
.worktrees/
js/node_modules/

# Superpowers docs (local only)
docs/superpowers/

/.luarc.json
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ docs: r-docs py-docs-render ## [docs] Build the documentation
# @echo "🧳 Building JS code in watch mode"
# cd $(PATH_PKG_JS) && npm run watch

.PHONY: js-setup
js-setup: ## [js] Install shared web asset dependencies
@echo "🆙 Setup shared web asset dependencies"
cd $(PATH_PKG_JS) && npm ci

.PHONY: js-build
js-build: ## [js] Build shared web assets
@echo "🧳 Building shared web assets"
cd $(PATH_PKG_JS) && npm run build

.PHONY: js-check
js-check: ## [js] Check shared web assets
@echo "📐 Checking shared web assets"
cd $(PATH_PKG_JS) && npm run check

.PHONY: r-setup
r-setup: ## [r] Install R dependencies
@echo "🆙 Updating R dependencies"
Expand Down
141 changes: 141 additions & 0 deletions js/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { build } from "esbuild";
import {
access,
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = path.dirname(fileURLToPath(import.meta.url));
export const repoDir = path.resolve(rootDir, "..");

const jsTargets = [
{
source: "src/viz.ts",
output: "../pkg-py/src/querychat/static/js/viz.js",
},
];

const cssTargets = [
{
source: "src/viz.css",
output: "../pkg-py/src/querychat/static/css/viz.css",
},
];

const ensureParentDir = async (relativePath) => {
const absolutePath = path.resolve(rootDir, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
return absolutePath;
};

export const assetTargets = [...cssTargets, ...jsTargets];

export const resolveOutputPath = (baseDir, relativePath) =>
path.resolve(baseDir, path.relative(repoDir, path.resolve(rootDir, relativePath)));

const banner = (source) =>
`/* Generated file. Source: js/${source}. Do not edit directly. */\n`;

const uniqueSources = (targets) => [...new Set(targets.map((target) => target.source))];

const findMissingSources = async (targets) => {
const missingSources = [];

for (const source of uniqueSources(targets)) {
try {
await access(path.resolve(rootDir, source));
} catch {
missingSources.push(`js/${source}`);
}
}

return missingSources;
};

const reportMissingSources = async () => {
const missingCssSources = await findMissingSources(cssTargets);
const missingJsSources = await findMissingSources(jsTargets);

if (missingCssSources.length === 0 && missingJsSources.length === 0) {
return;
}

const messages = [];

if (missingCssSources.length > 0) {
messages.push(`Missing CSS source files:\n- ${missingCssSources.join("\n- ")}`);
}

if (missingJsSources.length > 0) {
messages.push(`Missing JS source files:\n- ${missingJsSources.join("\n- ")}`);
}

throw new Error(messages.join("\n\n"));
};

export const stageBuildOutputs = async (stageDir) => {
const cssSourcePath = path.resolve(rootDir, "src/viz.css");
const cssSource = await readFile(cssSourcePath, "utf8");

for (const target of cssTargets) {
const outputPath = resolveOutputPath(stageDir, target.output);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${banner(target.source)}${cssSource}`, "utf8");
}

for (const target of jsTargets) {
const outputPath = resolveOutputPath(stageDir, target.output);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
bundle: true,
entryPoints: [path.resolve(rootDir, target.source)],
format: "iife",
logLevel: "info",
outfile: outputPath,
platform: "browser",
target: "es2020",
banner: {
js: banner(target.source),
},
});
}
};

export const commitBuildOutputs = async (stageDir) => {
for (const target of assetTargets) {
const stagedOutputPath = resolveOutputPath(stageDir, target.output);
await ensureParentDir(target.output);
await copyFile(stagedOutputPath, path.resolve(rootDir, target.output));
}
};

export async function withStagedBuild(callback) {
await reportMissingSources();

const stageDir = await mkdtemp(path.join(os.tmpdir(), "querychat-build-"));

try {
await stageBuildOutputs(stageDir);
return await callback(stageDir);
} finally {
await rm(stageDir, { force: true, recursive: true });
}
}

export async function buildOutputs() {
await withStagedBuild(commitBuildOutputs);
}

const isEntrypoint =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (isEntrypoint) {
await buildOutputs();
}
83 changes: 83 additions & 0 deletions js/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import ts from "typescript";
import { assetTargets, repoDir, resolveOutputPath, withStagedBuild } from "./build.mjs";

const configPath = "tsconfig.json";
const formatHost = {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: ts.sys.getCurrentDirectory,
getNewLine: () => ts.sys.newLine,
};

const readResult = ts.readConfigFile(configPath, ts.sys.readFile);
if (readResult.error) {
console.error(ts.formatDiagnosticsWithColorAndContext([readResult.error], formatHost));
process.exit(1);
}

const parsedConfig = ts.parseJsonConfigFileContent(
readResult.config,
ts.sys,
process.cwd(),
undefined,
configPath,
);

const configErrors = parsedConfig.errors.filter((error) => error.code !== 18003);
if (configErrors.length > 0) {
console.error(ts.formatDiagnosticsWithColorAndContext(configErrors, formatHost));
process.exit(1);
}

if (parsedConfig.fileNames.length > 0) {
const program = ts.createProgram({
options: parsedConfig.options,
rootNames: parsedConfig.fileNames,
});

const diagnostics = ts.getPreEmitDiagnostics(program);
if (diagnostics.length > 0) {
console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost));
process.exit(1);
}
}

const staleOutputs = [];

await withStagedBuild(async (stageDir) => {
for (const target of assetTargets) {
const stagedOutputPath = resolveOutputPath(stageDir, target.output);
const committedOutputPath = resolveOutputPath(repoDir, target.output);
const relativeOutputPath = path.relative(repoDir, committedOutputPath);

let stagedOutput;
let committedOutput;

try {
[stagedOutput, committedOutput] = await Promise.all([
readFile(stagedOutputPath),
readFile(committedOutputPath),
]);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
staleOutputs.push(relativeOutputPath);
continue;
}

throw error;
}

if (!stagedOutput.equals(committedOutput)) {
staleOutputs.push(relativeOutputPath);
}
}
});

if (staleOutputs.length > 0) {
console.error("Generated web assets are out of sync. Run `make js-build`.");
for (const outputPath of staleOutputs) {
console.error(`- ${outputPath}`);
}
process.exit(1);
}
Loading
Loading