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
24 changes: 13 additions & 11 deletions .github/scripts/docs-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ import { spawnSync } from "node:child_process";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

export const REQUIRED_DOC_FILES = [
export const COMMON_DOC_FILES = [
"projects/typed-peripheral-digitalitems/build.gradle.kts",
"projects/typed-peripheral-digitalitems/package.json",
"projects/typed-peripheral-digitalitems/package-lock.json",
"projects/typed-peripheral-digitalitems/typedoc.json",
"projects/typed-peripheral-digitalitems/tsconfig.docs.json",
"projects/typed-peripheral-digitalitems/shared.ts",
"projects/typed-peripheral-digitalitems/digitizer.ts",
"projects/typed-peripheral-digitalitems/advanced_digitizer.ts",
"projects/typed-peripheral-digitalitems/documentation/index.md",
"projects/typed-peripheral-digitalitems/documentation/theme/plugin.mjs",
"projects/typed-peripheral-digitalitems/documentation/theme/digitalitems.css",
"projects/typed-peripheral-digitalitems/documentation/theme/digitalitems.js",
];
const DOC_GENERATORS = [
["projects/typed-peripheral-digitalitems/typedoc.json", "projects/typed-peripheral-digitalitems/documentation/theme/plugin.mjs"],
["projects/typed-peripheral-digitalitems/mkdocs.yml", "projects/typed-peripheral-digitalitems/requirements-docs.txt", "projects/typed-peripheral-digitalitems/documentation/generate-docs.mjs"],
];

const BRANCHES = ["1.20", "1.21"];
const TAG_RE = /^v\d+(?:\.\d+)+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
Expand Down Expand Up @@ -119,9 +120,10 @@ function show(repository, ref, path) {
}

function refHasDocs(repository, ref) {
return REQUIRED_DOC_FILES.every((path) =>
git(repository, ["cat-file", "-e", `${ref}:${path}`], true).status === 0
) && /\bgenerateDocs\b/.test(show(repository, ref, REQUIRED_DOC_FILES[0]));
const has = (path) => git(repository, ["cat-file", "-e", `${ref}:${path}`], true).status === 0;
return COMMON_DOC_FILES.every(has)
&& DOC_GENERATORS.some((files) => files.every(has))
&& /\bgenerateDocs\b/.test(show(repository, ref, COMMON_DOC_FILES[0]));
}

function metadata(repository, ref) {
Expand Down Expand Up @@ -265,7 +267,10 @@ function localTarget(root, html, rawLink) {
fail(`Malformed local link in ${html}: ${rawLink}`);
}
if (pathname.includes("\\") || pathname.includes("\0")) fail(`Unsafe local link in ${html}: ${rawLink}`);
const target = pathname.startsWith("/") ? resolve(root, `.${pathname}`) : resolve(dirname(html), pathname);
const versionedPath = pathname.match(/^\/.*?\/((?:branch|tag)\/.*)$/)?.[1];
const target = pathname.startsWith("/")
? resolve(root, versionedPath ?? `.${pathname}`)
: resolve(dirname(html), pathname);
if (!within(root, target)) fail(`Local link escapes site in ${html}: ${rawLink}`);
return target;
}
Expand All @@ -290,9 +295,6 @@ export async function validateSite(site) {
for (const entry of plan) {
const versionRoot = resolve(root, entry.path);
await regularFile(resolve(versionRoot, "index.html"), `${entry.path} index.html`);
for (const asset of ["search.js", "navigation.js", "custom.css", "custom.js"]) {
await regularFile(resolve(versionRoot, "assets", asset), `${entry.path} ${asset}`);
}
}
const canonicalRoot = await realpath(root);
for (const html of await htmlFiles(root)) {
Expand Down
19 changes: 14 additions & 5 deletions .github/scripts/docs-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ function entry(kind, name, sha = SHA_A) {
};
}

async function fragment(root, path, html = '<a href="assets/main.js">asset</a>') {
async function fragment(root, path, html = '<link rel="stylesheet" href="assets/main.css"><script src="assets/main.js"></script>') {
const directory = join(root, path);
await mkdir(join(directory, "assets"), { recursive: true });
await writeFile(join(directory, "index.html"), html);
for (const asset of ["main.js", "search.js", "navigation.js", "custom.css", "custom.js"]) {
for (const asset of ["main.js", "main.css"]) {
await writeFile(join(directory, "assets", asset), "// docs\n");
}
}
Expand Down Expand Up @@ -107,14 +107,23 @@ test("validate rejects links escaping the site", async () => {
await assert.rejects(validateSite(output), /escapes site/);
});

test("validate rejects missing TypeDoc assets", async () => {
test("validate resolves hosted version paths below the Pages project prefix", async () => {
const root = await mkdtemp(join(tmpdir(), "docs-site-prefix-"));
const input = join(root, "input");
const output = join(root, "output");
await fragment(input, "branch/1.20", '<script src="/DigitalItems/branch/1.20/assets/main.js"></script>');
await assembleSite({ input, output, plan: [entry("branch", "1.20")] });
await validateSite(output);
});

test("validate rejects a missing linked asset", async () => {
const root = await mkdtemp(join(tmpdir(), "docs-site-assets-"));
const input = join(root, "input");
const output = join(root, "output");
await fragment(input, "branch/1.20");
await assembleSite({ input, output, plan: [entry("branch", "1.20")] });
await unlink(join(output, "branch", "1.20", "assets", "search.js"));
await assert.rejects(validateSite(output), /Missing branch\/1.20 search.js/);
await unlink(join(output, "branch", "1.20", "assets", "main.css"));
await assert.rejects(validateSite(output), /Broken local link.*assets\/main.css/);
});

test("assemble rejects a symlinked output ancestor", async () => {
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/docs-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: projects/typed-peripheral-digitalitems/requirements-docs.txt

- name: Install documentation dependencies
run: timeout --foreground 120s python -m pip install -r projects/typed-peripheral-digitalitems/requirements-docs.txt

- name: Build package and documentation
run: timeout --foreground 600s ./gradlew :typed-peripheral-digitalitems:compileTypeScript :typed-peripheral-digitalitems:generateDocs --no-daemon

Expand All @@ -55,7 +65,7 @@ jobs:
run: |
set -euo pipefail
NODE="$PWD/.gradle/nodejs/node-v22.14.0-linux-x64/bin/node"
timeout --foreground 60s "$NODE" --test .github/scripts/docs-site.test.mjs
timeout --foreground 60s "$NODE" --test .github/scripts/docs-site.test.mjs projects/typed-peripheral-digitalitems/documentation/generate-docs.test.mjs
mkdir -p build/docs-fragments/branch/1.20
cp -a projects/typed-peripheral-digitalitems/docs/. build/docs-fragments/branch/1.20/
timeout --foreground 60s "$NODE" .github/scripts/docs-site.mjs assemble --input build/docs-fragments --output build/docs-site --plan-json "$DOCS_PLAN"
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/docs-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install MkDocs for migrated refs
run: |
if [[ -f projects/typed-peripheral-digitalitems/requirements-docs.txt ]]; then
timeout --foreground 120s python -m pip install -r projects/typed-peripheral-digitalitems/requirements-docs.txt
fi

- name: Generate documentation with ref-owned task
env:
DOCS_PATH: ${{ matrix.path }}
Expand Down
1 change: 1 addition & 0 deletions projects/typed-peripheral-digitalitems/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
*.d.ts
node_modules/
docs/
.mkdocs-build/
6 changes: 4 additions & 2 deletions projects/typed-peripheral-digitalitems/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ val generateDocs by tasks.registering(NpmTask::class) {
"package.json",
"package-lock.json",
"tsconfig.json",
"tsconfig.docs.json",
"typedoc.json",
"mkdocs.yml",
"requirements-docs.txt",
"*.ts",
"documentation/**/*.md",
"documentation/**/*.mjs",
"documentation/theme/**",
)
exclude("node_modules/**", "*.d.ts")
Expand All @@ -71,6 +72,7 @@ tasks.assemble {

tasks.clean {
delete(file("docs"))
delete(file(".mkdocs-build"))
delete(fileTree(projectDir) {
include("*.d.ts", "*.lua")
})
Expand Down
196 changes: 196 additions & 0 deletions projects/typed-peripheral-digitalitems/documentation/generate-docs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";

const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const peripherals = [
{ interfaceName: "Digitizer", page: "guides/basic-digitizer.md" },
{ interfaceName: "AdvancedDigitizer", page: "guides/advanced-items.md" },
];

function text(parts) {
return ts.displayPartsToString(parts).trim();
}

function tagsFor(signature, checker) {
const result = { params: new Map(), returns: "", throws: [] };
for (const tag of signature.getJsDocTags(checker)) {
const value = text(tag.text);
if (tag.name === "param") {
const match = /^(\S+)\s*-?\s*(.*)$/s.exec(value);
if (match) result.params.set(match[1], match[2]);
} else if (tag.name === "returns" || tag.name === "return") result.returns = value;
else if (tag.name === "throws") result.throws.push(value);
}
return result;
}

function sourceInterface(symbol) {
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
let current = declaration;
while (current && !ts.isInterfaceDeclaration(current)) current = current.parent;
return current?.name.text;
}

export function extractPeripheral(program, interfaceName) {
const checker = program.getTypeChecker();
const matches = [];
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile && sourceFile.fileName.includes("typescript/lib/")) continue;
ts.forEachChild(sourceFile, (node) => {
if (ts.isInterfaceDeclaration(node) && node.name.text === interfaceName) matches.push(node);
});
}
if (matches.length !== 1) throw new Error(`Expected one ${interfaceName} interface, found ${matches.length}`);
const declaration = matches[0];
let peripheralType = null;
for (const sourceFile of program.getSourceFiles()) {
const visit = (node) => {
if (ts.isNewExpression(node)
&& node.typeArguments?.[0]?.getText() === interfaceName
&& node.arguments?.[0]
&& ts.isStringLiteral(node.arguments[0])) {
peripheralType = node.arguments[0].text;
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
}
if (!peripheralType) throw new Error(`No peripheral provider found for ${interfaceName}`);
const type = checker.getTypeAtLocation(declaration);
const methods = checker.getPropertiesOfType(type).flatMap((symbol) => {
const methodType = checker.getTypeOfSymbolAtLocation(symbol, declaration);
const signatures = checker.getSignaturesOfType(methodType, ts.SignatureKind.Call);
if (!signatures.length) return [];
return [{
name: symbol.name,
inheritedFrom: sourceInterface(symbol) === interfaceName ? null : sourceInterface(symbol),
signatures: signatures.map((signature) => {
const tags = tagsFor(signature, checker);
const parameters = signature.getParameters().map((parameter) => {
const parameterDeclaration = parameter.valueDeclaration ?? parameter.declarations?.[0] ?? declaration;
return {
name: parameter.name,
type: checker.typeToString(checker.getTypeOfSymbolAtLocation(parameter, parameterDeclaration), declaration, ts.TypeFormatFlags.NoTruncation),
optional: Boolean(parameter.flags & ts.SymbolFlags.Optional) || Boolean(parameterDeclaration.questionToken),
description: tags.params.get(parameter.name) ?? "",
};
});
return {
signature: `${symbol.name}${checker.signatureToString(signature, declaration, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope)}`,
summary: text(signature.getDocumentationComment(checker)),
parameters,
returns: tags.returns,
throws: tags.throws,
};
}),
}];
});
if (!methods.length) throw new Error(`${interfaceName} has no methods`);
return { interfaceName, peripheralType, methods };
}

function escapeCell(value) {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}

export function renderPeripheral(peripheral, revision = "") {
const lines = [
"## Peripheral methods",
"",
"The reference below is generated from the published TypeScript interface.",
"",
];
for (const method of peripheral.methods) {
lines.push(`### \`${method.name}\``, "");
if (method.inheritedFrom) lines.push(`*Inherited from \`${method.inheritedFrom}\`.*`, "");
for (const [index, signature] of method.signatures.entries()) {
if (method.signatures.length > 1) lines.push(`#### Overload ${index + 1}`, "");
lines.push("```typescript", signature.signature, "```", "");
if (signature.summary) lines.push(signature.summary, "");
if (signature.parameters.length) {
lines.push("| Parameter | Type | Description |", "| --- | --- | --- |");
for (const parameter of signature.parameters) {
const name = `\`${parameter.name}${parameter.optional ? "?" : ""}\``;
lines.push(`| ${name} | \`${escapeCell(parameter.type)}\` | ${escapeCell(parameter.description)} |`);
}
lines.push("");
}
if (signature.returns) lines.push(`**Returns:** ${signature.returns}`, "");
for (const error of signature.throws) lines.push(`**Throws:** ${error}`, "");
}
}
if (revision) lines.push(`[View TypeScript source](https://github.com/SirEdvin/DigitalItems/tree/${revision}/projects/typed-peripheral-digitalitems)`, "");
return lines.join("\n");
}

function options(args) {
const result = { out: resolve(projectRoot, "docs"), revision: "", siteUrl: "" };
for (let index = 0; index < args.length; index += 2) {
const value = args[index + 1];
if (!value) throw new Error(`Missing value for ${args[index]}`);
if (args[index] === "--out") result.out = resolve(value);
else if (args[index] === "--gitRevision") result.revision = value;
else if (args[index] === "--hostedBaseUrl") result.siteUrl = value;
else throw new Error(`Unknown option: ${args[index]}`);
}
return result;
}

export async function buildDocs(args = []) {
const config = options(args);
const buildRoot = resolve(projectRoot, ".mkdocs-build");
await rm(buildRoot, { recursive: true, force: true });
await cp(resolve(projectRoot, "documentation"), buildRoot, {
recursive: true,
filter: (path) => !path.endsWith("generate-docs.mjs") && !path.endsWith("generate-docs.test.mjs") && !path.includes("/theme/"),
});
await mkdir(resolve(buildRoot, "assets/peripherals"), { recursive: true });
const textureRoot = resolve(projectRoot, "../core/src/main/resources/assets/digitalitems/textures/block");
await cp(resolve(textureRoot, "digitizer_front_on.png"), resolve(buildRoot, "assets/peripherals/digitizer.png"));
await cp(resolve(textureRoot, "advanced_digitizer_front_on.png"), resolve(buildRoot, "assets/peripherals/advanced_digitizer.png"));
await mkdir(resolve(buildRoot, "assets/stylesheets"), { recursive: true });
await mkdir(resolve(buildRoot, "assets/javascripts"), { recursive: true });
await cp(resolve(projectRoot, "documentation/theme/digitalitems.css"), resolve(buildRoot, "assets/stylesheets/digitalitems.css"));
await cp(resolve(projectRoot, "documentation/theme/digitalitems.js"), resolve(buildRoot, "assets/javascripts/digitalitems.js"));
await cp(resolve(projectRoot, "documentation/theme/favicon.svg"), resolve(buildRoot, "assets/favicon.svg"));

const parsed = ts.parseJsonConfigFileContent(
ts.readConfigFile(resolve(projectRoot, "tsconfig.json"), ts.sys.readFile).config,
ts.sys,
projectRoot,
{ noEmit: true, strictNullChecks: true },
);
const program = ts.createProgram(parsed.fileNames, parsed.options);
const errors = ts.getPreEmitDiagnostics(program);
if (errors.length) throw new Error(ts.formatDiagnosticsWithColorAndContext(errors, {
getCanonicalFileName: (name) => name,
getCurrentDirectory: () => projectRoot,
getNewLine: () => "\n",
}));
for (const peripheral of peripherals) {
const path = resolve(buildRoot, peripheral.page);
const markdown = await readFile(path, "utf8");
const extracted = extractPeripheral(program, peripheral.interfaceName);
await writeFile(path, `${markdown.replaceAll("{{ peripheralType }}", extracted.peripheralType).trim()}\n\n${renderPeripheral(extracted, config.revision)}\n`);
}

await rm(config.out, { recursive: true, force: true });
const result = spawnSync("python3", ["-m", "mkdocs", "build", "--strict", "--clean", "--site-dir", config.out], {
cwd: projectRoot,
env: config.siteUrl ? { ...process.env, DOCS_SITE_URL: config.siteUrl } : process.env,
encoding: "utf8",
});
if (result.status !== 0) throw new Error((result.stderr || result.stdout).trim());
await rm(buildRoot, { recursive: true, force: true });
return relative(projectRoot, config.out);
}

if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
buildDocs(process.argv.slice(2)).catch((error) => {
process.stderr.write(`generate-docs: ${error.message}\n`);
process.exitCode = 1;
});
}
Loading
Loading