Skip to content
Draft
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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
"dev": "astro dev",
"format": "prettier -w --cache --plugin prettier-plugin-astro .",
"format:check": "prettier -c --cache --plugin prettier-plugin-astro .",
"check": "pnpm build:releases && astro check",
"check": "pnpm check:tutorials && pnpm build:releases && astro check",
"check:tutorials": "node scripts/check-tutorial-steps.mjs",
"build:compatibility-table": "pnpm --filter compatibility-table run build",
"build:references": "pnpm --filter js-api-generator run build",
"build:config": "pnpm --filter config-generator run build",
"build:cli": "pnpm --filter cli-generator run build",
"build:releases": "pnpm --filter releases-site run generate",
"build:astro": "astro build",
"build:astro": "pnpm check:tutorials && astro build",
"build": "pnpm dev:setup && pnpm build:references && pnpm build:config && pnpm build:cli && pnpm build:releases && pnpm build:astro",
"preview": "astro preview",
"test": "node --test \"src/**/*.test.ts\""
Expand Down
130 changes: 130 additions & 0 deletions scripts/check-tutorial-steps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Verifies that every page using <TutorialStep> or a <Tutorial> wrapper
// agrees with the committed tutorial manifests
// (src/data/tutorials/*.manifest.json). For <TutorialStep>: referenced steps
// exist, every step of a referenced tutorial is present, and they appear in
// manifest order. For <Tutorial>: the wrapper renders every step in manifest
// order itself, so the page-side check reduces to slot names agreeing with
// the step ids. Default-locale pages hard-fail; translated copies only warn,
// because translation lag is expected and surfaced by Lunaria. The site build
// still fails on any page that references an unknown tutorial or step, so the
// leniency here only covers completeness and order.
//
// schemaVersion is enforced by the build gate in
// src/components/tutorial/manifests.ts, not here; this script only reads ids.
// The manifests are committed artifacts from the runner's CI; nothing here runs the runner.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const docsDir = path.join(root, 'src', 'content', 'docs');
const manifestDir = path.join(root, 'src', 'data', 'tutorials');

const locales = Object.keys(
JSON.parse(fs.readFileSync(path.join(root, 'locales.json'), 'utf8'))
).filter((l) => l !== 'root');

const manifests = new Map();
if (fs.existsSync(manifestDir)) {
for (const f of fs.readdirSync(manifestDir).filter((f) => f.endsWith('.manifest.json'))) {
const m = JSON.parse(fs.readFileSync(path.join(manifestDir, f), 'utf8'));
manifests.set(m.id, m);
}
}

const errors = [];
const warnings = [];
const referenced = new Set();

for (const entry of fs.readdirSync(docsDir, { recursive: true })) {
const rel = entry.replaceAll('\\', '/');
if (!/\.mdx?$/.test(rel)) continue;
const isTranslation = locales.includes(rel.split('/')[0]);
const report = isTranslation ? warnings : errors;

const text = fs.readFileSync(path.join(docsDir, entry), 'utf8');
const refs = [];
for (const tag of text.matchAll(/<TutorialStep\b[^>]*>/g)) {
const tutorial = tag[0].match(/tutorial="([^"]+)"/)?.[1];
const step = tag[0].match(/step="([^"]+)"/)?.[1];
if (!tutorial || !step) {
report.push(`${rel}: <TutorialStep> without tutorial= and step= props`);
continue;
}
refs.push({ tutorial, step });
}
const wrapperIds = [];
for (const tag of text.matchAll(/<Tutorial\b[^>]*>/g)) {
const id = tag[0].match(/id="([^"]+)"/)?.[1];
if (!id) {
report.push(`${rel}: <Tutorial> without an id= prop`);
continue;
}
wrapperIds.push(id);
}
for (const id of wrapperIds) {
referenced.add(id);
if (!manifests.has(id)) report.push(`${rel}: references unknown tutorial "${id}"`);
}
// with several wrappers on one page, slot names cannot be attributed to a
// tutorial without real JSX parsing; Tutorial.astro still checks at build time
if (wrapperIds.length === 1 && manifests.has(wrapperIds[0])) {
const order = manifests.get(wrapperIds[0]).steps.map((s) => s.id);
const slots = [...text.matchAll(/slot="([^"]+)"/g)].map((m) => m[1]);
for (const name of slots) {
if (!order.includes(name.replace(/-after$/, ''))) {
report.push(`${rel}: tutorial "${wrapperIds[0]}" has no step for slot "${name}"`);
}
}
for (const id of order) {
if (!slots.includes(id)) {
report.push(`${rel}: step "${id}" of "${wrapperIds[0]}" has no prose slot`);
}
}
}

if (!refs.length) {
// a tag Prettier wrapped across lines is invisible to the regex above;
// an import with zero matches is the tell
if (!wrapperIds.length && /import\s+Tutorial(Step)?\b/.test(text)) {
report.push(`${rel}: imports the tutorial components but no single-line tag matched`);
}
continue;
}

