Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215// A small document model, and its Markdown and HTML renderings.//// Commands that print for a person have three outputs besides JSON: text for// the terminal, Markdown, and HTML. The last two say the same thing in two// syntaxes, so both are rendered from one description of the content —// headings, paragraphs, links, images, quotes, lists — rather than written// twice. That also puts escaping in one place for each: post text is anyone's// text, and must reach a Markdown viewer as text and a browser as text, never// as markup.
export type Inline = | { t: 'text'; text: string } | { t: 'link'; href: string; children: Inline[] } | { t: 'strong' | 'em'; children: Inline[] } | { t: 'code'; text: string } | { t: 'break' };
export type Block = | { t: 'heading'; level: 1 | 2 | 3 | 4; children: Inline[] } | { t: 'paragraph'; children: Inline[] } | { t: 'quote'; children: Block[] } | { t: 'list'; ordered?: boolean; items: Block[][] } | { t: 'image'; src: string; alt: string; href?: string | undefined } | { t: 'code'; text: string; lang?: string } | { t: 'rule' } | { t: 'table'; head: string[]; rows: Inline[][][] } /** A group that belongs together: an <article> in HTML, nothing of its own in Markdown. */ | { t: 'section'; children: Block[] };
export interface Doc { title: string; blocks: Block[];}
// --------------------------------------------------------------- building ---
export const text = (value: string): Inline => ({ t: 'text', text: value });export const link = (href: string, ...children: Array<Inline | string>): Inline => ({ t: 'link', href, children: children.map(inline) });export const strong = (...children: Array<Inline | string>): Inline => ({ t: 'strong', children: children.map(inline) });export const em = (...children: Array<Inline | string>): Inline => ({ t: 'em', children: children.map(inline) });export const code = (value: string): Inline => ({ t: 'code', text: value });export const br: Inline = { t: 'break' };export const p = (...children: Array<Inline | string>): Block => ({ t: 'paragraph', children: children.map(inline) });export const h = (level: 1 | 2 | 3 | 4, ...children: Array<Inline | string>): Block => ({ t: 'heading', level, children: children.map(inline) });
function inline(value: Inline | string): Inline { return typeof value === 'string' ? text(value) : value;}
/** Text with its line breaks as breaks, and blank lines as paragraph breaks. */export function paragraphs(value: string): Block[] { return value.split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean).map((part) => ({ t: 'paragraph' as const, children: part.split('\n').flatMap((line, at) => (at ? [br, text(line)] : [text(line)])), }));}
// --------------------------------------------------------------- Markdown ---
/** Text, made inert: nothing in it reads as Markdown or as HTML. */export function escapeMarkdown(value: string): string { return value .replace(/([\\`*_[\]<>|~])/g, '\\$1') .replace(/^(\s*)([#>+-]|\d+\.)(?=\s|$)/gm, (_, space: string, mark: string) => `${space}${mark.replace(/^(\d+)\./, '$1\\.').replace(/^([#>+-])$/, '\\$1')}`);}
function mdHref(href: string): string { return /[\s()<>]/.test(href) ? `<${href.replace(/[<>]/g, encodeURIComponent)}>` : href;}
function mdInline(nodes: Inline[]): string { return nodes.map((node) => { switch (node.t) { case 'text': return escapeMarkdown(node.text); case 'link': return safeHref(node.href) ? `[${mdInline(node.children)}](${mdHref(node.href)})` : mdInline(node.children); case 'strong': return `**${mdInline(node.children)}**`; case 'em': return `*${mdInline(node.children)}*`; case 'code': { const ticks = '`'.repeat(Math.max(1, ...[...node.text.matchAll(/`+/g)].map((match) => match[0].length + 1))); return `${ticks}${ticks.length > 1 ? ' ' : ''}${node.text}${ticks.length > 1 ? ' ' : ''}${ticks}`; } case 'break': return '\\\n'; } }).join('');}
function mdBlocks(blocks: Block[]): string { return blocks.map(mdBlock).filter(Boolean).join('\n\n');}
function mdBlock(block: Block): string { switch (block.t) { case 'heading': return `${'#'.repeat(block.level)} ${mdInline(block.children)}`; case 'paragraph': return mdInline(block.children); case 'quote': return mdBlocks(block.children).split('\n').map((line) => (line ? `> ${line}` : '>')).join('\n'); case 'list': return block.items.map((item, at) => { const marker = block.ordered ? `${at + 1}. ` : '- '; const body = mdBlocks(item); return marker + body.split('\n').map((line, index) => (index && line ? ' '.repeat(marker.length) + line : line)).join('\n'); }).join('\n'); case 'image': { if (!safeHref(block.src)) return ''; const image = `})`; return block.href && safeHref(block.href) ? `[${image}](${mdHref(block.href)})` : image; } case 'code': { const fence = '`'.repeat(Math.max(3, ...[...block.text.matchAll(/`{3,}/g)].map((match) => match[0].length + 1))); return `${fence}${block.lang ?? ''}\n${block.text}\n${fence}`; } case 'rule': return '---'; case 'table': { const cell = (nodes: Inline[]) => mdInline(nodes).replace(/\n/g, ' '); return [ `| ${block.head.map(escapeMarkdown).join(' | ')} |`, `|${block.head.map(() => '---').join('|')}|`, ...block.rows.map((row) => `| ${row.map(cell).join(' | ')} |`), ].join('\n'); } case 'section': return mdBlocks(block.children); }}
export function toMarkdown(doc: Doc): string { return `${mdBlocks(doc.blocks).replace(/\n{3,}/g, '\n\n').trim()}\n`;}
// ------------------------------------------------------------------- HTML ---
export function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!);}
/** Only addresses that go somewhere harmless become links: never `javascript:` and the like. */function safeHref(href: string): boolean { return /^(https?:|mailto:)/i.test(href);}
function htmlInline(nodes: Inline[]): string { return nodes.map((node) => { switch (node.t) { case 'text': return escapeHtml(node.text); case 'link': return safeHref(node.href) ? `<a href="${escapeHtml(node.href)}">${htmlInline(node.children)}</a>` : htmlInline(node.children); case 'strong': return `<strong>${htmlInline(node.children)}</strong>`; case 'em': return `<em>${htmlInline(node.children)}</em>`; case 'code': return `<code>${escapeHtml(node.text)}</code>`; case 'break': return '<br>'; } }).join('');}
function htmlBlocks(blocks: Block[]): string { return blocks.map(htmlBlock).filter(Boolean).join('\n');}
function htmlBlock(block: Block): string { switch (block.t) { case 'heading': return `<h${block.level}>${htmlInline(block.children)}</h${block.level}>`; case 'paragraph': return `<p>${htmlInline(block.children)}</p>`; case 'quote': return `<blockquote>\n${htmlBlocks(block.children)}\n</blockquote>`; case 'list': { const tag = block.ordered ? 'ol' : 'ul'; return `<${tag}>\n${block.items.map((item) => `<li>${htmlBlocks(item)}</li>`).join('\n')}\n</${tag}>`; } case 'image': { if (!safeHref(block.src)) return ''; const image = `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" loading="lazy">`; return `<figure>${block.href && safeHref(block.href) ? `<a href="${escapeHtml(block.href)}">${image}</a>` : image}${block.alt ? `<figcaption>${escapeHtml(block.alt)}</figcaption>` : ''}</figure>`; } case 'code': return `<pre><code${block.lang ? ` class="language-${escapeHtml(block.lang)}"` : ''}>${escapeHtml(block.text)}</code></pre>`; case 'rule': return '<hr>'; case 'table': return [ '<table>', `<thead><tr>${block.head.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('')}</tr></thead>`, `<tbody>${block.rows.map((row) => `<tr>${row.map((cell) => `<td>${htmlInline(cell)}</td>`).join('')}</tr>`).join('\n')}</tbody>`, '</table>', ].join('\n'); case 'section': return `<article>\n${htmlBlocks(block.children)}\n</article>`; }}
const STYLE = `:root { color-scheme: light dark; --fg: #1d2129; --muted: #66707d; --line: #d8dde3; --bg: #fff; --accent: #0a66d8; }@media (prefers-color-scheme: dark) { :root { --fg: #e6e9ee; --muted: #98a2ae; --line: #2d343c; --bg: #12161b; --accent: #5aa6ff; } }body { font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--fg); background: var(--bg); max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }article { border-bottom: 1px solid var(--line); padding: 0.75rem 0; }article article, blockquote article { border: 0; padding: 0; }blockquote { margin: 0.75rem 0; padding: 0.5rem 0.9rem; border: 1px solid var(--line); border-radius: 0.6rem; }figure { margin: 0.75rem 0; } img { max-width: 100%; border-radius: 0.5rem; }figcaption, em { color: var(--muted); } figcaption { font-size: 0.85rem; }ul { padding-left: 1.2rem; } li { margin: 0.4rem 0; }pre { overflow-x: auto; padding: 0.75rem; border: 1px solid var(--line); border-radius: 0.5rem; }table { border-collapse: collapse; } td, th { border-bottom: 1px solid var(--line); padding: 0.3rem 0.6rem; text-align: left; vertical-align: top; }`.trim();
/** A whole page, so the output opens as it is in a browser and pipes into anything that reads HTML. */export function toHtml(doc: Doc): string { return [ '<!doctype html>', '<html lang="en">', '<head>', '<meta charset="utf-8">', '<meta name="viewport" content="width=device-width, initial-scale=1">', '<meta name="generator" content="atcli">', `<title>${escapeHtml(doc.title)}</title>`, `<style>\n${STYLE}\n</style>`, '</head>', '<body>', htmlBlocks(doc.blocks), '</body>', '</html>', '', ].join('\n');}