Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
dadc373
refactor: extract colorizeReport into shared report-colors module
MentorFilou Jul 17, 2026
a5869f7
feat: run the real flashtrace release build in the browser on /try/
MentorFilou Jul 17, 2026
3a9dc57
fix: seed the process global before the worker imports the bundle
MentorFilou Jul 17, 2026
56f75a1
feat: add the 11 tutorial chapters with build-time verification
MentorFilou Jul 17, 2026
31308bd
feat: chapter engine, rail and persistence on /try/
MentorFilou Jul 17, 2026
f620abd
fix: initialize the tutorial engine after its state declarations
MentorFilou Jul 17, 2026
de244cb
feat: assist buttons - "Help me" and "Do the next step for me"
MentorFilou Jul 17, 2026
3b2c07e
feat: polish the tutorial - highlight overlay, mobile, a11y
MentorFilou Jul 17, 2026
178f9ea
style: cleanup some button stylings
MentorFilou Jul 18, 2026
1a22941
feat: free editor sandbox entry on the chapter rail
MentorFilou Jul 18, 2026
4041abf
feat: send "Try it out" to the free editor, greeting first-timers
MentorFilou Jul 18, 2026
8b67d5b
feat: rename /try/ to /learn/ and add a Learn nav item
MentorFilou Jul 18, 2026
1f1770c
fix: let [hidden] win over display:flex on the assist bar
MentorFilou Jul 18, 2026
929adb8
fix: caret drift in the /learn/ editor overlays
MentorFilou Jul 18, 2026
ee503ee
style: replace stock scrollbars with slim themed thumbs
MentorFilou Jul 18, 2026
a12aa69
feat: blur finished chapters behind a completion card
MentorFilou Jul 18, 2026
fd2362b
feat: unfold chapter steps in an accordion above the /learn/ IDE
MentorFilou Jul 18, 2026
08c57aa
feat: add and delete file tabs in the /learn/ editor
MentorFilou Jul 18, 2026
34d7e91
fix: keep the step accordion exclusive - one step unfolded at most
MentorFilou Jul 18, 2026
027b6fe
fix: keep a user-opened step open when a silent run resolves
MentorFilou Jul 18, 2026
959f8a5
style: synchronization improvements
MentorFilou Jul 18, 2026
d726ac5
feat: ring per rail chapter charting its step progress
MentorFilou Jul 18, 2026
1e9882c
feat: link to language support request issue template directly
MentorFilou Jul 18, 2026
a69a448
fix: show one rail symbol per chapter - ring or check
MentorFilou Jul 18, 2026
1afddc9
feat: color rail rings and step checks by who solved each step
MentorFilou Jul 18, 2026
18a5139
feat: fold the auto-step assist into the help popup
MentorFilou Jul 18, 2026
4a354b0
feat: style Free editor rail entry as a distinct chip
MentorFilou Jul 18, 2026
bd04fea
feat: view resolved files
MentorFilou Jul 18, 2026
372caa5
feat: open up the foundations chapters for exploration
MentorFilou Jul 18, 2026
c1224d0
feat: mention real-world diff in tutorial chap 3
MentorFilou Jul 18, 2026
5b28426
feat: restore the description step in the first-item chapter
MentorFilou Jul 18, 2026
80b6bcc
feat: teach writing a tag before scoping in chapter 8
MentorFilou Jul 19, 2026
a1f5a3f
feat: build up forwarding chapter in two steps
MentorFilou Jul 19, 2026
ecb10b6
refactor: split tutorial.js into focused engine modules
MentorFilou Jul 19, 2026
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
87 changes: 86 additions & 1 deletion build.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Static site generator: renders the flashtrace tool repo's docs/ plus the
// hand-written landing page into dist/. Pure Node + marked, no framework.
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
Expand All @@ -11,6 +11,9 @@ import { docShell, esc, EXT_ATTRS, GITHUB_URL, highlightTokens, SITE_URL } from
import { renderLanding } from './src/landing.mjs';
import { renderImpressum } from './src/impressum.mjs';
import { renderLicense } from './src/license.mjs';
import { renderTutorial } from './src/tutorial.mjs';
import { chapters } from './src/tutorial/chapters.mjs';
import { verifyChapters } from './src/tutorial/verify.mjs';

