Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f2e3f06
WIP Implement Bikeshed support
kfranqueiro Nov 3, 2025
52e8359
Work around bikeshed JSON output bug
kfranqueiro Nov 3, 2025
869e087
Implement issues-list URL fetching
kfranqueiro Nov 3, 2025
71b5c1a
Hide spec-specific controls when issues-list is selected
kfranqueiro Nov 3, 2025
458dbc2
Improve spec/issues-specific show/hide behavior
kfranqueiro Nov 3, 2025
1296da4
Implement --die-on
kfranqueiro Nov 4, 2025
6d13e05
Add bikeshed tests, and clean up respec tests
kfranqueiro Nov 5, 2025
a228de3
Skip md-* params with no value
kfranqueiro Nov 5, 2025
d3f1019
Layout/style improvements/fixes
kfranqueiro Nov 5, 2025
4cd52e8
Install bikeshed during test workflow
kfranqueiro Nov 5, 2025
ec3d14c
Re-extend test timeout for github workflow
kfranqueiro Nov 5, 2025
78b0d74
Work around bikeshed 5.3.5 regression
kfranqueiro Nov 5, 2025
10162e7
Add HTTP API docs
kfranqueiro Nov 5, 2025
534a381
Remove bikeshed workarounds that are no longer necessary as of 5.3.6
kfranqueiro Nov 7, 2025
d6e52a1
Standardize on 2-space indent (prettier default)
kfranqueiro Nov 7, 2025
b6e4661
README: Add note RE bikeshed version
kfranqueiro Nov 7, 2025
631e3a6
Restructure as single page
kfranqueiro Nov 22, 2025
f3a2c6a
Tweak test timeouts
kfranqueiro Nov 24, 2025
e2c893a
Fix die-on behavior for ReSpec
kfranqueiro Nov 24, 2025
fa30723
Merge branch 'main' into kgf-bikeshed
kfranqueiro Nov 24, 2025
cfd6645
Add tests for type=respec + die-on
kfranqueiro Nov 24, 2025
a37b2dc
Simplify test suite setup to only start the server once
kfranqueiro Nov 24, 2025
c26a38f
Extend respec die-on suite timeout for CI
kfranqueiro Nov 24, 2025
91f380f
Cleanup
kfranqueiro Nov 25, 2025
263f3f1
Add more detailed docs around file types
kfranqueiro Nov 25, 2025
3097bf7
Fix no-query-string case for mergeRequestParams
kfranqueiro Dec 1, 2025
ba384e0
Define limits for express-fileupload
kfranqueiro Dec 2, 2025
3dc9516
Apply suggestions from Sid's code review
kfranqueiro Dec 5, 2025
72616d9
Fix formatting; apply Sid's base URL suggestion to one more instance
kfranqueiro Dec 5, 2025
1772d6b
Increase test timeout for CI again
kfranqueiro Dec 5, 2025
4f94774
Merge branch 'main' into kgf-bikeshed
kfranqueiro Dec 5, 2025
f6e4d08
Move respec die-on logic to allow relaying same error/warning info
kfranqueiro Dec 5, 2025
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
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ jobs:
uses: actions/[email protected]
with:
node-version: ${{ matrix.node-version }}

- name: Install bikeshed
run: pipx install bikeshed && bikeshed update
- run: npm install
- run: npx respec2html -e --timeout 30 --src "https://w3c.github.io/spec-generator/respec.html"
- run: npm test
3 changes: 1 addition & 2 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"endOfLine": "auto",
"tabWidth": 4
"endOfLine": "auto"
}
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@

This exposes a service to automatically generate specs from various source formats.

## Setup

Clone or download the repository, then install dependencies:

```
npm install
```

### Bikeshed preparation

In order for the server to field requests for Bikeshed documents,
`bikeshed` must be installed such that it is executable by the user running the server.
Version 5.3.6 is required at minimum, as it contains fixes related to JSON output.

