Feat: documentation, UI/UX

This commit is contained in:
2026-03-01 19:09:52 +01:00
parent ecb559e556
commit db44f9b98e
57 changed files with 1531 additions and 615 deletions

View File

@@ -0,0 +1,338 @@
---
import fs from 'node:fs';
// --- Forth syntax highlighter ---
const EMIT = new Set(['.', '.!']);
const SOUNDS = new Set([
'sound', 's', 'saw', 'sine', 'kick', 'hat', 'snare', 'modal', 'noise',
'square', 'tri', 'pulse', 'clap', 'rim', 'crash', 'fm', 'sample', 'plaits',
'analog', 'waveshaping', 'granular', 'string', 'chord', 'speech', 'sub',
'super', 'wt', 'input', 'hh',
]);
const PARAMS = new Set([
'freq', 'note', 'gain', 'decay', 'attack', 'release', 'lpf', 'hpf', 'bpf',
'verb', 'delay', 'pan', 'orbit', 'harmonics', 'distort', 'speed', 'voice',
'dur', 'sustain', 'delaytime', 'delayfb', 'chorus', 'phaser', 'flanger',
'crush', 'fold', 'wrap', 'resonance', 'begin', 'end', 'velocity', 'chan',
'dev', 'ccnum', 'ccout', 'bend', 'pressure', 'program', 'tilt', 'slope',
'sub_gain', 'sub_oct', 'feedback', 'depth', 'sweep', 'comb', 'damping',
'detune', 'timbre', 'morph', 'color', 'model', 'cutoff',
]);
const STACK = new Set([
'dup', 'drop', 'swap', 'over', 'rot', 'nip', 'tuck', '2dup', '2drop',
]);
const PROB = new Set([
'chance', 'prob', 'choose', 'wchoose', 'cycle', 'pcycle', 'bounce',
'rand', 'exprand', 'logrand', 'coin', 'seed', 'often', 'sometimes',
'rarely', 'always', 'never', 'almostAlways', 'almostNever', 'every',
'except', 'bjork', 'pbjork', 'shuffle',
]);
const KEYWORDS = new Set([
':', ';', 'if', 'then', 'else', 'do', 'loop', '?', '!?', 'ifelse',
'select', 'apply', 'times', 'forget', 'all', 'noall', 'clear', 'm.',
]);
const CONTEXT = new Set([
'step', 'beat', 'pattern', 'tempo', 'phase', 'runs', 'iter', 'fill', 'stepdur',
]);
const NOTE_RE = /^[a-g][#sb]?\d+$/i;
const NUM_RE = /^-?\d+(\.\d+)?$/;
function classifyToken(word: string): string | null {
if (EMIT.has(word)) return 'f-emit';
if (SOUNDS.has(word)) return 'f-snd';
if (PARAMS.has(word)) return 'f-par';
if (STACK.has(word)) return 'f-stack';
if (PROB.has(word)) return 'f-prob';
if (KEYWORDS.has(word)) return 'f-kw';
if (CONTEXT.has(word)) return 'f-ctx';
if (NOTE_RE.test(word)) return 'f-note';
if (NUM_RE.test(word)) return 'f-num';
if (word.length > 1 && (word[0] === '@' || word[0] === '!' || word[0] === ',')) return 'f-var';
return null;
}
function highlightForth(code: string): string {
return code.split('\n').map(line => {
let result = '';
let i = 0;
while (i < line.length) {
if (line[i] === ' ' || line[i] === '\t') { result += line[i]; i++; continue; }
if (line[i] === ';' && line[i + 1] === ';') {
result += `<span class="f-com">${escapeHtml(line.slice(i))}</span>`;
break;
}
if (line[i] === '"') {
let end = line.indexOf('"', i + 1);
if (end === -1) end = line.length - 1;
result += escapeHtml(line.slice(i, end + 1));
i = end + 1;
continue;
}
if (line[i] === '(' || line[i] === ')') { result += line[i]; i++; continue; }
let end = i;
while (end < line.length && line[end] !== ' ' && line[end] !== '\t') end++;
const word = line.slice(i, end);
const cls = classifyToken(word);
result += cls ? `<span class="${cls}">${escapeHtml(word)}</span>` : escapeHtml(word);
i = end;
}
return result;
}).join('\n');
}
// --- Markdown renderer ---
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(text: string): string {
const codes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_, code) => { codes.push(code); return `\x00${codes.length - 1}\x00`; });
text = escapeHtml(text);
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/_([^_]+)_/g, '<em>$1</em>');
text = text.replace(/\x00(\d+)\x00/g, (_, i) => `<code>${escapeHtml(codes[+i])}</code>`);
return text;
}
function parseTableCells(line: string, tag: string): string {
return line.split('|').slice(1, -1).map(cell => `<${tag}>${inline(cell.trim())}</${tag}>`).join('');
}
function renderMarkdown(md: string): string {
const lines = md.split('\n');
let html = '';
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim() === '') { i++; continue; }
if (line.startsWith('### ')) { html += `<h3>${inline(line.slice(4))}</h3>`; i++; continue; }
if (line.startsWith('## ')) { html += `<h2>${inline(line.slice(3))}</h2>`; i++; continue; }
if (line.startsWith('# ')) { html += `<h1>${inline(line.slice(2))}</h1>`; i++; continue; }
if (/^---+$|^\*\*\*+$/.test(line.trim())) { html += '<hr>'; i++; continue; }
if (line.startsWith('> ')) {
html += '<blockquote>';
while (i < lines.length && lines[i].startsWith('> ')) {
html += `<p>${inline(lines[i].slice(2))}</p>`;
i++;
}
html += '</blockquote>';
continue;
}
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
i++;
let code = '';
while (i < lines.length && !lines[i].startsWith('```')) {
code += (code ? '\n' : '') + lines[i];
i++;
}
i++;
html += lang === 'forth'
? `<pre><code>${highlightForth(code)}</code></pre>`
: `<pre><code>${escapeHtml(code)}</code></pre>`;
continue;
}
if (line.startsWith('|')) {
html += '<table>';
html += `<tr>${parseTableCells(line, 'th')}</tr>`;
i++;
if (i < lines.length && /^\|[\s:|-]+\|$/.test(lines[i])) i++;
while (i < lines.length && lines[i].startsWith('|')) {
html += `<tr>${parseTableCells(lines[i], 'td')}</tr>`;
i++;
}
html += '</table>';
continue;
}
if (/^\d+\.\s/.test(line)) {
html += '<ol>';
while (i < lines.length && (/^\d+\.\s/.test(lines[i]) || /^\s+[*-]\s/.test(lines[i]))) {
if (/^\d+\.\s/.test(lines[i])) {
html += `<li>${inline(lines[i].replace(/^\d+\.\s/, ''))}`;
i++;
if (i < lines.length && /^\s+[*-]\s/.test(lines[i])) {
html += '<ul>';
while (i < lines.length && /^\s+[*-]\s/.test(lines[i])) {
html += `<li>${inline(lines[i].replace(/^\s*[*-]\s/, ''))}</li>`;
i++;
}
html += '</ul>';
}
html += '</li>';
} else {
i++;
}
}
html += '</ol>';
continue;
}
if (/^(\s*)[*-]\s/.test(line)) {
let depth = 0;
let first = true;
html += '<ul>';
while (i < lines.length && /^(\s*)[*-]\s/.test(lines[i])) {
const indent = lines[i].match(/^(\s*)/)![1].length;
const level = Math.floor(indent / 2);
if (level > depth) {
while (level > depth) { html += '<ul>'; depth++; }
} else if (level < depth) {
html += '</li>';
while (level < depth) { html += '</ul></li>'; depth--; }
} else if (!first) {
html += '</li>';
}
html += `<li>${inline(lines[i].replace(/^\s*[*-]\s/, ''))}`;
first = false;
i++;
}
while (depth > 0) { html += '</li></ul>'; depth--; }
html += '</li></ul>';
continue;
}
let para = '';
while (i < lines.length && lines[i].trim() !== ''
&& !lines[i].startsWith('#') && !lines[i].startsWith('```')
&& !lines[i].startsWith('|') && !/^(\s*)[*-]\s/.test(lines[i])
&& !/^\d+\.\s/.test(lines[i])) {
para += (para ? ' ' : '') + lines[i];
i++;
}
if (para) html += `<p>${inline(para)}</p>`;
}
return html;
}
// --- Section/topic manifest — mirrors src/model/docs.rs (keep in sync!) ---
const SECTIONS = [
{
title: "Getting Started",
topics: [
{ title: "Welcome", file: "welcome.md" },
{ title: "Navigation", file: "getting-started/navigation.md" },
{ title: "The Big Picture", file: "getting-started/big_picture.md" },
{ title: "Banks & Patterns", file: "getting-started/banks_patterns.md" },
{ title: "Stage / Commit", file: "getting-started/staging.md" },
{ title: "Using the Sequencer", file: "getting-started/grid.md" },
{ title: "Editing a Step", file: "getting-started/editing.md" },
{ title: "The Audio Engine", file: "getting-started/engine.md" },
{ title: "Options", file: "getting-started/options.md" },
{ title: "Saving & Loading", file: "getting-started/saving.md" },
{ title: "The Sample Browser", file: "getting-started/samples.md" },
],
},
{
title: "Cagire's Forth",
topics: [
{ title: "About Forth", file: "forth/about_forth.md" },
{ title: "The Dictionary", file: "forth/dictionary.md" },
{ title: "The Stack", file: "forth/stack.md" },
{ title: "Creating Words", file: "forth/definitions.md" },
{ title: "Control Flow", file: "forth/control_flow.md" },
{ title: "The Prelude", file: "forth/prelude.md" },
{ title: "Cagire vs Classic", file: "forth/oddities.md" },
],
},
{
title: "Building sounds",
topics: [
{ title: "Introduction", file: "engine/intro.md" },
{ title: "Settings", file: "engine/settings.md" },
{ title: "Sources", file: "engine/sources.md" },
{ title: "Samples", file: "engine/samples.md" },
{ title: "Wavetables", file: "engine/wavetable.md" },
{ title: "Filters", file: "engine/filters.md" },
{ title: "Modulation", file: "engine/modulation.md" },
{ title: "Distortion", file: "engine/distortion.md" },
{ title: "Space & Time", file: "engine/space.md" },
{ title: "Audio-Rate Mod", file: "engine/audio_modulation.md" },
{ title: "Words & Sounds", file: "engine/words.md" },
],
},
{
title: "Branching out",
topics: [
{ title: "Introduction", file: "midi/intro.md" },
{ title: "MIDI Output", file: "midi/output.md" },
{ title: "MIDI Input", file: "midi/input.md" },
],
},
{
title: "Tutorials",
topics: [
{ title: "Randomness", file: "tutorials/randomness.md" },
{ title: "Notes & Harmony", file: "tutorials/harmony.md" },
{ title: "Generators", file: "tutorials/generators.md" },
{ title: "Timing with at", file: "tutorials/at.md" },
{ title: "Using Variables", file: "tutorials/variables.md" },
{ title: "Recording", file: "tutorials/recording.md" },
{ title: "Soundfonts", file: "tutorials/soundfont.md" },
{ title: "Sharing", file: "tutorials/sharing.md" },
],
},
];
// Read all doc files at build time
for (const section of SECTIONS) {
for (const topic of section.topics) {
const raw = fs.readFileSync(`../docs/${topic.file}`, 'utf-8');
(topic as any).html = renderMarkdown(raw);
}
}
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cagire - Documentation</title>
<meta name="description" content="Cagire documentation — Forth-based live coding sequencer">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/docs.css">
</head>
<body>
<div class="docs-topbar">
<a class="docs-back" href="/">&larr; Back to Cagire</a>
<div class="docs-notice" id="docs-notice">
<span>You are reading Cagire's built-in documentation. Here it is static, but inside the app the examples are runnable and the docs are interactive.</span>
<button onclick="dismissNotice()">Got it</button>
</div>
</div>
<div class="docs-layout">
<nav class="docs-sidebar">
{SECTIONS.map((section, si) => (
<Fragment>
<h3>{section.title}</h3>
{section.topics.map((topic, ti) => (
<button data-s={si} data-t={ti}>{topic.title}</button>
))}
</Fragment>
))}
</nav>
<main class="docs-content">
{SECTIONS.map((section, si) =>
section.topics.map((topic, ti) => (
<article id={`topic-s${si}t${ti}`} set:html={(topic as any).html} />
))
)}
</main>
</div>
<script is:inline src="/docs.js"></script>
</body>
</html>

View File

@@ -94,6 +94,9 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<p class="note">Source code and issue tracker on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>. You can also compile the software yourself from source!</p>
<h2>Documentation</h2>
<p>Cagire ships with built-in interactive documentation — browse it inside the app with runnable examples, or <a href="/docs">read the static version here</a>.</p>
<h2>About</h2>
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the associated script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
@@ -125,7 +128,7 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
<p class="colophon">
<a href="https://raphaelforment.fr">BuboBubo</a> · Audio engine: <a href="https://doux.livecoding.fr">Doux</a> · <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a> · AGPL-3.0 </p>
<a href="https://raphaelforment.fr">BuboBubo</a> · Audio engine: <a href="https://doux.livecoding.fr">Doux</a> · <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a> · <a href="/docs">Docs</a> · AGPL-3.0 </p>
<script is:inline src="/script.js"></script>
</body>