markupress 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -97
- package/dist/content.d.ts +33 -8
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +174 -255
- package/dist/content.js.map +1 -1
- package/dist/controllers/shell.d.ts +2 -3
- package/dist/controllers/shell.d.ts.map +1 -1
- package/dist/controllers/shell.js +4 -12
- package/dist/controllers/shell.js.map +1 -1
- package/dist/git.d.ts +8 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +27 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/markdown.d.ts +3 -1
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +10 -13
- package/dist/markdown.js.map +1 -1
- package/dist/navigation.d.ts +23 -31
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +48 -59
- package/dist/navigation.js.map +1 -1
- package/dist/site.d.ts +16 -26
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +91 -45
- package/dist/site.js.map +1 -1
- package/dist/versions.d.ts +4 -1
- package/dist/versions.d.ts.map +1 -1
- package/dist/versions.js +21 -41
- package/dist/versions.js.map +1 -1
- package/package.json +8 -7
- package/styles/prose.css +31 -0
- package/theme/chrome.html +38 -0
- package/theme/home.html +28 -0
- package/theme/shell.html +22 -33
- package/theme/theme.css +19 -3
package/dist/content.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { basename, resolve } from 'node:path';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import { applyLoomaCodeNotation, parseLoomaFenceInfo, splitLoomaFrontmatter } from '@threadlabs/looma/markdown';
|
|
4
|
+
import { createLoomaMarkdownIt } from '@threadlabs/looma/markdown/markdown-it';
|
|
4
5
|
import { parseFragment, serialize, serializeOuter } from 'parse5';
|
|
5
6
|
import { bundledLanguages, createHighlighter } from 'shiki';
|
|
6
7
|
import { parseDocument } from 'yaml';
|
|
7
8
|
import { stylesheet } from './styles.js';
|
|
9
|
+
const index = /^(?:\d+\.)?index\.md$/;
|
|
10
|
+
/** Whether a Markdown file is its folder's index page, which HTMLKit reads as the folder's own entry. */
|
|
11
|
+
export const isIndex = (file) => index.test(basename(file));
|
|
8
12
|
export function escapeHTML(value) {
|
|
9
13
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10
14
|
}
|
|
@@ -33,84 +37,102 @@ async function highlight(code, lang, lines) {
|
|
|
33
37
|
await shiki.loadLanguage(lang);
|
|
34
38
|
return shiki.codeToHtml(code, { lang, themes, defaultColor: 'light-dark()', transformers: [shikiOutput, lines] });
|
|
35
39
|
}
|
|
36
|
-
// Line highlighting and numbers are classes written at build time; the theme's stylesheet draws them.
|
|
37
|
-
|
|
40
|
+
// Line highlighting and numbers are classes written at build time; the theme's stylesheet draws them. The fence
|
|
41
|
+
// meta stays on <code> as Looma Markdown's canonical HTML writes it.
|
|
42
|
+
const lineClasses = (marked, numbers, meta) => ({
|
|
38
43
|
pre(node) { if (numbers)
|
|
39
44
|
this.addClassToHast(node, 'line-numbers'); },
|
|
45
|
+
code(node) { if (meta)
|
|
46
|
+
node.properties['data-meta'] = meta; },
|
|
40
47
|
line(node, line) { if (marked.has(line))
|
|
41
48
|
this.addClassToHast(node, 'highlighted'); },
|
|
42
49
|
});
|
|
43
|
-
// A fence's first info word names its language unless it is an attribute, as in ```title="notes.txt".
|
|
44
|
-
// It ends at `{` or `:`, so VitePress's ```ts{1} and ```ts:line-numbers work too.
|
|
45
|
-
const fenceLanguage = (info = '') => /^[^\s={:]+(?=[\s{:]|$)/.exec(info)?.[0];
|
|
46
|
-
// Fence meta: `{1,3-5}` highlights lines, and `:line-numbers` or `:no-line-numbers` overrides the default.
|
|
47
|
-
function fenceLines(info = '') {
|
|
48
|
-
const meta = info.replace(/(?:^|\s)title="[^"]*"/, '');
|
|
49
|
-
const marked = new Set();
|
|
50
|
-
for (const range of /\{([\d\s,-]+)\}/.exec(meta)?.[1].split(',') ?? []) {
|
|
51
|
-
const [from = NaN, to = from] = range.split('-').map(part => Number.parseInt(part, 10));
|
|
52
|
-
for (let line = from; line <= to; line++)
|
|
53
|
-
marked.add(line);
|
|
54
|
-
}
|
|
55
|
-
const numbers = /:(no-)?line-numbers(?![\w-])/.exec(meta);
|
|
56
|
-
return { marked, numbers: numbers ? numbers[1] === undefined : undefined };
|
|
57
|
-
}
|
|
58
|
-
// `// [!code highlight]` at the end of a line highlights it and is removed, and `[!code highlight:3]` covers three
|
|
59
|
-
// lines. Alone on a line, the marker is removed with its line and applies to the lines after it.
|
|
60
|
-
// ponytail: matched on source lines rather than on Shiki's comment tokens, so a string ending in the marker counts too.
|
|
61
|
-
const notation = /[ \t]*(?:\/\/|#|--|<!--|\{?\/\*)[ \t]*\[!code highlight(?::(\d+))?\][ \t]*(?:-->|\*\/\}?)?[ \t]*$/;
|
|
62
|
-
function highlightNotation(code, marked) {
|
|
63
|
-
const lines = [];
|
|
64
|
-
for (const line of code.split('\n')) {
|
|
65
|
-
const match = notation.exec(line);
|
|
66
|
-
if (!match) {
|
|
67
|
-
lines.push(line);
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
const rest = line.slice(0, match.index), own = rest.trim() !== '';
|
|
71
|
-
if (own)
|
|
72
|
-
lines.push(rest);
|
|
73
|
-
for (let n = 0; n < Number(match[1] ?? 1); n++)
|
|
74
|
-
marked.add(lines.length + (own ? 0 : 1) + n);
|
|
75
|
-
}
|
|
76
|
-
return lines.join('\n');
|
|
77
|
-
}
|
|
78
50
|
// Hidden until the page's code controller connects, so without JavaScript a code block is only its code.
|
|
79
51
|
const codeActions = '<div class="code-actions" hidden aria-live="polite"><button type="button" data-code-action="wrap" aria-pressed="false">Wrap</button>' +
|
|
80
52
|
'<button type="button" data-code-action="copy">Copy</button></div>';
|
|
81
|
-
const fenceTitle = (info = '') => /(?:^|\s)title="([^"]*)"/.exec(info)?.[1];
|
|
82
|
-
// Looma's shared block vocabulary (its "Shared blocks" page): a container's name, matched without regard to
|
|
83
|
-
// case, is a callout tone, details or tabs. `caution` and GitHub's [!CAUTION] mean danger.
|
|
84
|
-
const tones = { note: 'note', tip: 'tip', info: 'info', important: 'important', warning: 'warning', danger: 'danger', caution: 'danger' };
|
|
85
|
-
const blocks = new Set([...Object.keys(tones), 'details', 'tabs', 'code-group']);
|
|
86
53
|
function textContent(node) {
|
|
87
54
|
if ('value' in node)
|
|
88
55
|
return node.value;
|
|
89
56
|
return 'childNodes' in node ? node.childNodes.map(textContent).join('') : '';
|
|
90
57
|
}
|
|
58
|
+
// Looma Markdown: Looma's markdown-it plugin parses every form and renders the standard's canonical HTML. Markupress adds its presentation on top: Shiki code figures, heading self-links, literal text,
|
|
59
|
+
// and native component carriers.
|
|
60
|
+
const md = createLoomaMarkdownIt();
|
|
61
|
+
const { escapeHtml } = md.utils;
|
|
62
|
+
const rules = md.renderer.rules;
|
|
63
|
+
// Markdown text is literal: braces are escaped so HTML Next never interpolates them. Attributes are always literal
|
|
64
|
+
// in HTML Next, and raw HTML keeps its expressions. Plugin rules escape their text and attributes alike, so their
|
|
65
|
+
// text segments (between tags, where escaped HTML holds no `>`) gain the same escape.
|
|
66
|
+
const literalText = (html) => html.replace(/>([^<]+)</g, (_, text) => `>${literal(text)}<`);
|
|
67
|
+
for (const name of ['looma_container_open', 'looma_chip', 'looma_mention', 'looma_toc']) {
|
|
68
|
+
const rule = rules[name];
|
|
69
|
+
rules[name] = (...args) => literalText(rule(...args));
|
|
70
|
+
}
|
|
71
|
+
rules.text = (tokens, index) => literal(escapeHtml(tokens[index].content));
|
|
72
|
+
rules.code_inline = (tokens, index, _options, _env, self) => `<code${self.renderAttrs(tokens[index])}>${literal(escapeHtml(tokens[index].content))}</code>`;
|
|
73
|
+
// Each heading links to its own anchor, which the plugin assigned.
|
|
74
|
+
rules.heading_open = (tokens, index, options, _env, self) => `${self.renderToken(tokens, index, options)}<a href="#${escapeHtml(tokens[index].attrGet('id') ?? '')}">`;
|
|
75
|
+
rules.heading_close = (tokens, index, options, _env, self) => `</a>${self.renderToken(tokens, index, options)}`;
|
|
76
|
+
// Code reads as Looma's code notation leaves it: `[!code highlight]` comments are removed from every fence.
|
|
77
|
+
const plainCode = (code, { language, meta }) => `<pre><code${language ? ` class="language-${escapeHtml(language)}"` : ''}${meta ? ` data-meta="${escapeHtml(meta)}"` : ''}>${literal(escapeHtml(applyLoomaCodeNotation(code.replace(/\n$/, '')).code))}</code></pre>`;
|
|
78
|
+
const codeFigure = (pre, title) => `<figure class="code">${title ? `<figcaption>${literal(escapeHtml(title))}</figcaption>` : ''}${codeActions}${pre}</figure>\n`;
|
|
79
|
+
rules.fence = (tokens, index, _options, env) => {
|
|
80
|
+
const token = tokens[index], info = parseLoomaFenceInfo(token.info), tab = tokens[index - 1];
|
|
81
|
+
// A code group's tab already shows the fence title that labels it.
|
|
82
|
+
const title = tab?.type === 'looma_tab_open' && tab.meta.label === info.title ? undefined : info.title;
|
|
83
|
+
return codeFigure(env.highlighted.get(token) ?? plainCode(token.content, info), title);
|
|
84
|
+
};
|
|
85
|
+
rules.code_block = (tokens, index) => codeFigure(plainCode(tokens[index].content, { meta: '' }));
|
|
86
|
+
// A native component carrier, `<template component>` at the start of a block, is raw HTML through its closing tag,
|
|
87
|
+
// blank lines included. Native fragment parsing finds the boundary, nested templates too, so Markdown never
|
|
88
|
+
// interprets a component's CSS, expressions or internal markup.
|
|
89
|
+
md.block.ruler.before('html_block', 'native_component', (state, startLine, endLine, silent) => {
|
|
90
|
+
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
91
|
+
if (state.sCount[startLine] - state.blkIndent >= 4 || !/^<template(?:\s|>)/i.test(state.src.slice(start, start + 10)))
|
|
92
|
+
return false;
|
|
93
|
+
const carrier = parseFragment(state.src.slice(start), { sourceCodeLocationInfo: true }).childNodes.find(node => 'tagName' in node);
|
|
94
|
+
if (!carrier || !('tagName' in carrier) || carrier.tagName !== 'template' || !carrier.attrs.some(attr => attr.name === 'component'))
|
|
95
|
+
return false;
|
|
96
|
+
const end = carrier.sourceCodeLocation?.endTag?.endOffset;
|
|
97
|
+
if (end === undefined)
|
|
98
|
+
throw new Error(`${state.env.file}: unclosed native component template.`);
|
|
99
|
+
let line = startLine;
|
|
100
|
+
while (line < endLine && state.eMarks[line] < start + end)
|
|
101
|
+
line++;
|
|
102
|
+
if (line >= endLine)
|
|
103
|
+
return false;
|
|
104
|
+
if (silent)
|
|
105
|
+
return true;
|
|
106
|
+
const token = state.push('html_block', '', 0);
|
|
107
|
+
token.content = state.src.slice(start, state.eMarks[line]) + '\n';
|
|
108
|
+
token.map = [startLine, line + 1];
|
|
109
|
+
state.line = line + 1;
|
|
110
|
+
return true;
|
|
111
|
+
}, { alt: ['paragraph', 'reference', 'blockquote'] });
|
|
91
112
|
function frontmatter(source, file) {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
113
|
+
const split = splitLoomaFrontmatter(source);
|
|
114
|
+
if (split === null)
|
|
94
115
|
return { body: source, fields: {} };
|
|
95
|
-
const document = parseDocument(
|
|
116
|
+
const document = parseDocument(split.yaml);
|
|
96
117
|
if (document.errors.length > 0)
|
|
97
118
|
throw new Error(`${file}: ${document.errors.map(error => error.message).join('; ')}`);
|
|
98
119
|
const fields = document.toJS();
|
|
99
120
|
if (fields === null || typeof fields !== 'object' || Array.isArray(fields))
|
|
100
121
|
throw new Error(`${file}: frontmatter must be a mapping.`);
|
|
101
|
-
return { body:
|
|
122
|
+
return { body: split.body, fields: fields };
|
|
102
123
|
}
|
|
103
124
|
const text = (value) => typeof value === 'string' && value !== '';
|
|
104
125
|
const strings = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
|
105
126
|
Object.values(value).every(item => typeof item === 'string' || typeof item === 'number');
|
|
106
127
|
const level = (value) => Number.isInteger(value) && value >= 1 && value <= 6;
|
|
107
128
|
const fieldTypes = {
|
|
108
|
-
title: [text, 'a nonempty string'], description: [text, 'a nonempty string'],
|
|
109
|
-
|
|
110
|
-
|
|
129
|
+
title: [text, 'a nonempty string'], description: [text, 'a nonempty string'],
|
|
130
|
+
order: [value => Number.isSafeInteger(value) && value >= 0, 'a whole number'],
|
|
131
|
+
navigation: [value => value === 'hidden' || value === 'collapsed', 'hidden, or collapsed on a folder\'s index page'],
|
|
132
|
+
id: [text, 'a nonempty string'],
|
|
111
133
|
aliases: [value => Array.isArray(value) && value.every(item => typeof item === 'string'), 'a list of route paths'],
|
|
112
134
|
layout: [value => value === false || text(value), 'an HTMLKit layout name or false'],
|
|
113
|
-
|
|
135
|
+
outline: [value => value === false || (Array.isArray(value) && value.length === 2 && value.every(level) && value[0] <= value[1]), 'false or a heading range such as [2, 3]'],
|
|
114
136
|
head: [value => (value !== null && typeof value === 'object' && !Array.isArray(value) && Object.entries(value).every(([key, item]) => key === 'canonical' ? text(item) : (key === 'meta' || key === 'og') && strings(item))), 'a mapping of meta, og and canonical'],
|
|
115
137
|
editLink: [value => value === false || text(value), 'a URL or false'],
|
|
116
138
|
};
|
|
@@ -122,204 +144,58 @@ function validFrontMatter(fields, file) {
|
|
|
122
144
|
if (!type[0](value))
|
|
123
145
|
throw new Error(`${file}: front matter ${name} must be ${type[1]}.`);
|
|
124
146
|
}
|
|
147
|
+
// HTMLKit reads a folder from its index page, so only an index page can start its folder collapsed.
|
|
148
|
+
if (fields.navigation === 'collapsed' && !isIndex(file))
|
|
149
|
+
throw new Error(`${file}: front matter navigation collapsed applies to a folder's index page.`);
|
|
125
150
|
return fields;
|
|
126
151
|
}
|
|
152
|
+
// Blocks rendered as Looma components, which the page links by package specifier, as the theme's shell links
|
|
153
|
+
// ui-button, so the site resolves Looma.
|
|
154
|
+
const blockComponent = (token) => token.type === 'looma_container_open'
|
|
155
|
+
? (token.info === 'tabs' || token.info === 'code-group' ? 'ui-tabs' : token.info === 'details' || token.info === 'figure' ? undefined : 'ui-callout')
|
|
156
|
+
: token.type === 'blockquote_open' && token.tag === 'ui-callout' ? 'ui-callout' : undefined;
|
|
157
|
+
/**
|
|
158
|
+
* The Looma Markdown layer: splits the front matter, parses with Looma's plugin and renders with Markupress's
|
|
159
|
+
* presentation. Front matter fields are left to the host; compileMarkdown validates Markupress's.
|
|
160
|
+
*/
|
|
161
|
+
export async function renderMarkdown(source, options) {
|
|
162
|
+
const { body, fields } = frontmatter(source, options.file);
|
|
163
|
+
const tokens = md.parse(source, { file: options.file });
|
|
164
|
+
const highlighted = new Map(), components = new Set(), headings = [];
|
|
165
|
+
let hasCode = false;
|
|
166
|
+
for (const [index, token] of tokens.entries()) {
|
|
167
|
+
const component = blockComponent(token);
|
|
168
|
+
if (component)
|
|
169
|
+
components.add(component);
|
|
170
|
+
if (token.type === 'heading_open') {
|
|
171
|
+
const id = token.attrGet('id');
|
|
172
|
+
const label = textContent(parseFragment(md.renderer.renderInline(tokens[index + 1].children ?? [], md.options, { highlighted }))).replace(/\\([\\{])/g, '$1');
|
|
173
|
+
headings.push({ id, href: `#${id}`, label, depth: Number(token.tag.slice(1)) });
|
|
174
|
+
}
|
|
175
|
+
if (token.type === 'code_block')
|
|
176
|
+
hasCode = true;
|
|
177
|
+
if (token.type !== 'fence')
|
|
178
|
+
continue;
|
|
179
|
+
hasCode = true;
|
|
180
|
+
const { language, meta, lines, lineNumbers = options.lineNumbers ?? false } = parseLoomaFenceInfo(token.info);
|
|
181
|
+
// Languages Shiki does not bundle stay escaped plain text.
|
|
182
|
+
if (!language || !(Object.hasOwn(bundledLanguages, language) || plain.has(language)))
|
|
183
|
+
continue;
|
|
184
|
+
// Highlighted lines are the fence's `{1,3-5}` and the lines its `[!code highlight]` comments mark.
|
|
185
|
+
const { code, lines: noted } = applyLoomaCodeNotation(token.content.replace(/\n$/, ''));
|
|
186
|
+
const marked = new Set([...lines, ...noted]);
|
|
187
|
+
highlighted.set(token, await highlight(code, language, lineClasses(marked, lineNumbers, meta)));
|
|
188
|
+
}
|
|
189
|
+
return { html: md.renderer.render(tokens, md.options, { highlighted }), frontmatter: fields, body, headings, hasCode, components };
|
|
190
|
+
}
|
|
127
191
|
export async function compileMarkdown(source, options) {
|
|
128
|
-
const {
|
|
192
|
+
const { html, frontmatter: raw, body, headings, hasCode, components } = await renderMarkdown(source, options);
|
|
129
193
|
const fields = { ...options.frontmatter, ...validFrontMatter(raw, options.file) };
|
|
130
194
|
const aliases = fields.aliases ?? [];
|
|
131
|
-
|
|
132
|
-
const used = new Set(), reserved = new Set(), suffixes = new Map();
|
|
133
|
-
const customIds = new Map();
|
|
134
|
-
let firstHeading, hasCode = false;
|
|
135
|
-
const headings = [];
|
|
136
|
-
const highlighted = new Map();
|
|
137
|
-
// Code group panels labelled by their fence title, and the Looma components the page's blocks use. The page
|
|
138
|
-
// links each one by package specifier, as the theme's shell links ui-button, so the site resolves Looma.
|
|
139
|
-
const labelled = new WeakSet(), components = new Set();
|
|
140
|
-
const fail = (message) => { throw new Error(`${options.file}: ${message}`); };
|
|
141
|
-
// Tabs content is "== Label" panels; a code group may instead make each fence a panel labelled by its title.
|
|
142
|
-
const panels = (tokens, code) => {
|
|
143
|
-
const result = [];
|
|
144
|
-
const marked = tokens.some(token => token.type === 'tab-label');
|
|
145
|
-
for (const token of tokens) {
|
|
146
|
-
if (token.type === 'tab-label')
|
|
147
|
-
result.push({ type: 'tab-panel', raw: '', label: token.label, tokens: [] });
|
|
148
|
-
else if (marked && result.length > 0)
|
|
149
|
-
result.at(-1).tokens.push(token);
|
|
150
|
-
else if (!marked && code && token.type === 'code') {
|
|
151
|
-
labelled.add(token);
|
|
152
|
-
result.push({ type: 'tab-panel', raw: '', label: fenceTitle(token.lang) ?? fenceLanguage(token.lang) ?? 'Code', tokens: [token] });
|
|
153
|
-
}
|
|
154
|
-
else if (token.type !== 'space')
|
|
155
|
-
fail(code ? 'a code group holds fences, or "== Label" panels.' : 'tabs content starts with an "== Label" line.');
|
|
156
|
-
}
|
|
157
|
-
return result;
|
|
158
|
-
};
|
|
159
|
-
const markdown = new Marked({ async: true, async walkTokens(token) {
|
|
160
|
-
if (token.type === 'heading') {
|
|
161
|
-
// `## Title {#custom-id}` names the anchor; the marker ends the heading's last text token. Every token is
|
|
162
|
-
// walked before any renders, so a custom ID is reserved before an earlier heading could generate it.
|
|
163
|
-
const last = token.tokens?.at(-1);
|
|
164
|
-
const custom = last?.type === 'text' ? / +\{#([\w-]+)\}$/.exec(last.text) : null;
|
|
165
|
-
if (custom && last?.type === 'text') {
|
|
166
|
-
last.text = last.text.slice(0, custom.index);
|
|
167
|
-
customIds.set(token, custom[1]);
|
|
168
|
-
reserved.add(custom[1]);
|
|
169
|
-
}
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (token.type !== 'code')
|
|
173
|
-
return;
|
|
174
|
-
const language = fenceLanguage(token.lang);
|
|
175
|
-
// Languages Shiki does not bundle stay escaped plain text.
|
|
176
|
-
if (!language || !(Object.hasOwn(bundledLanguages, language) || plain.has(language)))
|
|
177
|
-
return;
|
|
178
|
-
const { marked, numbers = options.lineNumbers ?? false } = fenceLines(token.lang);
|
|
179
|
-
highlighted.set(token, await highlight(highlightNotation(token.text, marked), language, lineClasses(marked, numbers)));
|
|
180
|
-
}, renderer: {
|
|
181
|
-
text(token) { return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : literal(token.text); },
|
|
182
|
-
codespan({ text }) { return `<code>${literal(escapeHTML(text))}</code>`; },
|
|
183
|
-
code(token) {
|
|
184
|
-
const { text, lang = '' } = token;
|
|
185
|
-
const language = fenceLanguage(lang);
|
|
186
|
-
// A code group's tab already shows a panel's fence title.
|
|
187
|
-
const title = labelled.has(token) ? undefined : fenceTitle(lang);
|
|
188
|
-
const pre = highlighted.get(token) ?? `<pre><code${language ? ` class="language-${escapeHTML(language)}"` : ''}>${literal(escapeHTML(text))}</code></pre>`;
|
|
189
|
-
hasCode = true;
|
|
190
|
-
return `<figure class="code">${title ? `<figcaption>${literal(escapeHTML(title))}</figcaption>` : ''}${codeActions}${pre}</figure>\n`;
|
|
191
|
-
},
|
|
192
|
-
heading(token) {
|
|
193
|
-
const { tokens, depth } = token;
|
|
194
|
-
const html = this.parser.parseInline(tokens);
|
|
195
|
-
const label = textContent(parseFragment(html)).replace(/\\([\\{])/g, '$1');
|
|
196
|
-
firstHeading ??= label;
|
|
197
|
-
const custom = customIds.get(token);
|
|
198
|
-
const stem = custom ?? (label.normalize('NFKD').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '') || 'heading');
|
|
199
|
-
// A custom ID is kept unless an earlier heading used it. Any other ID takes the first free suffix,
|
|
200
|
-
// skipping IDs already used and custom IDs reserved by later headings, so no two headings share an ID.
|
|
201
|
-
const taken = (id) => used.has(id) || reserved.has(id);
|
|
202
|
-
let id = stem;
|
|
203
|
-
if (custom === undefined ? taken(id) : used.has(id)) {
|
|
204
|
-
let n = suffixes.get(stem) ?? 1;
|
|
205
|
-
while (taken(`${stem}-${n}`))
|
|
206
|
-
n++;
|
|
207
|
-
suffixes.set(stem, n + 1);
|
|
208
|
-
id = `${stem}-${n}`;
|
|
209
|
-
}
|
|
210
|
-
used.add(id);
|
|
211
|
-
headings.push({ id, href: `#${id}`, label, depth });
|
|
212
|
-
return `<h${depth} id="${escapeHTML(id)}"><a href="#${escapeHTML(id)}">${html}</a></h${depth}>\n`;
|
|
213
|
-
},
|
|
214
|
-
link({ href, title, tokens }) {
|
|
215
|
-
const target = options.resolveLink?.(href, 'link') ?? href;
|
|
216
|
-
return `<a href="${escapeHTML(target)}"${title ? ` title="${escapeHTML(title)}"` : ''}>${this.parser.parseInline(tokens)}</a>`;
|
|
217
|
-
},
|
|
218
|
-
image({ href, title, text }) {
|
|
219
|
-
const target = options.resolveLink?.(href, 'asset') ?? href;
|
|
220
|
-
return `<img src="${escapeHTML(target)}" alt="${escapeHTML(text)}"${title ? ` title="${escapeHTML(title)}"` : ''}>`;
|
|
221
|
-
},
|
|
222
|
-
} });
|
|
223
|
-
markdown.use({ extensions: [{ name: 'native-component', level: 'block',
|
|
224
|
-
start(text) { return text.indexOf('<template'); },
|
|
225
|
-
tokenizer(text) {
|
|
226
|
-
if (!/^ {0,3}<template(?:\s|>)/i.test(text))
|
|
227
|
-
return undefined;
|
|
228
|
-
// Native fragment parsing identifies the carrier boundary, including nested templates.
|
|
229
|
-
// Markdown must never interpret a component's CSS, expressions or internal markup.
|
|
230
|
-
const carrier = parseFragment(text, { sourceCodeLocationInfo: true }).childNodes.find(node => 'tagName' in node);
|
|
231
|
-
if (!carrier || !('tagName' in carrier) || carrier.tagName !== 'template' || !carrier.attrs.some(attr => attr.name === 'component'))
|
|
232
|
-
return undefined;
|
|
233
|
-
const end = carrier.sourceCodeLocation?.endTag?.endOffset;
|
|
234
|
-
if (end === undefined)
|
|
235
|
-
throw new Error(`${options.file}: unclosed native component template.`);
|
|
236
|
-
const raw = text.slice(0, end);
|
|
237
|
-
return { type: 'native-component', raw };
|
|
238
|
-
}, renderer(token) { return token.raw; },
|
|
239
|
-
}, { name: 'container', level: 'block',
|
|
240
|
-
// `::: name Title` … `:::` is a shared block; any other name stays text. A container closes at the first
|
|
241
|
-
// marker at least as long as its own, so an outer container uses more colons than those it contains.
|
|
242
|
-
start(text) { return /^ {0,3}:{3,}[ \t]*(?:note|tip|info|important|warning|danger|caution|details|tabs|code-group)(?![\w-])/im.exec(text)?.index; },
|
|
243
|
-
tokenizer(text) {
|
|
244
|
-
const open = /^ {0,3}(:{3,})[ \t]*([A-Za-z][\w-]*)(?:[ \t]+([^\n]*?))?[ \t]*(?:\n|$)/.exec(text);
|
|
245
|
-
const name = open?.[2].toLowerCase();
|
|
246
|
-
if (!open || !name || !blocks.has(name))
|
|
247
|
-
return undefined;
|
|
248
|
-
const close = new RegExp(`^ {0,3}:{${open[1].length},}[ \\t]*$`);
|
|
249
|
-
// A marker line inside fenced code is code, so the scan skips fences while looking for the close.
|
|
250
|
-
let fence;
|
|
251
|
-
let offset = open[0].length;
|
|
252
|
-
for (const line of text.slice(offset).split('\n')) {
|
|
253
|
-
if (fence) {
|
|
254
|
-
if (fence.test(line))
|
|
255
|
-
fence = undefined;
|
|
256
|
-
}
|
|
257
|
-
else if (close.test(line)) {
|
|
258
|
-
// Like a blockquote, the content is a document of its own, so its paragraphs stay paragraphs inside a list.
|
|
259
|
-
// `== Label` lines start panels only directly inside tabs.
|
|
260
|
-
const grouped = name === 'tabs' || name === 'code-group';
|
|
261
|
-
const state = this.lexer.state, { top, tabs } = state;
|
|
262
|
-
state.top = true;
|
|
263
|
-
state.tabs = grouped;
|
|
264
|
-
const tokens = this.lexer.blockTokens(text.slice(open[0].length, offset));
|
|
265
|
-
state.top = top;
|
|
266
|
-
state.tabs = tabs;
|
|
267
|
-
return { type: 'container', raw: text.slice(0, offset + line.length + 1), name, title: open[3] ?? '', tokens: grouped ? panels(tokens, name === 'code-group') : tokens };
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
const opener = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
271
|
-
if (opener)
|
|
272
|
-
fence = new RegExp(`^ {0,3}${opener[0]}{${opener.length},}[ \\t]*$`);
|
|
273
|
-
}
|
|
274
|
-
offset += line.length + 1;
|
|
275
|
-
}
|
|
276
|
-
return undefined;
|
|
277
|
-
}, renderer(token) {
|
|
278
|
-
const { name, title } = token;
|
|
279
|
-
const content = (tokens = token.tokens ?? []) => this.parser.parse(tokens);
|
|
280
|
-
const tone = tones[name];
|
|
281
|
-
if (tone) {
|
|
282
|
-
components.add('ui-callout');
|
|
283
|
-
return `<ui-callout tone="${tone}"${title ? ` heading="${escapeHTML(title)}"` : ''}>\n${content()}</ui-callout>\n`;
|
|
284
|
-
}
|
|
285
|
-
// Native <details> opens without JavaScript and find-in-page can reveal it; the contract allows it in place of ui-disclosure.
|
|
286
|
-
if (name === 'details') {
|
|
287
|
-
const [, summary, open] = /^(.*?)[ \t]*(\{open\})?$/.exec(title);
|
|
288
|
-
return `<details${open ? ' open' : ''}><summary>${literal(escapeHTML(summary || 'Details'))}</summary>\n${content()}</details>\n`;
|
|
289
|
-
}
|
|
290
|
-
// Tabs of one group keep one selection across the page and remember it; the group defaults to the block name.
|
|
291
|
-
components.add('ui-tabs');
|
|
292
|
-
return `<ui-tabs${name === 'code-group' ? ' class="code-group"' : ''} group="${escapeHTML(title || name)}">\n${token.tokens.map(panel => `<section aria-label="${escapeHTML(panel.label)}">\n${content(panel.tokens)}</section>\n`).join('')}</ui-tabs>\n`;
|
|
293
|
-
},
|
|
294
|
-
}, { name: 'alert', level: 'block',
|
|
295
|
-
// A GitHub alert, `> [!TIP]` alone on a quote's first line, is a callout without a title.
|
|
296
|
-
start(text) { return /^ {0,3}>[ \t]?\[!/m.exec(text)?.index; },
|
|
297
|
-
tokenizer(text) {
|
|
298
|
-
const kind = /^ {0,3}>[ \t]?\[!(note|tip|important|warning|caution)\][ \t]*(?:\n|$)/i.exec(text)?.[1];
|
|
299
|
-
const quote = kind === undefined ? undefined : this.lexer.tokenizer.blockquote(text);
|
|
300
|
-
if (!kind || !quote)
|
|
301
|
-
return undefined;
|
|
302
|
-
const state = this.lexer.state, { top, tabs } = state;
|
|
303
|
-
state.top = true;
|
|
304
|
-
state.tabs = false;
|
|
305
|
-
const tokens = this.lexer.blockTokens(quote.text.replace(/^[^\n]*\n?/, ''));
|
|
306
|
-
state.top = top;
|
|
307
|
-
state.tabs = tabs;
|
|
308
|
-
return { type: 'container', raw: quote.raw, name: kind.toLowerCase(), title: '', tokens };
|
|
309
|
-
},
|
|
310
|
-
}, { name: 'tab-label', level: 'block',
|
|
311
|
-
start(text) { return this.lexer.state.tabs ? /^ {0,3}==[ \t]/m.exec(text)?.index : undefined; },
|
|
312
|
-
tokenizer(text) {
|
|
313
|
-
if (!this.lexer.state.tabs)
|
|
314
|
-
return undefined;
|
|
315
|
-
const label = /^ {0,3}==[ \t]+(\S[^\n]*?)[ \t]*(?:\n|$)/.exec(text);
|
|
316
|
-
return label ? { type: 'tab-label', raw: label[0], label: label[1] } : undefined;
|
|
317
|
-
},
|
|
318
|
-
// Panels consume the labels directly inside tabs; one nested deeper, as in a quote, stays text.
|
|
319
|
-
renderer(token) { return `<p>${literal(escapeHTML(token.raw.trim()))}</p>\n`; }, }] });
|
|
320
|
-
const fragment = parseFragment(await markdown.parse(body));
|
|
195
|
+
const fragment = parseFragment(html);
|
|
321
196
|
const declarations = [];
|
|
322
197
|
const metadata = [];
|
|
198
|
+
const imports = [];
|
|
323
199
|
const original = pathToFileURL(resolve(options.file)).href;
|
|
324
200
|
const rebase = (value) => /^(?:\.\.?\/|\/)/.test(value) ? new URL(value, original).href : value;
|
|
325
201
|
const visit = (node, inCarrier) => {
|
|
@@ -334,7 +210,7 @@ export async function compileMarkdown(source, options) {
|
|
|
334
210
|
if (node.tagName === 'link' && attribute.name === 'href' && node.attrs.some(attr => attr.name === 'rel' && attr.value.split(/\s+/).includes('component')))
|
|
335
211
|
attribute.value = rebase(attribute.value);
|
|
336
212
|
else if (options.resolveLink && ((attribute.name === 'href' && node.tagName === 'a') || (attribute.name === 'src' && node.tagName === 'img'))) {
|
|
337
|
-
//
|
|
213
|
+
// Every authored link and image, from Markdown or raw HTML, resolves here once.
|
|
338
214
|
attribute.value = options.resolveLink(attribute.value, attribute.name === 'src' ? 'asset' : 'link');
|
|
339
215
|
}
|
|
340
216
|
}
|
|
@@ -349,6 +225,11 @@ export async function compileMarkdown(source, options) {
|
|
|
349
225
|
declarations.push(serializeOuter(child));
|
|
350
226
|
else if (!inCarrier && 'tagName' in child && child.tagName === 'link' && child.attrs.some(attr => attr.name === 'rel' && attr.value.split(/\s+/).includes('component')))
|
|
351
227
|
declarations.push(serializeOuter(child));
|
|
228
|
+
// A stylesheet is no head declaration: the page's <style> imports it, which scopes it to the page as its own CSS.
|
|
229
|
+
else if (!inCarrier && 'tagName' in child && child.tagName === 'link' && child.attrs.some(attr => attr.name === 'rel' && attr.value.split(/\s+/).includes('stylesheet'))) {
|
|
230
|
+
const attr = (name) => child.attrs.find(item => item.name === name)?.value;
|
|
231
|
+
imports.push(`@import ${JSON.stringify(attr('href') ?? '')}${attr('media') ? ` ${attr('media')}` : ''};`);
|
|
232
|
+
}
|
|
352
233
|
else if (!inCarrier && 'tagName' in child && ['title', 'meta', 'link'].includes(child.tagName))
|
|
353
234
|
metadata.push(serializeOuter(child));
|
|
354
235
|
else
|
|
@@ -358,25 +239,63 @@ export async function compileMarkdown(source, options) {
|
|
|
358
239
|
}
|
|
359
240
|
};
|
|
360
241
|
visit(fragment, false);
|
|
361
|
-
|
|
242
|
+
const title = fields.title ?? headings[0]?.label ?? basename(options.file, '.md').replace(/^\d+\./, '');
|
|
243
|
+
const blocks = new Set(components);
|
|
244
|
+
const article = fields.layout === 'home' ? home(fragment, title, blocks) : serialize(fragment);
|
|
245
|
+
for (const name of blocks)
|
|
362
246
|
declarations.push(`<link rel="component" href="@threadlabs/looma/components/${name}/${name}.html">`);
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
// Front matter becomes HTMLKit page
|
|
366
|
-
// layout choice, and the document head,
|
|
247
|
+
// A folder's index page with front matter only generates the folder's listing, which the layout's loader supplies.
|
|
248
|
+
const generated = isIndex(options.file) && body.trim() === '';
|
|
249
|
+
// Front matter becomes head declarations and HTMLKit settings in the page's <defs>: HTMLKit owns navigation labels,
|
|
250
|
+
// order, visibility, alias routes, the layout choice, and the document head, where the page's entries win over its layout's.
|
|
367
251
|
const { meta = {}, og = {}, canonical } = fields.head ?? {};
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
(fields.
|
|
252
|
+
// An authored <title> takes the place of the generated one, since a definition declares one title.
|
|
253
|
+
const head = (metadata.some(item => item.startsWith('<title')) ? '' : `<title>${literal(escapeHTML(title))}</title>`) + (fields.description === undefined ? '' : `<meta name="description" content="${escapeHTML(fields.description)}">`) +
|
|
254
|
+
`<meta name="hk:label" content="${escapeHTML(title)}">` + (fields.navigation === 'hidden' ? '<meta name="hk:navigation" content="hidden">' : '') +
|
|
255
|
+
(fields.order === undefined ? '' : `<meta name="hk:order" content="${fields.order}">`) +
|
|
371
256
|
aliases.map(alias => `<meta name="hk:alias" content="${escapeHTML(alias)}">`).join('') +
|
|
372
257
|
(fields.layout === undefined ? '' : `<meta name="hk:layout" content="${fields.layout === false ? 'none' : escapeHTML(fields.layout)}">`) +
|
|
373
258
|
Object.entries(meta).map(([name, content]) => `<meta name="${escapeHTML(name)}" content="${escapeHTML(String(content))}">`).join('') +
|
|
374
259
|
Object.entries(og).map(([name, content]) => `<meta property="og:${escapeHTML(name)}" content="${escapeHTML(String(content))}">`).join('') +
|
|
375
260
|
(canonical === undefined ? '' : `<link rel="canonical" href="${escapeHTML(canonical)}">`);
|
|
376
|
-
const [from, to] = fields.
|
|
377
|
-
const
|
|
378
|
-
|
|
261
|
+
const [from, to] = fields.outline === false ? [0, -1] : fields.outline ?? [2, 3];
|
|
262
|
+
const css = [...(options.styles ?? []).map(file => `@import "${pathToFileURL(file).href}";`), ...imports].join('');
|
|
263
|
+
const styles = css ? `<style>${stylesheet(css, options.file, true).css}</style>` : '';
|
|
264
|
+
return { title, aliases, generated, ...(fields.id === undefined ? {} : { id: fields.id }),
|
|
379
265
|
frontmatter: fields, outline: headings.filter(heading => heading.depth >= from && heading.depth <= to),
|
|
380
|
-
resource: `<meta name="hk:page" content="${escapeHTML(options.pageName)}">\n${declarations.join('\n')}\n<template component="${escapeHTML(options.pageName)}"${hasCode ? ' controller="markupress/controllers/code.js"' : ''}>${head}${metadata.join('')}
|
|
266
|
+
resource: `<meta name="hk:page" content="${escapeHTML(options.pageName)}">\n${declarations.join('\n')}\n<template component="${escapeHTML(options.pageName)}"${hasCode ? ' controller="markupress/controllers/code.js"' : ''}><defs>${head}${metadata.join('')}</defs><article class="markupress-prose">${generated ? `<h1>${literal(escapeHTML(title))}</h1>` : ''}${article}</article>${styles}</template>\n` };
|
|
267
|
+
}
|
|
268
|
+
const element = (node, tag) => 'tagName' in node && node.tagName === tag;
|
|
269
|
+
/** A paragraph holding only `tag` elements, at least one, and the space between them. */
|
|
270
|
+
const only = (node, tag) => element(node, 'p') && node.childNodes.some(child => element(child, tag)) &&
|
|
271
|
+
node.childNodes.every(child => element(child, tag) || (child.nodeName === '#text' && 'value' in child && child.value.trim() === ''));
|
|
272
|
+
/**
|
|
273
|
+
* A home page (`layout: home`) is read from its Markdown structure, so it reads the same as plain Markdown anywhere.
|
|
274
|
+
* Everything before the first h2 is the hero: its h1 is the title, a paragraph of only links is its actions (the first
|
|
275
|
+
* one primary), an image alone in its paragraph is its image, and the rest is its tagline. Each h2 starts a feature card
|
|
276
|
+
* that holds everything up to the next h2.
|
|
277
|
+
*/
|
|
278
|
+
function home(fragment, title, components) {
|
|
279
|
+
const hero = [], features = [];
|
|
280
|
+
for (const node of fragment.childNodes) {
|
|
281
|
+
if (element(node, 'h2'))
|
|
282
|
+
features.push([node]);
|
|
283
|
+
else
|
|
284
|
+
(features.at(-1) ?? hero).push(node);
|
|
285
|
+
}
|
|
286
|
+
// Looma Markdown renders an image alone in its paragraph as a block image, with no paragraph.
|
|
287
|
+
const actions = hero.find(node => only(node, 'a')), image = hero.find(node => element(node, 'img') || only(node, 'img'));
|
|
288
|
+
// Markdown puts text in blocks, so the fragment's own text nodes are only the space between them.
|
|
289
|
+
const outer = (nodes) => nodes.map(node => 'tagName' in node ? serializeOuter(node) : '').join('');
|
|
290
|
+
const heading = hero.some(node => element(node, 'h1')) ? '' : `<h1>${literal(escapeHTML(title))}</h1>`;
|
|
291
|
+
const buttons = (actions && 'childNodes' in actions ? actions.childNodes : []).filter(child => element(child, 'a')).map((link, index) => `<ui-button as="a" href="${escapeHTML(link.attrs.find(attr => attr.name === 'href')?.value ?? '')}" variant="${index === 0 ? 'solid' : 'outline'}" size="lg">${serialize(link)}</ui-button>`).join('');
|
|
292
|
+
if (buttons)
|
|
293
|
+
components.add('ui-cluster').add('ui-button');
|
|
294
|
+
if (features.length > 0)
|
|
295
|
+
components.add('ui-grid').add('ui-card');
|
|
296
|
+
return `<header class="hero"><div class="hero-text">${heading}${outer(hero.filter(node => node !== actions && node !== image))}` +
|
|
297
|
+
(buttons ? `<ui-cluster class="actions" gap="m">${buttons}</ui-cluster>` : '') + '</div>' +
|
|
298
|
+
(image ? `<div class="hero-image">${element(image, 'p') ? serialize(image) : outer([image])}</div>` : '') + '</header>' +
|
|
299
|
+
(features.length === 0 ? '' : `<ui-grid class="features" min="md">${features.map(nodes => `<ui-card as="section">${outer(nodes)}</ui-card>`).join('')}</ui-grid>`);
|
|
381
300
|
}
|
|
382
301
|
//# sourceMappingURL=content.js.map
|