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
342 changes: 342 additions & 0 deletions .playwright-mcp/page-2026-08-26T23-58-30-111Z.yml

Large diffs are not rendered by default.

347 changes: 347 additions & 0 deletions .playwright-mcp/page-2026-08-26T23-58-40-711Z.yml

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ registrar completes (PRD §11). SEO strategy and roadmap:
- **Static-first Astro** (zero client JavaScript), deployed to Cloudflare
Pages from `main`; every PR gets a preview.
- **Docs pipeline (FR-4)** — [engine-docs.manifest.json](engine-docs.manifest.json)
pins the engine documents; `npm run build` fetches them at build time and
**fails loudly** if one moves. Documents are never hand-copied.
pins the engine documents at keel's latest published release tag, resolved at
build time (#85), so the site describes what an operator runs; `npm run build`
fetches them and **fails loudly** if one moves. Documents are never
hand-copied.
- **News (FR-5)** — the Announcements category of GitHub Discussions, read via
the public REST endpoint (no token, no Worker needed — see the note in
docs/DEPLOYMENT.md), refreshed by an hourly rebuild workflow.
Expand Down
18 changes: 15 additions & 3 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,21 @@ not the pull request.
- **A build that cannot fetch a pinned engine doc fails on purpose** (FR-4).
Fix by updating `engine-docs.manifest.json` to the document's new path —
never by hand-copying content into `src/content/engine-docs/` (gitignored).
- **Release / Discussions fetch failures degrade, not fail**: the build keeps
the last-known `data/*.json`; the Install and News pages show their GitHub
fallbacks. `data/` contents are build artifacts and never committed.
- **The docs ref is a release tag, not a branch** (#85). `npm run fetch` runs
`fetch-release.mjs` first, so `fetch-engine-docs.mjs` reads the tag from
`data/release.json` without a second call to the releases API. If no tag can
be resolved at all, the docs fetch exits non-zero: there is deliberately no
fallback to `main`, which is the skew the pin removes. The last-known-tag
fallback in `release-tag.mjs` is a local convenience only — `data/` is
gitignored and no workflow caches it, so in CI the releases API is the sole
source and a failure stops the build.
- **Release / Discussions fetch failures degrade, not fail** *for page content*:
the build keeps the last-known `data/*.json`; the Install and News pages show
their GitHub fallbacks. `data/` contents are build artifacts and never
committed. One exception since #85 — the **tag** the release fetch resolves is
load-bearing for the docs pin above, so a build that cannot resolve one stops
rather than degrading. The live site is unaffected: Cloudflare Pages keeps
serving the last successful deployment.
- **Adding French (FR dry-run, Success criterion 8)**: copy the thin wrappers
into `src/pages/fr/`, add `fr` dictionaries under `src/i18n/pages/`, and
extend `locales` in `src/i18n/config.ts` plus the sitemap i18n map in
Expand Down
15 changes: 10 additions & 5 deletions docs/LAUNCH-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ Verified 2026-08-22:
## 5. Docs pages fail the build when an engine doc moves — GREEN

`scripts/fetch-engine-docs.mjs` fetches every document pinned in
`engine-docs.manifest.json`; on a 404 or network error it records the failure and
ends with `process.exit(1)` ("The build stops here by design (FR-4): never render
stale or missing docs."). CI surfaces this legibly: `.github/workflows/ci.yml` step
`engine-docs.manifest.json`, at keel's latest published release tag — resolved at
build time since #85, never at `main`. On a 404 or network error it records the
failure and ends with `process.exit(1)` ("The build stops here by design (FR-4):
never render stale or missing docs."). CI surfaces this legibly: `.github/workflows/ci.yml` step
**"Fetch engine docs, release, discussions"** (`npm run fetch`) runs before type
check and build; `deploy.yml` chains the same fetch inside `npm run build`, so a
moved engine doc red-Xes Checks and blocks the deploy instead of publishing a
Expand All @@ -93,8 +94,12 @@ stale page.
EN install **100/100**, FR compare **100/100**.

Both runs sit comfortably above the ≥95/≥95 budget. The site ships no client-side
JavaScript framework or bundle by design — two small inline scripts only: the
light/dark choice and the code-block copy button.
JavaScript framework or bundle by design — three behaviors only: the light/dark
choice, the code-block copy button, and the docs version notice (#85). Counted as
tags that is four blocks on an English engine document page (the light/dark
choice is a bootstrap plus a toggle) and three everywhere else.
The third one ships on English engine document pages only, and does nothing at
all unless keel sends a `?v=` that differs from the tag the docs were built from.

## 7. Honesty review green in EN and AR before launch — GREEN (reviewed EN + AR + FR)

Expand Down
3 changes: 2 additions & 1 deletion engine-docs.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"repo": "CodeGateSoftware/keel",
"ref": "main",
"ref": "latest-release",
"refNote": "Sentinel, read by scripts/fetch-engine-docs.mjs (#85): resolve to the latest published release tag at build time, so the site describes what an operator actually runs. A literal branch, tag or SHA here is used verbatim instead. There is no fallback to main: if no tag resolves, the build fails.",
"docs": [
{
"path": "docs/glossary.md",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"fetch": "node scripts/fetch-engine-docs.mjs && node scripts/fetch-release.mjs && node scripts/fetch-discussions.mjs",
"fetch": "node scripts/fetch-release.mjs && node scripts/fetch-engine-docs.mjs && node scripts/fetch-discussions.mjs",
"dev": "npm run fetch && astro dev",
"build": "npm run fetch && astro build",
"preview": "astro preview",
Expand Down
47 changes: 42 additions & 5 deletions scripts/fetch-engine-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,53 @@
*
* Relative markdown links inside fetched documents are rewritten to absolute
* GitHub blob URLs so they resolve from this origin; document text is untouched.
*
* #85 — the ref is the latest published release tag, not `main`. An operator
* runs a release, so the documents this site renders must be the documents that
* release shipped. The manifest keeps the policy in its `ref` field as the
* sentinel "latest-release"; a literal branch, tag or SHA is still honored.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { LATEST_RELEASE, resolveLatestReleaseTag } from "./lib/release-tag.mjs";

const root = dirname(dirname(fileURLToPath(import.meta.url)));
const manifest = JSON.parse(
readFileSync(join(root, "engine-docs.manifest.json"), "utf8"),
);

/**
* The ref every fetch, rewritten link and source URL below points at. When the
* release tag cannot be resolved the build stops: falling back to `main` would
* silently restore the skew this pin exists to remove.
*/
/**
* Everything below reaches the build log by way of the releases API — the tag
* itself, and the `source` note, which quotes a network error verbatim. The tag
* is already validated in release-tag.mjs; this strips control characters and
* caps the length so no remote string can forge a line in the build log, which
* is read to decide whether a deploy is trustworthy.
*/
const forLog = (value) =>
String(value).replace(/[\u0000-\u001F\u007F]/g, " ").slice(0, 200);

const docsRef = await (async () => {
if (manifest.ref !== LATEST_RELEASE) return manifest.ref;
const resolved = await resolveLatestReleaseTag(root, manifest.repo);
if (!resolved.tag) {
console.error(
`\nFAIL: could not resolve the latest release tag for ${manifest.repo} — ${forLog(resolved.source)}.`,
);
console.error(
"The docs pipeline pins to a published release and never falls back to main (#85).",
);
process.exit(1);
}
console.log(` docs ref: ${forLog(resolved.tag)} (from ${forLog(resolved.source)})`);
return resolved.tag;
})();

const DOCS_DIR = join(root, "src/content/engine-docs");
const META_FILE = join(root, "data/docs-meta.json");
mkdirSync(DOCS_DIR, { recursive: true });
Expand Down Expand Up @@ -88,14 +125,14 @@ const failures = [];
const fetchedAt = new Date().toISOString();
const meta = {
repo: manifest.repo,
ref: manifest.ref,
ref: docsRef,
fetchedAt,
sections: manifest.sections ?? [],
docs: [],
};

for (const doc of manifest.docs) {
const url = `https://raw.githubusercontent.com/${manifest.repo}/${manifest.ref}/${doc.path}`;
const url = `https://raw.githubusercontent.com/${manifest.repo}/${docsRef}/${doc.path}`;
let response;
try {
response = await fetch(url, { headers: { "user-agent": "keeltrading.com-docs-fetch" } });
Expand All @@ -106,7 +143,7 @@ for (const doc of manifest.docs) {
if (!response.ok) {
failures.push(
`${doc.path}: HTTP ${response.status} at ${url}\n` +
` The document may have moved in ${manifest.repo}@${manifest.ref}. ` +
` The document may have moved in ${manifest.repo}@${docsRef}. ` +
`Update engine-docs.manifest.json to the new path — do not hand-copy the doc.`,
);
continue;
Expand All @@ -116,7 +153,7 @@ for (const doc of manifest.docs) {
// the page's single H1 (one-h1-per-page). Heading IDs are unaffected.
markdown = markdown.replace(/^#\s+.+\n/, "");
markdown = demoteTopLevelHeadings(markdown);
markdown = rewriteRelativeLinks(markdown, doc.path, manifest.repo, manifest.ref);
markdown = rewriteRelativeLinks(markdown, doc.path, manifest.repo, docsRef);
writeFileSync(join(DOCS_DIR, `${doc.slug}.md`), markdown);
meta.docs.push({
slug: doc.slug,
Expand All @@ -126,7 +163,7 @@ for (const doc of manifest.docs) {
ar: doc.ar,
fr: doc.fr ?? doc.en,
section: doc.section ?? "reference",
sourceUrl: `https://github.com/${manifest.repo}/blob/${manifest.ref}/${doc.path}`,
sourceUrl: `https://github.com/${manifest.repo}/blob/${docsRef}/${doc.path}`,
});
console.log(` fetched ${doc.path} -> engine-docs/${doc.slug}.md`);
}
Expand Down
104 changes: 104 additions & 0 deletions scripts/lib/release-tag.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Resolve the keel release tag this build describes (#85).
*
* Why a tag and not `main`: an operator runs a published release, so the site
* must describe a published release. The install page already learned this the
* hard way — it described a script served from `main`, and `main` moved the
* Python floor from 3.11 to 3.14 underneath the copy (PR #88). Pinning the docs
* pipeline to the released tag is the same correction, applied upstream of the
* prose: FR-9 says this site describes what shipped.
*
* Resolution order, cheapest first:
*
* 1. data/release.json, when fetch-release.mjs wrote it recently. `npm run
* fetch` runs that script first, so the normal build path costs zero extra
* calls to the releases API.
* 2. The releases API — one call, for a standalone run of the docs fetch.
* 3. The last-known tag in data/release.json, when the API is unreachable.
* That is still a real release tag, and the ref it resolves to is recorded
* in data/docs-meta.json and printed on every doc page, so a reader can
* see exactly which version the page describes.
*
* This tier is a local convenience, not CI resilience: `data/` is
* gitignored and no workflow caches it, so on the machine that actually
* ships the site the file never pre-exists. In CI the behaviour is binary
* — the releases API answers, or the build stops.
*
* There is deliberately no fallback to `main`. A silent fallback would
* reintroduce the skew this whole change exists to remove. When no tag can be
* resolved at all, the caller fails the build.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

/** Manifest sentinel: "whatever the latest published release is at build time". */
export const LATEST_RELEASE = "latest-release";

/** How recent data/release.json must be to be reused without an API call. */
const FRESH_MS = 60 * 60 * 1000;

/**
* A git tag this pipeline is willing to trust. The resolved tag is interpolated
* into raw.githubusercontent URLs, GitHub blob URLs and build logs, so it is
* validated at the boundary rather than anywhere downstream: whatever the
* releases API hands back, only a plain version tag gets past this line.
*
* Deliberately narrow. keel tags releases `v0.11.2`; anything that does not
* look like that is treated as unresolved, and the caller fails the build.
*/
const TAG_PATTERN = /^v?[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]{1,32})?$/;

Check warning on line 49 in scripts/lib/release-tag.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=CodeGateSoftware_keeltrading.com&issues=AaBAgZbtTL4WCHkwYlP-&open=AaBAgZbtTL4WCHkwYlP-&pullRequest=92

Check warning on line 49 in scripts/lib/release-tag.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=CodeGateSoftware_keeltrading.com&issues=AaBAgZbtTL4WCHkwYlP_&open=AaBAgZbtTL4WCHkwYlP_&pullRequest=92

/** @param {unknown} tag @returns {string|null} the tag, or null if untrusted. */
function trustedTag(tag) {
return typeof tag === "string" && TAG_PATTERN.test(tag) ? tag : null;
}

function readReleaseFile(root) {
const file = join(root, "data/release.json");
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch {
return null;
}
}

/**
* @param {string} root project root
* @param {string} repo owner/name, e.g. CodeGateSoftware/keel
* @returns {Promise<{tag: string|null, source: string}>} tag is null when
* nothing could be resolved; source explains where the tag came from.
*/
export async function resolveLatestReleaseTag(root, repo) {
const cached = readReleaseFile(root);
const cachedAge = cached?.fetchedAt ? Date.now() - Date.parse(cached.fetchedAt) : Number.NaN;
const cachedTag = trustedTag(cached?.tag);
if (cachedTag && cachedAge >= 0 && cachedAge < FRESH_MS) {
return { tag: cachedTag, source: "data/release.json, written this build" };
}

const headers = {
accept: "application/vnd.github+json",
"user-agent": "keeltrading.com-docs-fetch",
};
if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;

try {
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const release = await response.json();
const apiTag = trustedTag(release?.tag_name);
if (!apiTag) throw new Error("the releases API returned no usable tag_name");
return { tag: apiTag, source: "the GitHub releases API" };
} catch (error) {
if (cachedTag) {
return {
tag: cachedTag,
source: `data/release.json, last known — the releases API is unreachable (${error.message})`,
};
}
return { tag: null, source: `the releases API is unreachable (${error.message})` };
}
}
97 changes: 97 additions & 0 deletions src/components/DocsVersionNotice.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
import { localePath } from "../i18n/config";
import { t } from "../i18n/ui";

/**
* #85 — the version-skew notice on an engine document page.
*
* keel's browser UI deep-links here carrying the version it is running:
* /en/docs/glossary/?v=0.11.2#qabd. The docs pipeline pins to the latest
* published release tag, so most readers match and see nothing. When they do
* not match, this says so plainly instead of letting the reader assume the page
* describes their build.
*
* The site is `output: "static"`, so no query parameter exists at build time:
* the comparison happens in the browser. The markup ships hidden and complete,
* and the inline script below only fills one text node and unhides it — the
* same "labels as data attributes, behaviour in one small inline script"
* pattern as the code-block copy button. Because the element is in the document
* from the first parse and is unhidden during parsing, a `#anchor` in the URL
* still lands where it should; the script never reads or writes location.hash.
*
* Anything unexpected in `?v=` renders nothing at all: an unrecognized value is
* not evidence of skew.
*
* English only, by design and by routing: engine documents are published in
* English and `[slug].astro` exists only under /en/ (the ar and fr editions
* ship a docs index, not per-document pages). The Arabic and French strings
* exist in ui.ts so the chrome stays complete if that ever changes.
*/
interface Props {
/** The ref the page was built from — data/docs-meta.json's `ref`. */
builtRef: string;
}

const { builtRef } = Astro.props;
const chrome = t("en");

/**
* Only a version tag is comparable. A branch or SHA has nothing to compare.
*
* Anchored at both ends deliberately. Unanchored, a literal SHA in the
* manifest's `ref` — the documented escape hatch — would render a notice
* reading "these pages describe 1d1799e" when the SHA happens to start with a
* digit, and nothing when it starts with a letter.
*/
const comparable = /^v?\d+(\.\d+)*$/.test(builtRef);

// Rendered once with placeholders; the script substitutes both versions, so the
// script itself stays free of any locale's wording.
const template = chrome.docs.versionSkew("{running}", "{built}");
---

{
comparable && (
<div
class="stale-banner"
id="docs-version-notice"
role="note"
hidden
data-built-ref={builtRef}
data-skew-template={template}
>
<strong>{chrome.docs.versionSkewTitle}</strong>
<span data-skew-body />{" "}
<a href={localePath("en", "changelog")}>{chrome.docs.versionSkewLink} →</a>
</div>
)
}

{
comparable && (
<script is:inline>
(function () {
try {
var el = document.getElementById("docs-version-notice");
if (!el) return;
var raw = new URLSearchParams(window.location.search).get("v");
if (!raw || raw.length > 40) return;
// A plain version string only. Anything else is ignored outright.
if (!/^v?[0-9]+(\.[0-9]+)*([-+.][0-9A-Za-z.+-]*)?$/.test(raw)) return;
var strip = function (value) {
return value.trim().replace(/^v/i, "");
};
var running = strip(raw);
var built = strip(el.getAttribute("data-built-ref") || "");
if (!built || running === built) return;
var body = el.querySelector("[data-skew-body]");
var template = el.getAttribute("data-skew-template") || "";
if (!body || !template) return;
// textContent, never innerHTML: `running` comes from the URL.
body.textContent = template.replace("{running}", running).replace("{built}", built);
el.hidden = false;
} catch (e) {}
})();
</script>
)
}
Loading
Loading