const root = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(root, 'dist');
Expand Down Expand Up @@ -76,6 +79,23 @@ const pages = [
{ title: 'Overview', slug: '', file: 'index.md' },
...specPages,
];

// --- supported code extensions: from docs/code-tags.md's comment-family table,
// so a language added upstream flows into the /learn/ new-file validation on
// the next rebuild. Only table rows are scanned - prose mentions `.git` etc.
const codeTagsMd = readFileSync(path.join(docsDir, 'code-tags.md'), 'utf8');
const codeExtensions = [
...new Set(
codeTagsMd
.split('\n')
.filter((line) => line.startsWith('|'))
.flatMap((line) => [...line.matchAll(/`(\.[a-z0-9]+)`/g)].map((m) => m[1])),
),
];
if (codeExtensions.length === 0) {
console.error('error: no extensions found in docs/code-tags.md - the table format changed?');
process.exit(1);
}
const slugByName = new Map(pages.map((p) => [p.file.replace(/\.md$/, ''), p.slug]));

// --- markdown rendering ------------------------------------------------------
Expand Down Expand Up @@ -174,6 +194,41 @@ function renderDoc(page) {
});
}

// --- /learn/ runner: the release bundle, node builtins rewritten to shims ---

// CI checks out the full tool repo, so dist/flashtrace.mjs sits next to the
// docs the build already consumes. Locally the clone provides it the same way.
const bundlePath = path.join(docsDir, '..', 'dist', 'flashtrace.mjs');
if (!existsSync(bundlePath)) {
console.error(
`error: flashtrace bundle not found at ${bundlePath} - the /learn/ page runs the release build in the browser and needs it. ` +
'Make sure the tool repo checkout includes dist/.',
);
process.exit(1);
}

// The exact builtin set the shims in src/tutorial/shims/ cover. A release
// that imports anything else (or drops one) must fail the build here, never
// silently ship a broken /learn/ page.
const SHIMMED_BUILTINS = ['child_process', 'fs', 'path', 'process', 'url'];

function rewriteBundle(source) {
const found = new Set();
const rewritten = source.replace(/from "node:([a-z_]+)"/g, (m, name) => {
found.add(name);
return `from "./shims/${name}.mjs"`;
});
const actual = [...found].sort();
if (actual.join(',') !== SHIMMED_BUILTINS.join(',')) {
console.error(
`error: flashtrace.mjs imports node builtins [${actual.join(', ')}] but the /learn/ shims cover exactly [${SHIMMED_BUILTINS.join(', ')}].\n` +
'Align src/tutorial/shims/ (and this assertion) with the release bundle.',
);
process.exit(1);
}
return rewritten;
}

// --- emit --------------------------------------------------------------------

rmSync(dist, { recursive: true, force: true });
Expand All @@ -193,9 +248,39 @@ for (const page of pages) {
writeFileSync(path.join(dir, 'index.html'), renderDoc(page));
}