const byTutorial = Map.groupBy(refs, (r) => r.tutorial);
for (const [tutorial, used] of byTutorial) {
referenced.add(tutorial);
const manifest = manifests.get(tutorial);
if (!manifest) {
report.push(`${rel}: references unknown tutorial "${tutorial}"`);
continue;
}
const order = manifest.steps.map((s) => s.id);
const usedIds = used.map((r) => r.step);
for (const id of usedIds) {
if (!order.includes(id)) report.push(`${rel}: tutorial "${tutorial}" has no step "${id}"`);
}
for (const id of order) {
if (!usedIds.includes(id))
report.push(`${rel}: step "${id}" of "${tutorial}" is missing from the page`);
}
const positions = usedIds.map((id) => order.indexOf(id)).filter((i) => i >= 0);
if (positions.some((p, i) => i > 0 && p < positions[i - 1])) {
report.push(`${rel}: steps of "${tutorial}" are out of manifest order`);
}
}
}

for (const id of manifests.keys()) {
if (!referenced.has(id)) warnings.push(`manifest "${id}" is not referenced by any page`);
}

for (const w of warnings) console.warn(`warn: ${w}`);
if (errors.length) {
for (const e of errors) console.error(`error: ${e}`);
process.exit(1);
}
console.log(
`tutorial steps consistent (${manifests.size} manifest(s), ${warnings.length} warning(s))`
);
74 changes: 74 additions & 0 deletions src/components/tutorial/Tutorial.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
import TutorialStep from './TutorialStep.astro';
import { getTutorial } from './manifests';
import locales from '../../../locales.json';

interface Props {
/** tutorial id matching `src/data/tutorials/<id>.manifest.json` */
id: string;
}

const { id } = Astro.props;
// throws on unknown id, failing the build
const manifest = getTutorial(id);
const stepIds = manifest.steps.map((s) => s.id);

// Steps render in manifest order, one prose slot per step id, plus an
// optional `<id>-after` slot for prose that belongs below the step's code.
// Slot names are enumerable own properties of Astro.slots (astro
// core/render/slots.js defines one per provided slot), so a typo'd name is
// detectable here instead of silently dropping its prose.
const provided = Object.keys(Astro.slots).filter((n) => n !== 'default');

const problems: string[] = [];
for (const name of provided) {
if (!stepIds.includes(name.replace(/-after$/, ''))) {
problems.push(`unknown slot "${name}" (steps: ${stepIds.join(', ')})`);
}
}
for (const stepId of stepIds) {
if (!Astro.slots.has(stepId)) {
problems.push(`step "${stepId}" has no prose slot`);
}
}
// prose placed directly inside <Tutorial> lands in the default slot and
// would otherwise vanish; MDX always passes whitespace children, hence trim
if (((await Astro.slots.render('default')) ?? '').trim()) {
problems.push('prose outside a step slot: wrap it in <Fragment slot="<step id>">');
}

// translated copies lag behind the manifest by design (Lunaria tracks the
// gap), so under a locale prefix problems warn instead of failing the build
const localePrefix = Astro.url.pathname.split('/').filter(Boolean)[0] ?? '';
const isTranslation = localePrefix !== 'root' && localePrefix in locales;

if (problems.length) {
const message = `tutorial "${id}" on ${Astro.url.pathname}: ${problems.join('; ')}`;
if (isTranslation) {
console.warn(`warn: ${message}`);
} else {
throw new Error(message);
}
}

const steps = await Promise.all(
stepIds.map(async (stepId) => ({
id: stepId,
prose: Astro.slots.has(stepId) ? await Astro.slots.render(stepId) : undefined,
after: Astro.slots.has(`${stepId}-after`)
? await Astro.slots.render(`${stepId}-after`)
: undefined,
}))
);
---

{
steps.map((s) => (
<>
<TutorialStep tutorial={id} step={s.id}>
{s.prose !== undefined && <Fragment set:html={s.prose} />}
</TutorialStep>
{s.after !== undefined && <Fragment set:html={s.after} />}
</>
))
}
77 changes: 77 additions & 0 deletions src/components/tutorial/TutorialStep.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
import { Code } from '@astrojs/starlight/components';
import { getStep } from './manifests';

interface Props {
/** tutorial id matching `src/data/tutorials/<id>.manifest.json` */
tutorial: string;
/** step id inside that tutorial's manifest */
step: string;
}

const { tutorial, step } = Astro.props;
// throws on unknown tutorial/step, failing the build, because drift between
// prose and manifest is exactly what this component exists to catch
const record = getStep(tutorial, step);

// Shiki resolves extensions and aliases (`rs` → rust) natively; site-wide
// remaps belong in `shiki.langAlias` in astro.config.mjs
function langOf(file: string): string {
return file.split('.').pop() ?? 'txt';
}