One straightforward installation method is [`pipx`](https://pipx.pypa.io/),
which is designed for installing Python applications (as opposed to libraries),
and is available through various package managers.

```
pipx install bikeshed
```

## Running the server

Start the server listening on port 8000 by default:
Expand Down
224 changes: 224 additions & 0 deletions generators/bikeshed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { exec, spawn } from "child_process";
import { readFile, unlink, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";

import filenamify from "filenamify";

import type { ValidateParamsResult } from "../server.js";
import { SpecGeneratorError } from "./common.js";

interface BikeshedMessage {
lineNum: string | null;
/** One of the message types enumerated in bikeshed/messages.py */
messageType:
| "fatal"
| "link"
| "lint"
| "warning"
| "message"
| "success"
| "failure";
text: string;
}
type BikeshedResultMessage = Omit<BikeshedMessage, "messageType">;

interface BikeshedResult {
html: string;
links: BikeshedResultMessage[];
lints: BikeshedResultMessage[];
warnings: BikeshedResultMessage[];
messages: BikeshedResultMessage[];
}

const execAsync = promisify(exec);
const bikeshedVersion = await execAsync("bikeshed --version").then(
({ stdout }) => stdout.trim(),
() => null,
);
if (!bikeshedVersion) {
console.warn("Bikeshed not found! See README.md for setup instructions.");
console.warn("Bikeshed requests will result in 500 responses.");
}

const generateFilename = (url: string) =>
join(tmpdir(), `spec-generator-${Date.now()}-${filenamify(url)}.html`);

/**
* Invokes bikeshed on a URL with the given options.
* @param input HTTPS URL or file path to process
* @param modeOptions Additional CLI arguments specified after the mode
* @param globalOptions Additional CLI arguments specified before the mode
*/
async function invokeBikeshed(
input: string,
mode: "spec" | "issues-list",
modeOptions: string[] = [],
globalOptions: string[] = [],
) {
if (!bikeshedVersion) {
throw new SpecGeneratorError(
"Bikeshed is currently unavailable on this server.",
);
}

// Bikeshed logs everything to stdout, so stderr is unused.
// Output HTML to a file to make warnings/errors easier to parse.
const outputPath = generateFilename(input);

return new Promise<BikeshedResult>(async (resolve, reject) => {
// Use spawn instead of exec to make arguments injection-proof
const bikeshedProcess = spawn(
"bikeshed",
[
"--print=json",
"--no-update",
...globalOptions,
mode,
input,
outputPath,
...modeOptions,
],
{ timeout: 30000 },
);
const pid = bikeshedProcess.pid;
console.log(`[bikeshed(${pid})] generating ${mode} ${input}`);

const stdoutChunks: string[] = [];
bikeshedProcess.stdout.on("data", (data) => stdoutChunks.push(data));
bikeshedProcess.stderr.on("data", (data) =>
console.error(`[bikeshed(${pid}) stderr] ${data}`),
);
bikeshedProcess.on("error", (error) => {
console.error(`[bikeshed(${pid}) error]`, error);
reject(new SpecGeneratorError(error.message));
});
bikeshedProcess.on("exit", async (code, signal) => {
if (signal === "SIGTERM") {
console.error(`[bikeshed(${pid}) SIGTERM]`);
reject(
new SpecGeneratorError(
"bikeshed process timed out or otherwise terminated",
),
);
} else {
const result: BikeshedResult = {
html: "",
links: [],
lints: [],
warnings: [],
messages: [],
};
const fatals: BikeshedResultMessage[] = [];
const outcomes: BikeshedMessage[] = [];

const stdout = stdoutChunks.join("");

try {
const messages = JSON.parse(
stdout.trim() || "[]",
) as BikeshedMessage[];
for (const { lineNum, messageType, text } of messages) {
if (messageType === "fatal") {
fatals.push({ lineNum, text });
} else if (messageType === "success" || messageType === "failure") {
outcomes.push({ lineNum, messageType, text });
} else {
const key = `${messageType}s`;
if (key in result) {
result[key as keyof Omit<BikeshedResult, "html">].push({
lineNum,
text,
});
}
}
}
} catch {
fatals.push({
lineNum: null,
text: "Bikeshed returned incomplete or unexpected output",
});
}

// If bikeshed fails, report the most useful message we can find
const failure = outcomes.find(
({ messageType }) => messageType === "failure",
);
if (failure) {
reject(new SpecGeneratorError(`failure: ${failure.text}`));
} else if (fatals.length > 0) {
reject(new SpecGeneratorError(`fatal error: ${fatals[0].text}`));
} else if (code) {
reject(
new SpecGeneratorError(`bikeshed process exited with code ${code}`),
);
} else {
try {
result.html = await readFile(outputPath, "utf8");
resolve(result);
} catch {
reject(new SpecGeneratorError("bikeshed did not write any output"));
}
}
}
});
}).finally(() => unlink(outputPath).catch(() => {}));
}

/** Runs `bikeshed spec`, incorporating custom metadata. */
const generateSpec = async (input: string, params: URLSearchParams) => {
const metadataOverrides: string[] = [];
for (const [key, value] of params.entries()) {
if (key.startsWith("md-") && value)
metadataOverrides.push(`--${key}=${value}`);
}
return invokeBikeshed(
input,
"spec",
metadataOverrides,
params.has("die-on") ? [`--die-on=${params.get("die-on")}`] : [],
);
};

/** Runs `bikeshed issues-list`, fetching from remote server if a URL is specified. */
const generateIssuesList = async (input: string) => {
if (!/^https?:\/\//.test(input)) return invokeBikeshed(input, "issues-list");

const filename = generateFilename(input);
const response = await fetch(input);
if (response.status >= 400) {
throw new SpecGeneratorError(
`URL ${input} responded with ${response.status} status`,
);
}

await writeFile(filename, await response.text());
return invokeBikeshed(filename, "issues-list").finally(() =>
unlink(filename).catch(() => {}),
);
};

/** Generates response for validated bikeshed requests. */
export async function generateBikeshed(result: ValidateParamsResult) {
const { file, params, res, type, url } = result;

const input = file?.tempFilePath || url;
// Return early for type-safety; this should already be handled by server.ts
if (!input) return;

try {
const { html, ...messages } = await (type === "bikeshed-spec"
? generateSpec(input, params)
: generateIssuesList(input));
for (const k of Object.keys(messages)) {
res.setHeader(
`x-${k}-count`,
messages[k as keyof typeof messages].length,
);
}
res.send(html);
} catch (err) {
res.status(err.status).json({ error: err.message });
}
}
14 changes: 14 additions & 0 deletions generators/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
interface SpecGeneratorErrorConstructorOptions {
message: string;
status: number;
}

export class SpecGeneratorError extends Error {
status: number;
constructor(init: string | SpecGeneratorErrorConstructorOptions) {
const { message, status } =
typeof init === "string" ? { message: init, status: 500 } : init;
super(message);
this.status = status;
}
}
32 changes: 16 additions & 16 deletions generators/respec.d.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
interface ToHTMLOptions {
timeout?: number;
disableSandbox?: boolean;
disableGPU?: boolean;
devtools?: boolean;
useLocal?: boolean;
onError?: (error: any) => void;
onProgress?: (msg: string, remaining: number) => void;
onWarning?: (warning: any) => void;
timeout?: number;
disableSandbox?: boolean;
disableGPU?: boolean;
devtools?: boolean;
useLocal?: boolean;
onError?: (error: any) => void;
onProgress?: (msg: string, remaining: number) => void;
onWarning?: (warning: any) => void;
}

declare module "respec" {
export function toHTML(
url: string,
options?: ToHTMLOptions,
): Promise<{
html: string;
errors: any[];
warnings: any[];
}>;
export function toHTML(
url: string,
options?: ToHTMLOptions,
): Promise<{
html: string;
errors: any[];
warnings: any[];
}>;
}
Loading