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
4 changes: 4 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# docs:gather stamps each page with its source file's last commit
# date, which feeds the sitemap's <lastmod>. A shallow clone has a
# single commit, so every page would date to the clone day.
fetch-depth: 0
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
Expand Down
3 changes: 2 additions & 1 deletion packages/docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
/content

# Generated static artifacts (.md siblings, llms.txt, llms-full.txt).
# Keep committed assets (img/) and the placeholder.
# Keep committed assets (img/, robots.txt) and the placeholder.
/static/*
!/static/.gitkeep
!/static/img/
!/static/robots.txt

node_modules
*.log
6 changes: 6 additions & 0 deletions packages/docs/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ const config: Config = {
path: contentPath,
routeBasePath: '/',
sidebarPath: './sidebars.ts',
showLastUpdateTime: true,
},
blog: false,
sitemap: {
lastmod: 'date',
changefreq: null,
priority: null,
},
theme: {
customCss: './src/css/custom.css',
},
Expand Down
138 changes: 127 additions & 11 deletions packages/docs/scripts/lib/gather.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,17 @@ async function readNodeMeta(nodeDir) {
return { classType, title, description: extractDescription(text) };
}

/** Extract a first-sentence description from a raw services*.json text string. */
/**
* Extract a first-sentence description from a raw services*.json text string.
* The JSON `description` is an array of complete lines, so they join with a
* space — joining bare runs them together ("a node.Can be invoked"), which also
* defeats the '. ' sentence split below and returns the whole blob.
*/
function extractDescription(text) {
const m = /"description"\s*:\s*\[([^\]]*)\]/.exec(text);
if (!m) return '';
const parts = m[1].match(/"((?:[^"\\]|\\.)*)"/g) || [];
const full = parts.map((s) => s.slice(1, -1)).join('').trim();
const full = parts.map((s) => s.slice(1, -1)).join(' ').replace(/\s+/g, ' ').trim();
const dot = full.indexOf('. ');
return dot >= 0 ? full.slice(0, dot + 1) : full;
}
Expand Down Expand Up @@ -213,6 +218,62 @@ function frontMatterTitle(content) {
return t ? t[2].replace(/^['"]|['"]$/g, '') : null;
}

/**
* First-sentence description for a markdown/MDX page, mirroring what Docusaurus
* derives for its meta description. A front-matter `description:` wins when the
* page declares one; otherwise the first prose paragraph is used, skipping MDX
* imports, JSX, headings, code fences, tables and admonitions. Node pages get
* their description from services*.json instead (see extractDescription).
* @param {string} content - raw page content (md/mdx).
* @return {string} description, or '' when the page has no leading prose.
*/
function pageDescription(content) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
if (fm) {
const d = /(^|\n)description:[ \t]*(.*)/.exec(fm[1]);
if (d) {
const inline = d[2].trim();
if (/^[>|][-+\d]*$/.test(inline)) {
// YAML block scalar (`description: >`, `|`, `>-`, …). The text is the
// indented run that follows; the indicator itself is not the value.
const block = [];
for (const line of fm[1].slice(d.index + d[0].length).split(/\r?\n/).slice(1)) {
if (!/^[ \t]+\S/.test(line)) break;
block.push(line.trim());
}
Comment thread
EdwardLien0426 marked this conversation as resolved.
if (block.length) return block.join(' ');
} else if (inline) {
return inline.replace(/^['"]|['"]$/g, '');
}
}
}
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
const para = [];
let inFence = false;
for (const line of body.split(/\r?\n/)) {
const t = line.trim();
// Skip fenced blocks wholesale — the fence delimiters AND their contents.
if (/^(```|~~~)/.test(t)) {
if (para.length) break;
inFence = !inFence;
continue;
}
if (inFence) continue;
if (!t) {
if (para.length) break;
continue;
}
if (/^(#{1,6}\s|:::|<!--|\||[<{]|import\s|export\s|-{3,})/.test(t)) {
if (para.length) break;
continue;
}
Comment thread
EdwardLien0426 marked this conversation as resolved.
para.push(t);
}
const prose = para.join(' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*`_]/g, '').trim();
const m = /^(.+?[.!?])(\s|$)/.exec(prose);
return (m ? m[1] : prose).slice(0, 200).trim();
}

/** First markdown heading, as a title fallback. */
function headingTitle(content) {
const m = /^#\s+(.+?)\s*$/m.exec(content);
Expand Down Expand Up @@ -261,15 +322,68 @@ function discoverContributors() {
return contributors;
}

/** Remove generated artifacts from static/ (keep .gitkeep and img/). */
/** Remove generated artifacts from static/ (keep .gitkeep, img/, and committed files). */
async function clearGeneratedStatic(staticDir) {
if (!(await exists(staticDir))) return;
const KEEP = new Set(['.gitkeep', 'img', 'robots.txt']);
for (const name of await readDir(staticDir)) {
if (name === '.gitkeep' || name === 'img') continue;
if (KEEP.has(name)) continue;
await rm(path.join(staticDir, name));
}
}

// --- last_update stamping ----------------------------------------------------
// The assembled content tree under BUILD_ROOT is not git-tracked, so Docusaurus
// cannot infer page dates there (`showLastUpdateTime` finds nothing and the
// sitemap emits no <lastmod>). Stamp each staged page's front matter with the
// SOURCE file's last git commit date instead — Docusaurus prefers a
// `last_update` front-matter entry over git when present.
const { execFileSync } = require('node:child_process');
const _gitDateCache = new Map();

/**
* Last commit date (YYYY-MM-DD) of a source file, or null outside a git
* checkout / for untracked files. Cached per path. Requires git history at
* build time — a shallow CI clone collapses every date to the clone day, so
* the docs job should use fetch-depth: 0.
* @param {string} srcAbs - absolute path of the git-tracked source file.
* @return {string|null}
*/
function gitLastUpdate(srcAbs) {
if (_gitDateCache.has(srcAbs)) return _gitDateCache.get(srcAbs);
let date = null;
try {
date = execFileSync('git', ['log', '-1', '--format=%cs', '--', srcAbs], {
cwd: path.dirname(srcAbs), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
}).trim() || null;
} catch {
/* not a git checkout — leave null; the page simply carries no date */
}
_gitDateCache.set(srcAbs, date);
return date;
}

/**
* Merge `last_update: {date}` into a page's front matter (creating the block
* when absent). No-op when date is null or the page already declares one.
* @param {string} content - staged page content (md/mdx).
* @param {string|null} date - YYYY-MM-DD from gitLastUpdate().
* @return {string}
*/
function stampLastUpdate(content, date) {
if (!date) return content;
// Scope the check to the front matter — a `last_update:` line in the body
// (a YAML sample, say) must not silently cost the page its sitemap date.
const declared = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
if (declared && /(^|\n)last_update\s*:/.test(declared[1])) return content;
if (/^---\r?\n/.test(content)) {
const end = content.indexOf('\n---', 4);
if (end !== -1) return `${content.slice(0, end)}\nlast_update:\n date: ${date}${content.slice(end)}`;
return content;
}
return `---\nlast_update:\n date: ${date}\n---\n\n${content}`;
}

/**
* Stage one doc file into the assembled content tree and write its raw pre-MDX
* sibling for the LLM surface.
Expand All @@ -291,7 +405,9 @@ async function stageFile({ srcAbs, destAbs, siblingAbs, content, mode }) {
await copyFile(srcAbs, destAbs); // Windows symlink may need privilege
}
} else {
await copyFile(srcAbs, destAbs);
// Real write instead of a byte copy so the staged page carries the source
// file's git date (the assembled content tree itself is not git-tracked).
await writeFileEnsure(destAbs, stampLastUpdate(content, gitLastUpdate(srcAbs)));
}
// Raw pre-MDX sibling for the LLM surface.
await writeFileEnsure(siblingAbs, content);
Expand Down Expand Up @@ -334,7 +450,7 @@ async function gather({ projectRoot, contentStaticDir, contentDir, staticDir, mo
const destAbs = path.join(contentDir, `${docId || 'index'}${ext}`);
const siblingAbs = path.join(staticDir, `${docId || 'index'}.md`);
await stageFile({ srcAbs, destAbs, siblingAbs, content, mode });
manifest.push({ id: docId || 'index', route: docId ? `/${docId}` : '/', title: frontMatterTitle(content) || headingTitle(content) || docId || 'Home', mdSibling: `/${docId || 'index'}.md`, source: srcAbs });
manifest.push({ id: docId || 'index', route: docId ? `/${docId}` : '/', title: frontMatterTitle(content) || headingTitle(content) || docId || 'Home', mdSibling: `/${docId || 'index'}.md`, source: srcAbs, description: pageDescription(content) });
}
}

Expand Down Expand Up @@ -393,7 +509,7 @@ async function gather({ projectRoot, contentStaticDir, contentDir, staticDir, mo
// index so the parent label is clickable.
const folderRel = toPosix(path.join(NODES_DIR, cat.slug, name));
await writeFileEnsure(path.join(contentDir, folderRel, '_category_.json'), JSON.stringify({ label, collapsed: true, link: { type: 'doc', id: `${folderRel}/index` } }, null, 2));
await writeFileEnsure(path.join(contentDir, folderRel, 'index.md'), stageNodeMarkdown(content, { slug: `/${route}`, title: existingTitle ? null : label }));
await writeFileEnsure(path.join(contentDir, folderRel, 'index.md'), stampLastUpdate(stageNodeMarkdown(content, { slug: `/${route}`, title: existingTitle ? null : label }), gitLastUpdate(srcAbs)));
await writeFileEnsure(path.join(staticDir, `${route}.md`), content);
manifest.push({ id: route, route: `/${route}`, title: label, mdSibling: `/${route}.md`, source: srcAbs, node: name, category: cat.label, categoryOrder: cat.position, description });
const serviceDescriptions = await readServiceDescriptions(nodeDir);
Expand All @@ -406,7 +522,7 @@ async function gather({ projectRoot, contentStaticDir, contentDir, staticDir, mo
const vExistingTitle = frontMatterTitle(vcontent);
const vlabel = vExistingTitle || nodeLabel(variant, '');
const vdescription = variantDescription(variant, serviceDescriptions);
await writeFileEnsure(path.join(contentDir, folderRel, `${variant}.md`), stageNodeMarkdown(vcontent, { slug: `/${vroute}`, title: vExistingTitle ? null : vlabel }));
await writeFileEnsure(path.join(contentDir, folderRel, `${variant}.md`), stampLastUpdate(stageNodeMarkdown(vcontent, { slug: `/${vroute}`, title: vExistingTitle ? null : vlabel }), gitLastUpdate(vsrcAbs)));
await writeFileEnsure(path.join(staticDir, `${vroute}.md`), vcontent);
manifest.push({ id: vroute, route: `/${vroute}`, title: vlabel, mdSibling: `/${vroute}.md`, source: vsrcAbs, node: name, variant, category: cat.label, categoryOrder: cat.position, description: vdescription });
}
Expand All @@ -416,7 +532,7 @@ async function gather({ projectRoot, contentStaticDir, contentDir, staticDir, mo
// fills in when missing, and the body H1 is dropped to avoid a duplicate
// heading. Always a real write — a symlink can't carry the edits.
const destAbs = path.join(contentDir, NODES_DIR, cat.slug, `${name}.md`);
await writeFileEnsure(destAbs, stageNodeMarkdown(content, { slug: `/${route}`, title: existingTitle ? null : label }));
await writeFileEnsure(destAbs, stampLastUpdate(stageNodeMarkdown(content, { slug: `/${route}`, title: existingTitle ? null : label }), gitLastUpdate(srcAbs)));
await writeFileEnsure(path.join(staticDir, `${route}.md`), content);
manifest.push({ id: route, route: `/${route}`, title: label, mdSibling: `/${route}.md`, source: srcAbs, node: name, category: cat.label, categoryOrder: cat.position, description });
}
Expand All @@ -438,7 +554,7 @@ async function gather({ projectRoot, contentStaticDir, contentDir, staticDir, mo
const destAbs = path.join(contentDir, `${docId}${ext}`);
const siblingAbs = path.join(staticDir, `${docId}.md`);
await stageFile({ srcAbs: abs, destAbs, siblingAbs, content, mode });
manifest.push({ id: docId, route: `/${docId}`, title: frontMatterTitle(content) || headingTitle(content) || docId, mdSibling: `/${docId}.md`, source: abs, module: owner.module });
manifest.push({ id: docId, route: `/${docId}`, title: frontMatterTitle(content) || headingTitle(content) || docId, mdSibling: `/${docId}.md`, source: abs, module: owner.module, description: pageDescription(content) });
}

// 4. Placeholders for spine slots still lacking content (keeps the build green).
Expand Down Expand Up @@ -502,4 +618,4 @@ async function ensurePlaceholders({ contentDir, staticDir, routes, manifest }) {
}
}

module.exports = { gather, docIdFor };
module.exports = { gather, docIdFor, pageDescription, stampLastUpdate };
6 changes: 5 additions & 1 deletion packages/docs/scripts/lib/llms.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const { sections, sectionFor } = require('./spine');

const SITE_TITLE = 'RocketRide Documentation';
const SITE_DESC = 'Build, run, and ship data + AI pipelines with the RocketRide toolchain.';
// Absolute base for llms.txt links. The llms.txt format specifies URLs, and an
// agent that fetched the file on its own has no base to resolve against.
// Mirrors `url` in docusaurus.config.ts.
const SITE_URL = 'https://docs.rocketride.org';

// One-line description per node type (category label from gather.js
// NODE_CATEGORIES). Used to annotate each heading in the catalog. A label with
Expand Down Expand Up @@ -190,7 +194,7 @@ function llmsIndex(manifest) {
if (!entries || !entries.length) continue;
lines.push(`## ${label}`, '');
for (const e of entries.sort((a, b) => a.route.localeCompare(b.route))) {
lines.push(`- [${e.title}](${e.mdSibling})`);
lines.push(`- [${e.title}](${SITE_URL}${e.mdSibling})${e.description ? `: ${e.description}` : ''}`);
}
lines.push('');
}
Expand Down
99 changes: 99 additions & 0 deletions packages/docs/src/lib/gather.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { pageDescription, stampLastUpdate } = require('../../scripts/lib/gather.js');

describe('pageDescription', () => {
it('prefers a front-matter description when the page declares one', () => {
const md = '---\ntitle: Hi\ndescription: Declared up front.\n---\n\n# Hi\n\nProse instead.\n';
assert.equal(pageDescription(md), 'Declared up front.');
});

// A block scalar's value is the indented run beneath it; the bare '>' or '|'
// indicator must never become the description.
it('reads a folded block-scalar description', () => {
const md = '---\ntitle: T\ndescription: >\n Folded across\n two lines.\n---\n\n# T\n\nProse.\n';
assert.equal(pageDescription(md), 'Folded across two lines.');
});
Comment thread
EdwardLien0426 marked this conversation as resolved.

it('reads a literal block-scalar description', () => {
const md = '---\ntitle: T\ndescription: |\n Literal block.\n---\n\n# T\n\nProse.\n';
assert.equal(pageDescription(md), 'Literal block.');
});

it('reads a chomped block-scalar description', () => {
const md = '---\ntitle: T\ndescription: >-\n Stripped fold.\n---\n\n# T\n\nProse.\n';
assert.equal(pageDescription(md), 'Stripped fold.');
});

it('strips surrounding quotes from an inline description', () => {
assert.equal(pageDescription('---\ntitle: T\ndescription: "Quoted."\n---\n\n# T\n\nProse.\n'), 'Quoted.');
});

it('falls back to the first prose sentence, skipping the heading', () => {
const md = '---\ntitle: Hi\n---\n\n# Hi\n\nA pipeline is a graph. More after the stop.\n';
assert.equal(pageDescription(md), 'A pipeline is a graph.');
});

it('skips MDX imports and JSX to reach the prose', () => {
const md = "---\ntitle: Q\n---\n\nimport { Foo } from 'react-icons';\n\n<Foo />\n\n# Q\n\nPick the path that suits you.\n";
assert.equal(pageDescription(md), 'Pick the path that suits you.');
});

it('strips inline links and emphasis from the extracted sentence', () => {
const md = '# T\n\nSee the [engine](/concepts/engine) and **run** it.\n';
assert.equal(pageDescription(md), 'See the engine and run it.');
});

it('returns an empty string when the page has no leading prose', () => {
assert.equal(pageDescription('---\ntitle: T\n---\n\n# T\n\n```js\ncode();\n```\n'), '');
});
});

describe('stampLastUpdate', () => {
const D = '2026-07-16';

it('merges into an existing front-matter block', () => {
const out = stampLastUpdate('---\ntitle: Hi\n---\n\n# Body\n', D);
assert.match(out, /^---\ntitle: Hi\nlast_update:\n {2}date: 2026-07-16\n---\n/);
});

it('creates a front-matter block when the page has none', () => {
const out = stampLastUpdate('# Body\n', D);
assert.match(out, /^---\nlast_update:\n {2}date: 2026-07-16\n---\n\n# Body\n$/);
});

// Regression: startsWith('---\n') was false on a CRLF checkout, so a SECOND
// front-matter block was prepended and `title` fell out into the body.
it('merges into CRLF front matter rather than prepending a second block', () => {
const out = stampLastUpdate('---\r\ntitle: Hi\r\n---\r\n\r\n# Body\r\n', D);
assert.equal((out.match(/^---\r?$/gm) || []).length, 2, 'exactly one front-matter block');
assert.match(out, /title: Hi/);
assert.match(out, /date: 2026-07-16/);
// title must still be inside the front matter, not stranded in the body.
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(out)[1];
assert.match(fm, /title: Hi/);
});

// The guard reads the front matter only: a `last_update:` line in the body
// (a YAML code sample) must not cost the page its sitemap date.
it('stamps a page whose body merely mentions last_update', () => {
const md = '---\ntitle: T\n---\n\n# T\n\n```yaml\nlast_update:\n date: 2020-01-01\n```\n';
assert.match(stampLastUpdate(md, D), /date: 2026-07-16/);
});

it('is a no-op when the date is null or a date is already declared', () => {
assert.equal(stampLastUpdate('# B\n', null), '# B\n');
const already = '---\nlast_update:\n date: 2020-01-01\n---\n\n# B\n';
assert.equal(stampLastUpdate(already, D), already);
});

it('does not treat a body horizontal rule as the front-matter close', () => {
const out = stampLastUpdate('---\ntitle: Hi\n---\n\n# Body\n\n---\n\nmore\n', D);
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(out)[1];
assert.match(fm, /last_update/);
assert.match(fm, /title: Hi/);
});
});
8 changes: 6 additions & 2 deletions packages/docs/src/lib/llms.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('buildIndex', () => {

const manifest = [
{ id: 'quickstart', route: '/quickstart', title: 'Quickstart', mdSibling: '/quickstart.md' },
{ id: 'concepts/pipelines', route: '/concepts/pipelines', title: 'Pipelines', mdSibling: '/concepts/pipelines.md' },
{ id: 'concepts/pipelines', route: '/concepts/pipelines', title: 'Pipelines', mdSibling: '/concepts/pipelines.md', description: 'A pipeline is a directed graph of nodes.' },
{ id: 'nodes/webhook', route: '/nodes/webhook', title: 'webhook', mdSibling: '/nodes/webhook.md', node: 'webhook' },
];
await writeFile(path.join(contentDir, '.manifest.json'), JSON.stringify(manifest));
Expand All @@ -43,7 +43,11 @@ describe('buildIndex', () => {
assert.match(index, /^# RocketRide Documentation/m);
assert.match(index, /## Quickstart/);
assert.match(index, /## Concepts/);
assert.match(index, /\[Pipelines\]\(\/concepts\/pipelines\.md\)/);
// Links are absolute (the llms.txt format specifies URLs) and carry the
// manifest description when the page has one.
assert.match(index, /\[Pipelines\]\(https:\/\/docs\.rocketride\.org\/concepts\/pipelines\.md\): A pipeline is a directed graph of nodes\./);
// An entry without a description degrades to a bare link, no trailing colon.
assert.match(index, /\[Quickstart\]\(https:\/\/docs\.rocketride\.org\/quickstart\.md\)\n/);
// Quickstart section precedes Concepts (spine order)
assert.ok(index.indexOf('## Quickstart') < index.indexOf('## Concepts'));
});
Expand Down
Loading
Loading