const learnDir = path.join(dist, 'learn');
mkdirSync(learnDir, { recursive: true });
writeFileSync(path.join(learnDir, 'flashtrace.mjs'), rewriteBundle(readFileSync(bundlePath, 'utf8')));
cpSync(path.join(root, 'src', 'tutorial', 'shims'), path.join(learnDir, 'shims'), { recursive: true });
cpSync(path.join(root, 'src', 'tutorial', 'chapter-utils.mjs'), path.join(learnDir, 'chapter-utils.mjs'));
cpSync(path.join(root, 'src', 'scripts', 'tutorial.js'), path.join(learnDir, 'tutorial.js'));
cpSync(path.join(root, 'src', 'scripts', 'tutorial-worker.js'), path.join(learnDir, 'tutorial-worker.js'));
// tutorial.js is an entry that imports its engine from sibling ./tutorial-*.mjs
// modules; the browser loads them flat next to it, so copy each one.
for (const name of readdirSync(path.join(root, 'src', 'scripts'))) {
if (/^tutorial-.*\.mjs$/.test(name)) {
cpSync(path.join(root, 'src', 'scripts', name), path.join(learnDir, name));
}
}
cpSync(path.join(root, 'src', 'report-colors.mjs'), path.join(learnDir, 'report-colors.mjs'));
cpSync(path.join(root, 'src', 'highlight.mjs'), path.join(learnDir, 'highlight.mjs'));

// Every chapter's start state and each step's cumulative patched state runs
// through the exact bundle the browser executes; behavioral drift in a
// flashtrace release fails the deploy here instead of shipping a broken lesson.
let verified;
try {
verified = await verifyChapters(chapters, path.join(learnDir, 'flashtrace.mjs'), 'js');
} catch (err) {
console.error(`error: tutorial chapter verification failed.\n${err.message}`);
process.exit(1);
}
writeFileSync(path.join(learnDir, 'index.html'), renderTutorial({ version, verified, codeExtensions }));

const sitePaths = [
'/',
...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')),
'/learn/',
'/license/',
'/impressum/',
];
Expand Down
1 change: 1 addition & 0 deletions serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const types = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
Expand Down
37 changes: 2 additions & 35 deletions src/examples.mjs
Original file line number Diff line number Diff line change
@@ -1,39 +1,6 @@
// Curated interactive examples: real inputs and terminal output captured from
// actual `flashtrace` runs (v0.7.0). No in-browser execution - the CLI needs
// node:fs and git, so v1 ships pre-computed, trustworthy captures.
import { esc } from './layout.mjs';