type Block =
| { kind: 'diff'; code: string; lang: string; title?: string }
| { kind: 'file'; code: string; lang: string; title: string }
| { kind: 'command'; code: string };

// Expressive Code applies diff-like rendering (marker stripping, ins/del
// backgrounds, `lang` meta highlighting) only when the block is not a full
// unified diff, so the `---`/`+++`/`@@` lines the runner records are dropped.
// One block per hunk, because joining hunks silently glues disjoint regions.
function diffBlocks(file: string, diff: string): Block[] {
const lang = langOf(file);
// the runner records created files as a "new file: <path>" line plus
// all-added lines; render them as a plain full-file block
if (diff.startsWith('new file: ')) {
const code = diff
.split('\n')
.slice(1)
.map((line) => line.replace(/^\+/, ''))
.join('\n');
return [{ kind: 'file', code, lang, title: file }];
}
const hunks: string[][] = [[]];
for (const line of diff.split('\n')) {
if (/^(--- |\+\+\+ |\\)/.test(line)) continue;
if (line.startsWith('@@')) {
if (hunks[hunks.length - 1].some((l) => l !== '')) hunks.push([]);
continue;
}
hunks[hunks.length - 1].push(line);
}
return hunks
.filter((h) => h.some((l) => l !== ''))
.map((h, i) => ({ kind: 'diff', code: h.join('\n'), lang, title: i === 0 ? file : undefined }));
}

const blocks = record.mutations.flatMap((m): Block[] => {
if (m.file && m.diff) return diffBlocks(m.file, m.diff);
if (m.command) return [{ kind: 'command', code: m.command }];
return [];
});
---

<slot />

{
blocks.map((b) =>
b.kind === 'command' ? (
<Code code={b.code} lang="sh" frame="none" />
) : b.kind === 'file' ? (
<Code code={b.code} lang={b.lang} title={b.title} />
) : (
<Code code={b.code} lang="diff" meta={`lang="${b.lang}"`} title={b.title} />
)
)
}
39 changes: 39 additions & 0 deletions src/components/tutorial/manifest-types.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// generated by `tatu schema --emit-ts` — do not edit; regenerate from the runner

export interface TutorialManifest {
/** bumped when the manifest shape changes; consumers assert on it */
schemaVersion: number;
id: string;
title: string;
/** true when produced outside the pinned container (`tatu check`) */
advisory: boolean;
/** node-compatible platform tag: win32 / darwin / linux */
platform: string;
steps: TutorialStepRecord[];
}

export interface TutorialStepRecord {
id: string;
task: string;
mutations: MutationRecord[];
preconditions: ResultRecord[];
assertions: ResultRecord[];
}

export interface MutationRecord {
file?: string;
/** base-relative unified diff; empty string when the overlay changed nothing */
diff?: string;
command?: string;
cwd?: string;
}

export interface ResultRecord {
kind: string;
command?: string;
run?: string;
status: Status;
}

export type Status = 'pass' | 'fail' | 'skipped';

49 changes: 49 additions & 0 deletions src/components/tutorial/manifests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Committed tutorial manifests, produced by the tutorial runner (tatu) and
// landed here as reviewed diffs. The docs build only reads JSON and never
// runs the runner. One file per tutorial under src/data/tutorials/.
//
// The interfaces live in manifest-types.generated.ts, emitted by
// `tatu schema --emit-ts` from the runner's schemars schema, so regenerate
// them there instead of editing by hand.

import type { TutorialManifest, TutorialStepRecord } from './manifest-types.generated';

const SUPPORTED_SCHEMA_VERSION = 1;

const files = import.meta.glob<{ default: TutorialManifest }>(
'../../data/tutorials/*.manifest.json',
{
eager: true,
}
);

const manifests = new Map<string, TutorialManifest>();
for (const mod of Object.values(files)) {
if (mod.default.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
throw new Error(
`tutorial manifest "${mod.default.id}" has schemaVersion ${mod.default.schemaVersion} but this checkout supports ${SUPPORTED_SCHEMA_VERSION}, so regenerate the manifest or update the components`
);
}
manifests.set(mod.default.id, mod.default);
}

export function getTutorial(tutorial: string): TutorialManifest {
const manifest = manifests.get(tutorial);
if (!manifest) {
throw new Error(
`unknown tutorial "${tutorial}": no src/data/tutorials/${tutorial}.manifest.json (known: ${[...manifests.keys()].join(', ') || 'none'})`
);
}
return manifest;
}

export function getStep(tutorial: string, step: string): TutorialStepRecord {
const manifest = getTutorial(tutorial);
const record = manifest.steps.find((s) => s.id === step);
if (!record) {
throw new Error(
`tutorial "${tutorial}" has no step "${step}" (steps: ${manifest.steps.map((s) => s.id).join(', ')})`
);
}
return record;
}
Loading
Loading