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
27 changes: 27 additions & 0 deletions .github/workflows/docs-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Optional: the primary CI path is running scripts/ci.sh locally (see README).
# This workflow only calls the same script; drop this commit if Actions stays disabled.
name: docs-ci

on:
pull_request:

permissions:
contents: read

jobs:
docs-ci:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Run docs checks
run: scripts/ci.sh
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: docs-ci-logs
path: ci-logs/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ci-logs/
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Decepticon Docs

Source for the Decepticon documentation by Bitter Security, built with [Mintlify](https://mintlify.com).
Site config and navigation live in `docs.json`; pages are MDX under `en/` and `ko/` (kept 1:1).

## Preview locally

```bash
npx mintlify@latest dev
```

## Running checks

CI runs **locally** (we don't use paid GitHub Actions minutes), so run this before opening a PR:

```bash
scripts/ci.sh
```

It runs, in order:

| Step | Command | Fails when |
|---|---|---|
| Build validation | `npx mintlify validate` | `docs.json` / MDX doesn't build |
| Link check | `npx mintlify broken-links` | any internal link points at a missing page |
| EN/KO parity | `node scripts/check-parity.mjs` | a page exists in one language but not the other, a `docs.json` nav entry has no twin in the other language, a nav entry has no file, or a page file isn't in the nav |

Logs go to `/workspace/out/local-ci-<short-sha>/` (`validate.log`, `broken-links.log`, `parity.log`, `summary.txt`);
override with `CI_OUT_ROOT=/some/dir`. The mintlify CLI version is pinned in the script; override with `MINTLIFY_VERSION=latest`.
Requires Node 20+.

When you add a page, add both `en/<path>.mdx` and `ko/<path>.mdx` and list both in `docs.json`.

`.github/workflows/docs-ci.yml` (optional) runs the same script on pull requests if Actions is ever enabled.
82 changes: 82 additions & 0 deletions scripts/check-parity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// EN <-> KO parity check for the Mintlify docs.
//
// Checks, relative to the repo root (or the dir given as argv[2]):
// 1. Page files: every en/**/*.mdx has a ko/**/*.mdx twin at the same relative path, and vice versa.
// 2. docs.json nav: every page listed under the "en" language has a matching "ko" entry, and vice versa.
// 3. Nav <-> files: every nav entry resolves to an existing .mdx/.md file, and every page file is in the nav.
//
// Exits 1 and lists every mismatch if anything is out of sync; exits 0 otherwise.
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, relative, sep } from "node:path";

const root = process.argv[2] ?? process.cwd();
const LANGS = ["en", "ko"];
const PAGE_EXT = /\.(mdx|md)$/;

function walk(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (PAGE_EXT.test(name)) out.push(p);
}
return out;
}

// "en/foo/bar.mdx" -> "foo/bar"
const filePages = {};
for (const lang of LANGS) {
filePages[lang] = new Set(
walk(join(root, lang)).map((p) =>
relative(join(root, lang), p).split(sep).join("/").replace(PAGE_EXT, ""),
),
);
}

// Collect page slugs from the docs.json navigation for each language.
function collectPages(node, acc) {
if (typeof node === "string") acc.push(node);
else if (Array.isArray(node)) node.forEach((n) => collectPages(n, acc));
else if (node && typeof node === "object") {
for (const key of ["pages", "groups", "tabs", "anchors", "dropdowns", "versions", "menu"]) {
if (node[key]) collectPages(node[key], acc);
}
}
return acc;
}

const docs = JSON.parse(readFileSync(join(root, "docs.json"), "utf8"));
const navLangs = docs?.navigation?.languages ?? [];
const navPages = {};
for (const lang of LANGS) {
const entry = navLangs.find((l) => l.language === lang);
navPages[lang] = new Set(
entry ? collectPages(entry, []).map((p) => p.replace(new RegExp(`^/?${lang}/`), "")) : [],
);
if (!entry) navPages[lang].missingLanguage = true;
}

const problems = [];
const diff = (a, b) => [...a].filter((x) => !b.has(x)).sort();

for (const lang of LANGS) if (navPages[lang].missingLanguage) problems.push(`docs.json: no navigation.languages entry for "${lang}"`);

const [A, B] = LANGS;
for (const [x, y] of [[A, B], [B, A]]) {
for (const p of diff(filePages[x], filePages[y])) problems.push(`file: ${x}/${p} exists but ${y}/${p} is missing`);
for (const p of diff(navPages[x], navPages[y])) problems.push(`nav: ${x}/${p} is in docs.json but ${y}/${p} is not`);
}
for (const lang of LANGS) {
for (const p of diff(navPages[lang], filePages[lang])) problems.push(`nav->file: ${lang}/${p} is in docs.json but has no .mdx/.md file`);
for (const p of diff(filePages[lang], navPages[lang])) problems.push(`file->nav: ${lang}/${p} exists but is not in docs.json navigation`);
}

const counts = LANGS.map((l) => `${l}: ${filePages[l].size} files / ${navPages[l].size} nav`).join(", ");
if (problems.length) {
console.error(`parity: FAILED (${counts})`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
console.log(`parity: OK (${counts})`);
51 changes: 51 additions & 0 deletions scripts/ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Local docs CI: Mintlify build validation, broken-link check, EN<->KO parity.
#
# This is the primary CI path (GitHub Actions is not used for billing reasons;
# the workflow in .github/workflows/docs-ci.yml is optional and just calls this).
#
# Usage: scripts/ci.sh
# Env:
# MINTLIFY_VERSION mintlify CLI version for npx (default: pinned below; "latest" also works)
# CI_OUT_ROOT where log dirs go (default: /workspace/out locally, ./ci-logs under $CI)
#
# Logs: $CI_OUT_ROOT/local-ci-<short-sha>/{validate,broken-links,parity}.log + summary.txt
set -uo pipefail

cd "$(dirname "$0")/.."

MINTLIFY_VERSION="${MINTLIFY_VERSION:-4.2.1126}"
SHA="$(git rev-parse --short HEAD 2>/dev/null || echo nogit)"
if ! git diff --quiet HEAD 2>/dev/null; then SHA="${SHA}-dirty"; fi
if [[ -n "${CI:-}" ]]; then DEFAULT_OUT_ROOT="$PWD/ci-logs"; else DEFAULT_OUT_ROOT="/workspace/out"; fi
OUT_DIR="${CI_OUT_ROOT:-$DEFAULT_OUT_ROOT}/local-ci-${SHA}"
mkdir -p "$OUT_DIR"

MINT=(npx --yes "mintlify@${MINTLIFY_VERSION}")
declare -a RESULTS=()
FAILED=0

run_step() {
local name="$1"; shift
local log="$OUT_DIR/${name}.log"
echo "==> ${name}: $*"
if "$@" >"$log" 2>&1; then
RESULTS+=("PASS ${name} (${log})")
else
RESULTS+=("FAIL ${name} (${log})")
FAILED=1
tail -n 40 "$log" | sed 's/^/ /'
fi
}

run_step validate "${MINT[@]}" validate
run_step broken-links "${MINT[@]}" broken-links
run_step parity node scripts/check-parity.mjs

{
echo "docs local CI @ ${SHA} ($(date '+%Y-%m-%d %H:%M:%S %Z'))"
echo "mintlify@${MINTLIFY_VERSION}, node $(node --version)"
printf '%s\n' "${RESULTS[@]}"
} | tee "$OUT_DIR/summary.txt"

exit "$FAILED"
Loading