// Re-create the CLI's coloring (src/report.mjs in the tool repo) as HTML
// spans on the captured plain-text output.
export function colorizeReport(text) {
let s = esc(text);
// status line: colored mark + bold item ID
s = s.replace(/^([✔✘~]) (\S+)/gm, (m, mark, id) => {
const cls = mark === '✔' ? 't-green' : mark === '✘' ? 't-red' : 't-yellow';
return `<span class="${cls}">${mark}</span> <span class="t-bold">${id}</span>`;
});
s = s.replace(/✘ missing/g, '<span class="t-red">✘ missing</span>');
s = s.replace(/\((→ [^)]*)\)/g, '<span class="t-dim">($1)</span>');
s = s.replace(/(?<!t-green">)✔/g, '<span class="t-green">✔</span>');
s = s.replace(/(?<!t-red">)✘/g, '<span class="t-red">✘</span>');
s = s.replace(/→/g, '<span class="t-cyan">→</span>');
s = s.replace(/⚠/g, '<span class="t-yellow">⚠</span>');
s = s.replace(/•/g, '<span class="t-red">•</span>');
s = s.replace(/\[deep-covered\]/g, '<span class="t-green">[deep-covered]</span>');
s = s.replace(/\[shallow-covered\]/g, '<span class="t-yellow">[shallow-covered]</span>');
s = s.replace(/\[defective\]/g, '<span class="t-red">[defective]</span>');
s = s.replace(/&quot;[^&]*&quot;/g, (m) => `<span class="t-dim">${m}</span>`);
s = s.replace(/(^|\s)([\w./-]+\.(?:md|markdown|ts|js|mjs|cjs|tsx|py|rb|go|rs|java|cs|sql|lua|html|vue)(?::\d+)?)(?=\s|$)/gm, '$1<span class="t-dim">$2</span>');
s = s.replace(/^( )(needs|covers|wanted by)( )/gm, '$1<span class="t-dim">$2</span>$3');
s = s.replace(/^Summary$/m, '<span class="t-bold">Summary</span>');
s = s.replace(/^( items +\d+ )(\(.*\))$/m, '$1<span class="t-dim">$2</span>');
s = s.replace(/^( ok +)(\d+)$/m, '$1<span class="t-green">$2</span>');
s = s.replace(/^( defective +)([1-9]\d*)$/m, '$1<span class="t-red">$2</span>');
s = s.replace(/^( )(of the ok items.*)$/m, '$1<span class="t-dim">$2</span>');
s = s.replace(/^ok$/m, '<span class="t-green t-bold">ok</span>');
s = s.replace(/^not ok$/m, '<span class="t-red t-bold">not ok</span>');
return s;
}
// actual `flashtrace` runs (v0.7.0). The landing page ships these pre-computed
// captures; the /learn/ page runs the same CLI live in the browser.

export const heroTerminal = {
command: 'npx flashtrace',
Expand Down
22 changes: 22 additions & 0 deletions src/highlight.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be
// HTML-escaped already. Generic code stays uncolored.
//
// Standalone and dependency-free because it is shared between the build
// (layout.mjs re-exports it) and the browser (/learn/ editor highlight overlay).
//
// Deliberately language-agnostic: it runs on every fenced block and codespan
// regardless of its info string, because flashtrace tokens appear inside
// blocks of any language (md, ts, sql, plain trace output, ...). The cost is
// that an unrelated string shaped like an ID (foo:bar#1) in, say, a bash or
// json block also gets colored - acceptable for these docs, where anything
// ID-shaped in a code block is in practice a flashtrace reference.
export function highlightTokens(escaped) {
return escaped.replace(
/(--&gt;)|(&gt;&gt;)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm,
(m, fwd, need, id, kw) => {
if (fwd || need) return `<span class="tk-arrow">${m}</span>`;
if (id) return `<span class="tk-id">${m}</span>`;
return `<span class="tk-kw">${kw}</span>`;
},
);
}
3 changes: 2 additions & 1 deletion src/landing.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// The landing page: hero with a CSS-built IDE mock, how-it-works, features,
// curated interactive examples, install snippets. All static HTML.
import { colorizeReport, examples, heroTerminal } from './examples.mjs';
import { examples, heroTerminal } from './examples.mjs';
import { esc, EXT_ATTRS, GITHUB_URL, highlightTokens, pageShell } from './layout.mjs';
import { colorizeReport } from './report-colors.mjs';

// --- hero IDE mock -----------------------------------------------------------

Expand Down
24 changes: 5 additions & 19 deletions src/layout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,9 @@ export function esc(s) {
.replaceAll('"', '&quot;');
}

// Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be
// HTML-escaped already. Generic code stays uncolored.
//
// Deliberately language-agnostic: it runs on every fenced block and codespan
// regardless of its info string, because flashtrace tokens appear inside
// blocks of any language (md, ts, sql, plain trace output, ...). The cost is
// that an unrelated string shaped like an ID (foo:bar#1) in, say, a bash or
// json block also gets colored - acceptable for these docs, where anything
// ID-shaped in a code block is in practice a flashtrace reference.
export function highlightTokens(escaped) {
return escaped.replace(
/(--&gt;)|(&gt;&gt;)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm,
(m, fwd, need, id, kw) => {
if (fwd || need) return `<span class="tk-arrow">${m}</span>`;
if (id) return `<span class="tk-id">${m}</span>`;
return `<span class="tk-kw">${kw}</span>`;
},
);
}
// highlightTokens lives in its own dependency-free module (the /learn/ page
// also ships it to the browser); re-exported here for the build-side callers.
export { highlightTokens } from './highlight.mjs';

const themeInit = `(function(){try{var t=localStorage.getItem('ft-theme');if(t!=='light'&&t!=='dark'){t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='light';}})();`;

Expand All @@ -56,9 +40,11 @@ function topBar({ version, active, withSidebar }) {
<a class="version-badge" href="${GITHUB_URL}/releases"${EXT_ATTRS} title="flashtrace release">${esc(version)}</a>
<nav class="topbar-nav" aria-label="Site">
${link('/', 'Home', 'home')}
${link('/learn/', 'Learn', 'tutorial')}
${link('/docs/', 'Docs', 'docs')}
</nav>
<div class="topbar-actions">
<a class="btn btn-primary btn-cta" href="/learn/#editor"${active === 'tutorial' ? ' aria-current="page"' : ''}>Try it out</a>
<a class="icon-btn" href="${GITHUB_URL}"${EXT_ATTRS} aria-label="flashtrace on GitHub">${githubIcon}</a>
<button class="icon-btn theme-toggle" aria-label="Toggle color theme">
<span class="only-light">${moonIcon}</span><span class="only-dark">${sunIcon}</span>
Expand Down
43 changes: 43 additions & 0 deletions src/report-colors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Re-create the CLI's report coloring (src/report.mjs in the tool repo) as
// HTML spans over plain-text output. Shared between the build (landing page
// captures) and the browser (/learn/ terminal), so it must stay dependency-free
// and standalone - `esc` lives here instead of importing layout.mjs, which
// would drag the whole page-shell module into the client bundle.

export function esc(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}

export function colorizeReport(text) {
let s = esc(text);
// status line: colored mark + bold item ID
s = s.replace(/^([✔✘~]) (\S+)/gm, (m, mark, id) => {
const cls = mark === '✔' ? 't-green' : mark === '✘' ? 't-red' : 't-yellow';
return `<span class="${cls}">${mark}</span> <span class="t-bold">${id}</span>`;
});
s = s.replace(/✘ missing/g, '<span class="t-red">✘ missing</span>');
s = s.replace(/\((→ [^)]*)\)/g, '<span class="t-dim">($1)</span>');
s = s.replace(/(?<!t-green">)✔/g, '<span class="t-green">✔</span>');
s = s.replace(/(?<!t-red">)✘/g, '<span class="t-red">✘</span>');
s = s.replace(/→/g, '<span class="t-cyan">→</span>');
s = s.replace(/⚠/g, '<span class="t-yellow">⚠</span>');
s = s.replace(/•/g, '<span class="t-red">•</span>');
s = s.replace(/\[deep-covered\]/g, '<span class="t-green">[deep-covered]</span>');
s = s.replace(/\[shallow-covered\]/g, '<span class="t-yellow">[shallow-covered]</span>');
s = s.replace(/\[defective\]/g, '<span class="t-red">[defective]</span>');
s = s.replace(/&quot;[^&]*&quot;/g, (m) => `<span class="t-dim">${m}</span>`);
s = s.replace(/(^|\s)([\w./-]+\.(?:md|markdown|ts|js|mjs|cjs|tsx|py|rb|go|rs|java|cs|sql|lua|html|vue)(?::\d+)?)(?=\s|$)/gm, '$1<span class="t-dim">$2</span>');
s = s.replace(/^( )(needs|covers|wanted by)( )/gm, '$1<span class="t-dim">$2</span>$3');
s = s.replace(/^Summary$/m, '<span class="t-bold">Summary</span>');
s = s.replace(/^( items +\d+ )(\(.*\))$/m, '$1<span class="t-dim">$2</span>');
s = s.replace(/^( ok +)(\d+)$/m, '$1<span class="t-green">$2</span>');
s = s.replace(/^( defective +)([1-9]\d*)$/m, '$1<span class="t-red">$2</span>');
s = s.replace(/^( )(of the ok items.*)$/m, '$1<span class="t-dim">$2</span>');
s = s.replace(/^ok$/m, '<span class="t-green t-bold">ok</span>');
s = s.replace(/^not ok$/m, '<span class="t-red t-bold">not ok</span>');
return s;
}
Loading