| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import yaml from 'js-yaml' |
| | import fs from 'fs' |
| | import { chunk, last } from 'lodash-es' |
| | import { visit } from 'unist-util-visit' |
| | import { h } from 'hastscript' |
| | import { fromMarkdown } from 'mdast-util-from-markdown' |
| | import { toHast } from 'mdast-util-to-hast' |
| | import type { Root } from 'mdast' |
| | import { header } from './code-header' |
| | import findPage from '@/frame/lib/find-page' |
| |
|
| | interface LanguageConfig { |
| | comment: 'number' | 'slash' | 'xml' | 'percent' | 'hyphen' |
| | [key: string]: any |
| | } |
| |
|
| | interface ElementNode { |
| | type: 'element' |
| | tagName: string |
| | properties: { |
| | className?: string[] |
| | [key: string]: any |
| | } |
| | children: any[] |
| | data?: { |
| | meta?: { |
| | annotate?: boolean |
| | [key: string]: any |
| | } |
| | } |
| | } |
| |
|
| | const languages = yaml.load(fs.readFileSync('./data/code-languages.yml', 'utf8')) as Record< |
| | string, |
| | LanguageConfig |
| | > |
| |
|
| | const commentRegexes = { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | number: /^\s*#[^!]\s*|^\s*#$/, |
| | slash: /^\s*\/\/\s*/, |
| | xml: /^\s*<!--\s*/, |
| | percent: /^\s*%%?\s*/, |
| | hyphen: /^\s*--\s*/, |
| | } |
| |
|
| | |
| | const matcher = (node: any): node is ElementNode => |
| | node.type === 'element' && node.tagName === 'pre' && Boolean(getPreMeta(node).annotate) |
| |
|
| | |
| | export default function annotate(context: any) { |
| | |
| | return (tree: any) => { |
| | |
| | visit(tree, matcher, (node: ElementNode, index: number | undefined, parent: any) => { |
| | if (index !== undefined && parent) { |
| | parent.children[index] = createAnnotatedNode(node, context) |
| | } |
| | }) |
| | } |
| | } |
| |
|
| | |
| | function createAnnotatedNode(node: ElementNode, context: any): any { |
| | const lang = node.children[0].properties.className[0].replace('language-', '') |
| | const code = node.children[0].children[0].value |
| |
|
| | |
| | validate(lang, code) |
| |
|
| | |
| | const lines = code.split('\n').filter(hasChar) |
| | const groups = chunkBy(lines, matchComment(lang)) |
| |
|
| | |
| | const rows = chunk(groups, 2) |
| |
|
| | |
| | for (const [note, codeBlock] of rows) { |
| | if (note === undefined || codeBlock === undefined) { |
| | throw new Error( |
| | "Each annotation must have a note and a code block. If you're trying to create a blank annotation, you can use a single line comment with a space after it.", |
| | ) |
| | } |
| | } |
| |
|
| | |
| | return template({ lang, code, rows, context }) |
| | } |
| |
|
| | function validate(lang: string, code: string): void { |
| | if (!lang) { |
| | throw new Error('No language specific for annotate info string.') |
| | } |
| | if (!languages[lang]) { |
| | throw new Error( |
| | `Unsupported language for annotate info string. Please use one of: ${Object.keys( |
| | languages, |
| | )}.`, |
| | ) |
| | } |
| |
|
| | const firstLine = code.split('\n')[0] |
| | if (!getRegexp(lang).test(firstLine)) { |
| | throw new Error( |
| | `Make sure the annotated code example starts with a single line annotation. It's currently starting with: ${firstLine}`, |
| | ) |
| | } |
| |
|
| | if (!new RegExp(getRegexp(lang), 'm').test(code)) { |
| | throw new Error( |
| | 'Make sure the comment syntax matches the language. Use single-line comments only.', |
| | ) |
| | } |
| | } |
| |
|
| | function getRegexp(lang: string): RegExp { |
| | return commentRegexes[languages[lang].comment] |
| | } |
| |
|
| | function hasChar(line: string): boolean { |
| | return Boolean(line.trim()) |
| | } |
| |
|
| | function chunkBy(arr: string[], predicate: (item: string) => boolean): string[][] { |
| | const groups: string[][] = [[]] |
| | let on = predicate(arr[0]) |
| | for (const item of arr) { |
| | if ((!on && predicate(item)) || (on && !predicate(item))) { |
| | on = !on |
| | groups.push([]) |
| | } |
| | last(groups)!.push(item) |
| | } |
| | return groups |
| | } |
| |
|
| | function matchComment(lang: string): (line: string) => boolean { |
| | const regex = getRegexp(lang) |
| | return (line) => regex.test(line) |
| | } |
| |
|
| | |
| | function getSubnav(): any { |
| | const besideBtn = h( |
| | 'button', |
| | { |
| | name: 'annotate-display', |
| | value: 'beside', |
| | type: 'button', |
| | className: 'annotate-option', |
| | }, |
| | ['Beside'], |
| | ) |
| | const inlineBtn = h( |
| | 'button', |
| | { |
| | name: 'annotate-display', |
| | value: 'inline', |
| | type: 'button', |
| | className: 'annotate-option', |
| | }, |
| | ['Inline'], |
| | ) |
| |
|
| | return h('div', { className: 'annotate-toggle' }, [besideBtn, inlineBtn]) |
| | } |
| |
|
| | |
| | function template({ |
| | lang, |
| | code, |
| | rows, |
| | context, |
| | }: { |
| | lang: string |
| | code: string |
| | rows: string[][][] |
| | context: any |
| | }): any { |
| | return h( |
| | 'div', |
| | { class: 'annotate beside' }, |
| | h('div', { className: 'annotate-header' }, header(lang, code, getSubnav())), |
| | h( |
| | 'div', |
| | { className: 'annotate-beside' }, |
| | rows.map(([note, codeBlock]) => |
| | h('div', { className: 'annotate-row' }, [ |
| | h( |
| | 'div', |
| | { className: 'annotate-code' }, |
| | |
| | h('pre', h('code', { className: `language-${lang}` }, codeBlock.join('\n'))), |
| | ), |
| | h( |
| | 'div', |
| | { className: 'annotate-note' }, |
| | mdToHast(note.map(removeComment(lang)).join('\n'), context), |
| | ), |
| | ]), |
| | ), |
| | ), |
| | h('div', { className: 'annotate-inline' }, [ |
| | |
| | h('pre', h('code', { className: `language-${lang}` }, code)), |
| | ]), |
| | ) |
| | } |
| |
|
| | |
| | function mdToHast(text: string, context: any): any { |
| | const mdast: Root = fromMarkdown(text) |
| |
|
| | |
| | processAutotitleInMdast(mdast, context) |
| |
|
| | return toHast(mdast) |
| | } |
| |
|
| | |
| | |
| | |
| | function processAutotitleInMdast(mdast: Root, context: any): void { |
| | visit(mdast, 'link', (node) => { |
| | if (node.url && node.url.startsWith('/')) { |
| | for (const child of node.children) { |
| | if (child.type === 'text' && /^\s*AUTOTITLE\s*$/.test(child.value)) { |
| | |
| | const page = findPage(node.url, context.pages, context.redirects) |
| | if (page) { |
| | try { |
| | |
| | child.value = page.rawTitle || 'AUTOTITLE' |
| | } catch (error) { |
| | |
| | console.warn( |
| | `Could not resolve AUTOTITLE for ${node.url}:`, |
| | error instanceof Error ? error.message : String(error), |
| | ) |
| | } |
| | } |
| | } |
| | } |
| | } |
| | }) |
| | } |
| |
|
| | function removeComment(lang: string): (line: string) => string { |
| | const regex = getRegexp(lang) |
| | return (line) => line.replace(regex, '') |
| | } |
| |
|
| | function getPreMeta(node: ElementNode): { annotate?: boolean; [key: string]: any } { |
| | |
| | |
| | return node.children[0]?.data?.meta || {} |
| | } |
| |
|