@threadlabs/looma 0.28.14 → 0.28.16
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/editor/chunks/extensions.js +521 -75
- package/editor/extensions/callout.d.ts +2 -9
- package/editor/extensions/chip.d.ts +3 -2
- package/editor/extensions/index.js +1 -1
- package/editor/extensions/markdown.d.ts +6 -7
- package/editor/image-delivery.d.ts +10 -1
- package/editor/index.js +1 -1
- package/markdown/fixtures.d.ts +27 -0
- package/markdown/fixtures.js +214 -0
- package/markdown/index.d.ts +106 -0
- package/markdown/index.js +230 -0
- package/markdown/markdown-it.d.ts +13 -0
- package/markdown/markdown-it.js +397 -0
- package/package.json +17 -1
- package/vue/editor/LoomaEditor.d.ts +10 -1
- package/vue/editor/index.d.ts +1 -1
- package/vue/editor/index.js +25 -11
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
//#region src/markdown/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Looma Markdown's vocabulary and pure rules, shared by every reader and writer.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here touches the DOM or a Markdown parser, so it is safe to import during SSR. The
|
|
6
|
+
* markdown-it plugin is `@threadlabs/looma/markdown/markdown-it`; conformance fixtures are
|
|
7
|
+
* `@threadlabs/looma/markdown/fixtures`.
|
|
8
|
+
*/
|
|
9
|
+
/** The meanings Callout and Badge share. Callout adds `note`, `tip` and `important`; Badge adds `accent`. */
|
|
10
|
+
var LOOMA_MEANING_TONES = [
|
|
11
|
+
"neutral",
|
|
12
|
+
"info",
|
|
13
|
+
"success",
|
|
14
|
+
"warning",
|
|
15
|
+
"danger"
|
|
16
|
+
];
|
|
17
|
+
/** Badge's tones: the shared meanings and `accent`. */
|
|
18
|
+
var LOOMA_BADGE_TONES = [
|
|
19
|
+
"neutral",
|
|
20
|
+
"accent",
|
|
21
|
+
"info",
|
|
22
|
+
"success",
|
|
23
|
+
"warning",
|
|
24
|
+
"danger"
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Callout tones a document stores, in the order the editor offers them. The Callout component also
|
|
28
|
+
* takes `neutral` and `success`, which Markdown and the editor store as `note`.
|
|
29
|
+
*/
|
|
30
|
+
var LOOMA_CALLOUT_TONES = [
|
|
31
|
+
"note",
|
|
32
|
+
"tip",
|
|
33
|
+
"info",
|
|
34
|
+
"important",
|
|
35
|
+
"warning",
|
|
36
|
+
"danger"
|
|
37
|
+
];
|
|
38
|
+
/** Names that read as a stored tone: `caution` is `danger`, and `neutral` is the same muted aside as `note`. */
|
|
39
|
+
var LOOMA_CALLOUT_TONE_ALIASES = {
|
|
40
|
+
caution: "danger",
|
|
41
|
+
neutral: "note"
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Maps a tone name or alias, in any case, onto a stored tone.
|
|
45
|
+
* @failure Unknown and non-string values become `note`.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeLoomaCalloutTone(value) {
|
|
48
|
+
if (typeof value !== "string") return "note";
|
|
49
|
+
const tone = value.toLowerCase();
|
|
50
|
+
return LOOMA_CALLOUT_TONES.includes(tone) ? tone : LOOMA_CALLOUT_TONE_ALIASES[tone] ?? "note";
|
|
51
|
+
}
|
|
52
|
+
/** The hues chips and swatches take as their tone, written `:chip[Label]{tone=green}`. */
|
|
53
|
+
var LOOMA_CHIP_TONES = [
|
|
54
|
+
"neutral",
|
|
55
|
+
"blue",
|
|
56
|
+
"green",
|
|
57
|
+
"yellow",
|
|
58
|
+
"red",
|
|
59
|
+
"purple"
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Keeps a chip tone within the palette.
|
|
63
|
+
* @failure Unknown values become `neutral`.
|
|
64
|
+
*/
|
|
65
|
+
function normalizeLoomaChipTone(value) {
|
|
66
|
+
return LOOMA_CHIP_TONES.includes(value) ? value : "neutral";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Names that open a `:::` container, matched without regard to case. A container with any other
|
|
70
|
+
* name is ordinary text.
|
|
71
|
+
*/
|
|
72
|
+
var LOOMA_CONTAINER_NAMES = /* @__PURE__ */ new Set([
|
|
73
|
+
...LOOMA_CALLOUT_TONES,
|
|
74
|
+
"caution",
|
|
75
|
+
"details",
|
|
76
|
+
"figure",
|
|
77
|
+
"tabs",
|
|
78
|
+
"code-group"
|
|
79
|
+
]);
|
|
80
|
+
var ATTRIBUTE_KEY = /[A-Za-z][\w-]*/y;
|
|
81
|
+
var ATTRIBUTE_ID = /#([\w-]+)/y;
|
|
82
|
+
var ATTRIBUTE_VALUE = /[^\s"'=<>`{}]+/y;
|
|
83
|
+
function sticky(pattern, source, index) {
|
|
84
|
+
pattern.lastIndex = index;
|
|
85
|
+
return pattern.exec(source);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reads a brace attribute list starting at `source[start]`, which must be `{`.
|
|
89
|
+
* @contract Returns the attributes and the index just past the closing brace.
|
|
90
|
+
* @failure Returns null for anything that is not a complete, well-formed list, so the text stays literal.
|
|
91
|
+
*/
|
|
92
|
+
function readLoomaAttributes(source, start = 0) {
|
|
93
|
+
if (source[start] !== "{") return null;
|
|
94
|
+
const attributes = {};
|
|
95
|
+
let index = start + 1;
|
|
96
|
+
for (;;) {
|
|
97
|
+
while (source[index] === " " || source[index] === " ") index++;
|
|
98
|
+
if (source[index] === "}") return {
|
|
99
|
+
attributes,
|
|
100
|
+
end: index + 1
|
|
101
|
+
};
|
|
102
|
+
if (index > start + 1 && !/\s/.test(source[index - 1])) return null;
|
|
103
|
+
const id = sticky(ATTRIBUTE_ID, source, index);
|
|
104
|
+
if (id) {
|
|
105
|
+
attributes.id = id[1];
|
|
106
|
+
index += id[0].length;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const key = sticky(ATTRIBUTE_KEY, source, index);
|
|
110
|
+
if (!key) return null;
|
|
111
|
+
index += key[0].length;
|
|
112
|
+
if (source[index] !== "=") {
|
|
113
|
+
attributes[key[0]] = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
index++;
|
|
117
|
+
const quote = source[index];
|
|
118
|
+
if (quote === "\"" || quote === "'") {
|
|
119
|
+
const close = source.indexOf(quote, index + 1);
|
|
120
|
+
if (close < 0) return null;
|
|
121
|
+
attributes[key[0]] = source.slice(index + 1, close);
|
|
122
|
+
index = close + 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const value = sticky(ATTRIBUTE_VALUE, source, index);
|
|
126
|
+
if (!value) return null;
|
|
127
|
+
attributes[key[0]] = value[0];
|
|
128
|
+
index += value[0].length;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads a whole string as one brace attribute list.
|
|
133
|
+
* @failure Returns null unless the string is exactly one well-formed list.
|
|
134
|
+
*/
|
|
135
|
+
function parseLoomaAttributes(source) {
|
|
136
|
+
const read = readLoomaAttributes(source);
|
|
137
|
+
return read && read.end === source.length ? read.attributes : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Writes attributes as a brace list, `id` first as `#id`, then the rest in order.
|
|
141
|
+
* @contract `true` writes a bare key; values are quoted only when they need it. `false`, null and
|
|
142
|
+
* undefined values are left out, and an empty set writes an empty string.
|
|
143
|
+
*/
|
|
144
|
+
function formatLoomaAttributes(attributes) {
|
|
145
|
+
const items = [];
|
|
146
|
+
const { id, ...rest } = attributes;
|
|
147
|
+
if (typeof id === "string" && id) items.push(`#${id}`);
|
|
148
|
+
for (const [key, value] of Object.entries(rest)) if (value === true) items.push(key);
|
|
149
|
+
else if (value === false || value === null || value === void 0) continue;
|
|
150
|
+
else {
|
|
151
|
+
const text = String(value);
|
|
152
|
+
items.push(/^[^\s"'=<>`{}]+$/.test(text) ? `${key}=${text}` : text.includes("\"") ? `${key}='${text}'` : `${key}="${text}"`);
|
|
153
|
+
}
|
|
154
|
+
return items.length ? `{${items.join(" ")}}` : "";
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
|
|
158
|
+
* marks, with every other run of characters one hyphen.
|
|
159
|
+
* @failure Text with no letters or numbers, such as only emoji, derives `heading`.
|
|
160
|
+
*/
|
|
161
|
+
function loomaHeadingSlug(text) {
|
|
162
|
+
return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Gives every heading of a document its anchor, in document order.
|
|
166
|
+
* @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
|
|
167
|
+
* its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
|
|
168
|
+
*/
|
|
169
|
+
function assignLoomaHeadingIds(headings) {
|
|
170
|
+
const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
|
|
171
|
+
const used = /* @__PURE__ */ new Set();
|
|
172
|
+
return headings.map(({ text, id: custom }) => {
|
|
173
|
+
let id = custom || loomaHeadingSlug(text);
|
|
174
|
+
if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
|
|
175
|
+
const stem = id;
|
|
176
|
+
let suffix = 1;
|
|
177
|
+
while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
|
|
178
|
+
id = `${stem}-${suffix}`;
|
|
179
|
+
}
|
|
180
|
+
used.add(id);
|
|
181
|
+
return id;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Reads a fence's info string: ```` ```ts title="a.ts" {1,3-5} :line-numbers ````. */
|
|
185
|
+
function parseLoomaFenceInfo(info) {
|
|
186
|
+
const source = info.trim();
|
|
187
|
+
const language = /^[^\s={:]+(?=[\s{:]|$)/.exec(source)?.[0];
|
|
188
|
+
const meta = (language ? source.slice(language.length) : source).trim();
|
|
189
|
+
const title = /(?:^|\s)title="([^"]*)"/.exec(meta)?.[1];
|
|
190
|
+
const rest = meta.replace(/(?:^|\s)title="[^"]*"/, "");
|
|
191
|
+
const lines = /* @__PURE__ */ new Set();
|
|
192
|
+
for (const range of /\{([\d\s,-]+)\}/.exec(rest)?.[1].split(",") ?? []) {
|
|
193
|
+
const [from = NaN, to = from] = range.split("-").map((part) => Number.parseInt(part, 10));
|
|
194
|
+
for (let line = from; line <= to; line++) lines.add(line);
|
|
195
|
+
}
|
|
196
|
+
const numbers = /:(no-)?line-numbers(?![\w-])/.exec(rest);
|
|
197
|
+
return {
|
|
198
|
+
...language ? { language } : {},
|
|
199
|
+
meta,
|
|
200
|
+
...title !== void 0 ? { title } : {},
|
|
201
|
+
lines: [...lines].sort((a, b) => a - b),
|
|
202
|
+
...numbers ? { lineNumbers: numbers[1] === void 0 } : {}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Splits YAML front matter from the start of a document.
|
|
207
|
+
* @contract Front matter opens with a `---` line at the very start, holds at least one `key: value`
|
|
208
|
+
* line, and closes at a `---` or `...` line. `source` is the block as written, delimiters included.
|
|
209
|
+
* @failure Returns null when the document has none.
|
|
210
|
+
*/
|
|
211
|
+
function splitLoomaFrontmatter(document) {
|
|
212
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(document.slice(0, 8192));
|
|
213
|
+
if (!match || !/^[A-Za-z][\w-]*:[ \t]*\S/m.test(match[1])) return null;
|
|
214
|
+
return {
|
|
215
|
+
source: match[0].trimEnd(),
|
|
216
|
+
yaml: match[1],
|
|
217
|
+
body: document.slice(match[0].length)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/** The colons a container opens and closes with, given how deeply containers nest inside it. */
|
|
221
|
+
function loomaContainerMarker(nestedDepth) {
|
|
222
|
+
return ":".repeat(3 + Math.max(0, nestedDepth));
|
|
223
|
+
}
|
|
224
|
+
/** A backtick fence longer than any run of backticks in the code, and at least three long. */
|
|
225
|
+
function loomaCodeFence(code) {
|
|
226
|
+
const longest = Math.max(0, ...(code.match(/`+/g) ?? []).map((run) => run.length));
|
|
227
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
export { LOOMA_BADGE_TONES, LOOMA_CALLOUT_TONES, LOOMA_CALLOUT_TONE_ALIASES, LOOMA_CHIP_TONES, LOOMA_CONTAINER_NAMES, LOOMA_MEANING_TONES, assignLoomaHeadingIds, formatLoomaAttributes, loomaCodeFence, loomaContainerMarker, loomaHeadingSlug, normalizeLoomaCalloutTone, normalizeLoomaChipTone, parseLoomaAttributes, parseLoomaFenceInfo, readLoomaAttributes, splitLoomaFrontmatter };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
/**
|
|
3
|
+
* Adds Looma Markdown to a markdown-it instance: containers, alerts, task lists, the table of
|
|
4
|
+
* contents, chips, mentions, brace attributes, heading anchors, code meta and front matter.
|
|
5
|
+
* @contract Renders the canonical HTML, which is the HTML LoomaEditor saves. It is not sanitized.
|
|
6
|
+
* Linkify and raw HTML are instance options; `createLoomaMarkdownIt` turns both on.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loomaMarkdown(md: MarkdownIt): void;
|
|
9
|
+
/**
|
|
10
|
+
* A markdown-it instance for Looma Markdown: raw HTML and GFM autolinks on, with `loomaMarkdown`.
|
|
11
|
+
* @contract Each call returns a new instance that callers may extend.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createLoomaMarkdownIt(): MarkdownIt;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { LOOMA_CONTAINER_NAMES, assignLoomaHeadingIds, normalizeLoomaCalloutTone, normalizeLoomaChipTone, parseLoomaAttributes, parseLoomaFenceInfo, readLoomaAttributes, splitLoomaFrontmatter } from "./index.js";
|
|
2
|
+
import MarkdownIt from "markdown-it";
|
|
3
|
+
//#region src/markdown/markdown-it.ts
|
|
4
|
+
var CONTAINER_OPEN = /^(:{3,})[ \t]*([A-Za-z][\w-]*)(?:[ \t]+(.*?))?[ \t]*$/;
|
|
5
|
+
var CONTAINER_CLOSE = /^:{3,}[ \t]*$/;
|
|
6
|
+
var FENCE_OPEN = /^(`{3,}|~{3,})/;
|
|
7
|
+
var TAB_LABEL = /^==[ \t]+(\S.*?)[ \t]*$/;
|
|
8
|
+
var OPEN_FLAG = /[ \t]*\{open\}$/;
|
|
9
|
+
var ALERT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)/i;
|
|
10
|
+
var LEAF = /^::([A-Za-z][\w-]*)(\{.*\})?[ \t]*$/;
|
|
11
|
+
var TASK = /^\[([ xX])\][ \t]+/;
|
|
12
|
+
var TOC_FORMATS = /* @__PURE__ */ new Set([
|
|
13
|
+
"plain",
|
|
14
|
+
"bulleted",
|
|
15
|
+
"numbered"
|
|
16
|
+
]);
|
|
17
|
+
var PLACEMENTS = /* @__PURE__ */ new Set([
|
|
18
|
+
"block",
|
|
19
|
+
"center",
|
|
20
|
+
"wrap-left",
|
|
21
|
+
"wrap-right"
|
|
22
|
+
]);
|
|
23
|
+
var opensContainer = (text) => {
|
|
24
|
+
const match = CONTAINER_OPEN.exec(text);
|
|
25
|
+
return match && LOOMA_CONTAINER_NAMES.has(match[2].toLowerCase()) ? match : null;
|
|
26
|
+
};
|
|
27
|
+
function lineText(state, line) {
|
|
28
|
+
return state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]);
|
|
29
|
+
}
|
|
30
|
+
function frontmatter(state, startLine, _endLine, silent) {
|
|
31
|
+
if (startLine !== 0 || state.blkIndent !== 0) return false;
|
|
32
|
+
const split = splitLoomaFrontmatter(state.src);
|
|
33
|
+
if (!split) return false;
|
|
34
|
+
if (silent) return true;
|
|
35
|
+
const lines = split.source.split("\n").length;
|
|
36
|
+
const token = state.push("looma_frontmatter", "", 0);
|
|
37
|
+
Object.assign(token, {
|
|
38
|
+
content: split.yaml,
|
|
39
|
+
block: true,
|
|
40
|
+
hidden: true,
|
|
41
|
+
map: [0, lines]
|
|
42
|
+
});
|
|
43
|
+
state.env.frontmatter = split.yaml;
|
|
44
|
+
state.line = lines;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
function container(state, startLine, endLine, silent) {
|
|
48
|
+
if (state.sCount[startLine] - state.blkIndent >= 4) return false;
|
|
49
|
+
const match = opensContainer(lineText(state, startLine));
|
|
50
|
+
if (!match) return false;
|
|
51
|
+
if (silent) return true;
|
|
52
|
+
const name = match[2].toLowerCase();
|
|
53
|
+
const grouped = name === "tabs" || name === "code-group";
|
|
54
|
+
const labels = [];
|
|
55
|
+
let depth = 1;
|
|
56
|
+
let fence = null;
|
|
57
|
+
let next = startLine + 1;
|
|
58
|
+
for (; next < endLine; next++) {
|
|
59
|
+
const text = lineText(state, next);
|
|
60
|
+
if (text && state.sCount[next] < state.blkIndent) break;
|
|
61
|
+
if (fence) {
|
|
62
|
+
const close = FENCE_OPEN.exec(text);
|
|
63
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length && !text.slice(close[1].length).trim()) fence = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const opener = FENCE_OPEN.exec(text);
|
|
67
|
+
if (opener) {
|
|
68
|
+
fence = {
|
|
69
|
+
char: opener[1][0],
|
|
70
|
+
length: opener[1].length
|
|
71
|
+
};
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (CONTAINER_CLOSE.test(text)) {
|
|
75
|
+
if (--depth === 0) break;
|
|
76
|
+
} else if (opensContainer(text)) depth++;
|
|
77
|
+
else if (grouped && depth === 1) {
|
|
78
|
+
const label = TAB_LABEL.exec(text);
|
|
79
|
+
if (label) labels.push({
|
|
80
|
+
line: next,
|
|
81
|
+
label: label[1]
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let title = (match[3] ?? "").trim();
|
|
86
|
+
const open = name === "details" && OPEN_FLAG.test(title);
|
|
87
|
+
if (name === "details") title = title.replace(OPEN_FLAG, "");
|
|
88
|
+
const parentType = state.parentType;
|
|
89
|
+
const lineMax = state.lineMax;
|
|
90
|
+
state.parentType = "container";
|
|
91
|
+
state.lineMax = next;
|
|
92
|
+
const opening = state.push("looma_container_open", "div", 1);
|
|
93
|
+
Object.assign(opening, {
|
|
94
|
+
info: name,
|
|
95
|
+
meta: {
|
|
96
|
+
title,
|
|
97
|
+
open
|
|
98
|
+
},
|
|
99
|
+
block: true,
|
|
100
|
+
map: [startLine, next],
|
|
101
|
+
markup: match[1]
|
|
102
|
+
});
|
|
103
|
+
const panel = (label, from, to) => {
|
|
104
|
+
state.push("looma_tab_open", "section", 1).meta = { label };
|
|
105
|
+
state.md.block.tokenize(state, from, to);
|
|
106
|
+
state.push("looma_tab_close", "section", -1);
|
|
107
|
+
};
|
|
108
|
+
if (labels.length) {
|
|
109
|
+
state.md.block.tokenize(state, startLine + 1, labels[0].line);
|
|
110
|
+
labels.forEach(({ line, label }, index) => panel(label, line + 1, labels[index + 1]?.line ?? next));
|
|
111
|
+
} else {
|
|
112
|
+
const first = state.tokens.length;
|
|
113
|
+
state.md.block.tokenize(state, startLine + 1, next);
|
|
114
|
+
if (name === "code-group") for (let index = state.tokens.length - 1; index >= first; index--) {
|
|
115
|
+
const token = state.tokens[index];
|
|
116
|
+
if (token.type !== "fence" || token.level !== opening.level + 1) continue;
|
|
117
|
+
const { title: fenceTitle, language } = parseLoomaFenceInfo(token.info);
|
|
118
|
+
const tabOpen = new state.Token("looma_tab_open", "section", 1);
|
|
119
|
+
tabOpen.meta = { label: fenceTitle ?? language ?? "Code" };
|
|
120
|
+
state.tokens.splice(index, 1, tabOpen, token, new state.Token("looma_tab_close", "section", -1));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const closing = state.push("looma_container_close", "div", -1);
|
|
124
|
+
Object.assign(closing, {
|
|
125
|
+
info: name,
|
|
126
|
+
meta: { title },
|
|
127
|
+
block: true,
|
|
128
|
+
markup: match[1]
|
|
129
|
+
});
|
|
130
|
+
state.parentType = parentType;
|
|
131
|
+
state.lineMax = lineMax;
|
|
132
|
+
state.line = Math.min(next + 1, endLine);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
function leaf(state, startLine, _endLine, silent) {
|
|
136
|
+
if (state.sCount[startLine] - state.blkIndent >= 4) return false;
|
|
137
|
+
const match = LEAF.exec(lineText(state, startLine));
|
|
138
|
+
if (!match || match[1].toLowerCase() !== "toc") return false;
|
|
139
|
+
const attributes = match[2] ? parseLoomaAttributes(match[2]) : {};
|
|
140
|
+
if (!attributes) return false;
|
|
141
|
+
if (silent) return true;
|
|
142
|
+
const format = typeof attributes.format === "string" && TOC_FORMATS.has(attributes.format) ? attributes.format : "plain";
|
|
143
|
+
const depth = attributes.depth === "1" || attributes.depth === "2" ? Number(attributes.depth) : 3;
|
|
144
|
+
const token = state.push("looma_toc", "nav", 0);
|
|
145
|
+
Object.assign(token, {
|
|
146
|
+
block: true,
|
|
147
|
+
map: [startLine, startLine + 1],
|
|
148
|
+
meta: {
|
|
149
|
+
format,
|
|
150
|
+
depth
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
state.line = startLine + 1;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
function alerts(state) {
|
|
157
|
+
const tokens = state.tokens;
|
|
158
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
159
|
+
if (tokens[index].type !== "blockquote_open" || tokens[index + 1]?.type !== "paragraph_open") continue;
|
|
160
|
+
const inline = tokens[index + 2];
|
|
161
|
+
const match = inline?.type === "inline" ? ALERT.exec(inline.content) : null;
|
|
162
|
+
if (!match) continue;
|
|
163
|
+
inline.content = inline.content.slice(match[0].length);
|
|
164
|
+
if (!inline.content.trim()) tokens.splice(index + 1, 3);
|
|
165
|
+
tokens[index].tag = "ui-callout";
|
|
166
|
+
tokens[index].attrSet("tone", normalizeLoomaCalloutTone(match[1]));
|
|
167
|
+
for (let depth = 0, end = index; end < tokens.length; end++) if (tokens[end].type === "blockquote_open") depth++;
|
|
168
|
+
else if (tokens[end].type === "blockquote_close" && --depth === 0) {
|
|
169
|
+
tokens[end].tag = "ui-callout";
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function taskLists(state) {
|
|
175
|
+
const tokens = state.tokens;
|
|
176
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
177
|
+
if (tokens[index].type !== "bullet_list_open") continue;
|
|
178
|
+
const level = tokens[index].level;
|
|
179
|
+
const items = [];
|
|
180
|
+
let isTaskList = true;
|
|
181
|
+
for (let cursor = index + 1; cursor < tokens.length; cursor++) {
|
|
182
|
+
const token = tokens[cursor];
|
|
183
|
+
if (token.type === "bullet_list_close" && token.level === level) break;
|
|
184
|
+
if (token.type !== "list_item_open" || token.level !== level + 1) continue;
|
|
185
|
+
const inline = tokens[cursor + 2];
|
|
186
|
+
const match = tokens[cursor + 1]?.type === "paragraph_open" && inline?.type === "inline" ? TASK.exec(inline.content) : null;
|
|
187
|
+
if (!match) {
|
|
188
|
+
isTaskList = false;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
items.push({
|
|
192
|
+
item: token,
|
|
193
|
+
inline,
|
|
194
|
+
checked: match[1] !== " "
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (!isTaskList || !items.length) continue;
|
|
198
|
+
tokens[index].attrSet("data-type", "taskList");
|
|
199
|
+
for (const { item, inline, checked } of items) {
|
|
200
|
+
inline.content = inline.content.replace(TASK, "");
|
|
201
|
+
item.attrSet("data-type", "taskItem");
|
|
202
|
+
item.attrSet("data-checked", String(checked));
|
|
203
|
+
item.meta = { checked };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function directive(state, silent) {
|
|
208
|
+
const { src, pos } = state;
|
|
209
|
+
if (src.charCodeAt(pos) !== 58) return false;
|
|
210
|
+
const name = /^:(chip|mention)\[/.exec(src.slice(pos, pos + 10))?.[1];
|
|
211
|
+
if (!name) return false;
|
|
212
|
+
let label = "";
|
|
213
|
+
let index = pos + name.length + 2;
|
|
214
|
+
for (; index < state.posMax && src[index] !== "]"; index++) {
|
|
215
|
+
if (src[index] === "\\" && index + 1 < state.posMax) index++;
|
|
216
|
+
label += src[index];
|
|
217
|
+
}
|
|
218
|
+
if (src[index] !== "]") return false;
|
|
219
|
+
const read = src[index + 1] === "{" ? readLoomaAttributes(src, index + 1) : {
|
|
220
|
+
attributes: {},
|
|
221
|
+
end: index + 1
|
|
222
|
+
};
|
|
223
|
+
if (!read || read.end > state.posMax) return false;
|
|
224
|
+
if (name === "mention" && typeof read.attributes.id !== "string") return false;
|
|
225
|
+
if (!silent) {
|
|
226
|
+
const token = state.push(`looma_${name}`, "span", 0);
|
|
227
|
+
token.meta = {
|
|
228
|
+
label,
|
|
229
|
+
attributes: read.attributes
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
state.pos = read.end;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
function inlineText(token) {
|
|
236
|
+
return (token?.children ?? []).map((child) => child.type === "image" ? inlineText(child) : child.content).join("");
|
|
237
|
+
}
|
|
238
|
+
function attributes(state) {
|
|
239
|
+
const headings = [];
|
|
240
|
+
const tokens = state.tokens;
|
|
241
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
242
|
+
const token = tokens[index];
|
|
243
|
+
if (token.type !== "inline" || !token.children) continue;
|
|
244
|
+
const children = token.children;
|
|
245
|
+
for (let child = children.length - 3; child >= 0; child--) {
|
|
246
|
+
const text = children[child + 1];
|
|
247
|
+
if (children[child].type !== "link_open" || children[child].markup !== "linkify" || /^(?:[a-z][\w+.-]*:|www\.)/i.test(text.content)) continue;
|
|
248
|
+
children.splice(child, 3, Object.assign(new state.Token("text", "", 0), { content: text.content }));
|
|
249
|
+
}
|
|
250
|
+
if (tokens[index - 1]?.type === "heading_open") {
|
|
251
|
+
const last = children.at(-1);
|
|
252
|
+
const marker = last?.type === "text" ? /[ \t]+(\{[^{}]*\})$/.exec(last.content) : null;
|
|
253
|
+
const parsed = marker && parseLoomaAttributes(marker[1]);
|
|
254
|
+
let id;
|
|
255
|
+
if (last && marker && parsed && typeof parsed.id === "string") {
|
|
256
|
+
id = parsed.id;
|
|
257
|
+
last.content = last.content.slice(0, marker.index);
|
|
258
|
+
}
|
|
259
|
+
headings.push({
|
|
260
|
+
open: tokens[index - 1],
|
|
261
|
+
text: inlineText(token).trim(),
|
|
262
|
+
id
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
for (let child = 0; child < children.length; child++) {
|
|
266
|
+
const next = children[child + 1];
|
|
267
|
+
if (children[child].type !== "image" || next?.type !== "text" || !next.content.startsWith("{")) continue;
|
|
268
|
+
const read = readLoomaAttributes(next.content);
|
|
269
|
+
if (!read) continue;
|
|
270
|
+
applyImageAttributes(children[child], read.attributes);
|
|
271
|
+
next.content = next.content.slice(read.end);
|
|
272
|
+
if (!next.content) children.splice(child + 1, 1);
|
|
273
|
+
}
|
|
274
|
+
if (tokens[index - 1]?.type === "paragraph_open" && children.length === 1 && children[0].type === "image") {
|
|
275
|
+
tokens[index - 1].hidden = true;
|
|
276
|
+
tokens[index + 1].hidden = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const ids = assignLoomaHeadingIds(headings);
|
|
280
|
+
headings.forEach(({ open }, index) => open.attrSet("id", ids[index]));
|
|
281
|
+
}
|
|
282
|
+
function applyImageAttributes(image, attributes) {
|
|
283
|
+
for (const key of ["width", "height"]) {
|
|
284
|
+
const value = Number(attributes[key]);
|
|
285
|
+
if (Number.isSafeInteger(value) && value > 0) image.attrSet(key, String(value));
|
|
286
|
+
}
|
|
287
|
+
const placement = attributes.placement;
|
|
288
|
+
if (typeof placement === "string" && PLACEMENTS.has(placement) && placement !== "block") image.attrSet("data-placement", placement);
|
|
289
|
+
if (attributes.responsive === true) image.attrSet("data-looma-responsive", "");
|
|
290
|
+
}
|
|
291
|
+
function tableOfContents(tokens, format, depth, escape) {
|
|
292
|
+
const roots = [];
|
|
293
|
+
const ancestors = [];
|
|
294
|
+
tokens.forEach((token, index) => {
|
|
295
|
+
if (token.type !== "heading_open") return;
|
|
296
|
+
const level = Number(token.tag.slice(1));
|
|
297
|
+
const text = inlineText(tokens[index + 1]).trim();
|
|
298
|
+
if (level > depth || !text) return;
|
|
299
|
+
while (ancestors.length && ancestors[ancestors.length - 1].level >= level) ancestors.pop();
|
|
300
|
+
const entry = {
|
|
301
|
+
id: token.attrGet("id") ?? "",
|
|
302
|
+
text,
|
|
303
|
+
children: []
|
|
304
|
+
};
|
|
305
|
+
(ancestors.at(-1)?.entry.children ?? roots).push(entry);
|
|
306
|
+
ancestors.push({
|
|
307
|
+
level,
|
|
308
|
+
entry
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
const [list, item] = format === "plain" ? ["div", "div"] : [format === "numbered" ? "ol" : "ul", "li"];
|
|
312
|
+
const render = (entries) => `<${list} role="list">${entries.map(({ id, text, children }) => `<${item} role="listitem"><a href="#${escape(id)}">${escape(text)}</a>${children.length ? render(children) : ""}</${item}>`).join("")}</${list}>`;
|
|
313
|
+
return `<nav data-looma-toc="" data-format="${format}" data-depth="${depth}" aria-label="Table of contents"><strong>Table of contents</strong>${roots.length ? render(roots) : ""}</nav>\n`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Adds Looma Markdown to a markdown-it instance: containers, alerts, task lists, the table of
|
|
317
|
+
* contents, chips, mentions, brace attributes, heading anchors, code meta and front matter.
|
|
318
|
+
* @contract Renders the canonical HTML, which is the HTML LoomaEditor saves. It is not sanitized.
|
|
319
|
+
* Linkify and raw HTML are instance options; `createLoomaMarkdownIt` turns both on.
|
|
320
|
+
*/
|
|
321
|
+
function loomaMarkdown(md) {
|
|
322
|
+
const escape = md.utils.escapeHtml;
|
|
323
|
+
md.block.ruler.before("table", "looma_frontmatter", frontmatter);
|
|
324
|
+
md.block.ruler.before("fence", "looma_container", container, { alt: [
|
|
325
|
+
"paragraph",
|
|
326
|
+
"reference",
|
|
327
|
+
"blockquote",
|
|
328
|
+
"list"
|
|
329
|
+
] });
|
|
330
|
+
md.block.ruler.before("paragraph", "looma_leaf", leaf, { alt: [
|
|
331
|
+
"paragraph",
|
|
332
|
+
"reference",
|
|
333
|
+
"blockquote",
|
|
334
|
+
"list"
|
|
335
|
+
] });
|
|
336
|
+
md.inline.ruler.before("link", "looma_directive", directive);
|
|
337
|
+
md.core.ruler.after("block", "looma_task_lists", taskLists);
|
|
338
|
+
md.core.ruler.before("inline", "looma_alerts", alerts);
|
|
339
|
+
md.core.ruler.before("text_join", "looma_attributes", attributes);
|
|
340
|
+
const rules = md.renderer.rules;
|
|
341
|
+
rules.looma_frontmatter = () => "";
|
|
342
|
+
rules.looma_container_open = (tokens, index) => {
|
|
343
|
+
const { info, meta } = tokens[index];
|
|
344
|
+
if (info === "details") return `<details${meta.open ? " open" : ""}><summary>${escape(meta.title || "Details")}</summary>\n`;
|
|
345
|
+
if (info === "figure") return "<figure>\n";
|
|
346
|
+
if (info === "tabs" || info === "code-group") return `<ui-tabs${info === "code-group" ? " class=\"code-group\"" : ""} group="${escape(meta.title || info)}">\n`;
|
|
347
|
+
return `<ui-callout tone="${normalizeLoomaCalloutTone(info)}"${meta.title ? ` heading="${escape(meta.title)}"` : ""}>\n`;
|
|
348
|
+
};
|
|
349
|
+
rules.looma_container_close = (tokens, index, _options, env) => {
|
|
350
|
+
const { info } = tokens[index];
|
|
351
|
+
if (info === "details") return "</details>\n";
|
|
352
|
+
if (info === "figure") {
|
|
353
|
+
const title = tokens[index].meta.title;
|
|
354
|
+
return `${title ? `<figcaption>${md.renderInline(title, env)}</figcaption>\n` : ""}</figure>\n`;
|
|
355
|
+
}
|
|
356
|
+
if (info === "tabs" || info === "code-group") return "</ui-tabs>\n";
|
|
357
|
+
return "</ui-callout>\n";
|
|
358
|
+
};
|
|
359
|
+
rules.looma_tab_open = (tokens, index) => `<section aria-label="${escape(tokens[index].meta.label)}">\n`;
|
|
360
|
+
rules.looma_tab_close = () => "</section>\n";
|
|
361
|
+
rules.looma_toc = (tokens, index) => tableOfContents(tokens, tokens[index].meta.format, tokens[index].meta.depth, escape);
|
|
362
|
+
rules.looma_chip = (tokens, index) => {
|
|
363
|
+
const { label, attributes } = tokens[index].meta;
|
|
364
|
+
const tone = normalizeLoomaChipTone(attributes.tone);
|
|
365
|
+
return `<span data-looma-chip="" data-label="${escape(label)}" data-color="${tone}"${label ? "" : " data-placeholder=\"\""}>${escape(label)}</span>`;
|
|
366
|
+
};
|
|
367
|
+
rules.looma_mention = (tokens, index) => {
|
|
368
|
+
const { label, attributes } = tokens[index].meta;
|
|
369
|
+
const id = String(attributes.id);
|
|
370
|
+
return `<span data-type="mention" class="looma-mention" data-id="${escape(id)}" data-label="${escape(label || id)}">@${escape(label || id)}</span>`;
|
|
371
|
+
};
|
|
372
|
+
const listItem = rules.list_item_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options));
|
|
373
|
+
rules.list_item_open = (tokens, index, options, env, self) => {
|
|
374
|
+
const html = listItem(tokens, index, options, env, self);
|
|
375
|
+
const task = tokens[index].meta;
|
|
376
|
+
return task && typeof task.checked === "boolean" ? `${html}<input type="checkbox" disabled=""${task.checked ? " checked=\"\"" : ""}>` : html;
|
|
377
|
+
};
|
|
378
|
+
const code = (content, info) => {
|
|
379
|
+
const { language, meta } = parseLoomaFenceInfo(info);
|
|
380
|
+
return `<pre><code${language ? ` class="language-${escape(language)}"` : ""}${meta ? ` data-meta="${escape(meta)}"` : ""}>${escape(content.replace(/\n$/, ""))}</code></pre>\n`;
|
|
381
|
+
};
|
|
382
|
+
rules.fence = (tokens, index) => code(tokens[index].content, tokens[index].info);
|
|
383
|
+
rules.code_block = (tokens, index) => code(tokens[index].content, "");
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A markdown-it instance for Looma Markdown: raw HTML and GFM autolinks on, with `loomaMarkdown`.
|
|
387
|
+
* @contract Each call returns a new instance that callers may extend.
|
|
388
|
+
*/
|
|
389
|
+
function createLoomaMarkdownIt() {
|
|
390
|
+
return new MarkdownIt({
|
|
391
|
+
html: true,
|
|
392
|
+
linkify: true,
|
|
393
|
+
typographer: false
|
|
394
|
+
}).use(loomaMarkdown);
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
export { createLoomaMarkdownIt, loomaMarkdown };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadlabs/looma",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,21 @@
|
|
|
50
50
|
"import": "./editor/extensions/index.js",
|
|
51
51
|
"default": "./editor/extensions/index.js"
|
|
52
52
|
},
|
|
53
|
+
"./markdown": {
|
|
54
|
+
"types": "./markdown/index.d.ts",
|
|
55
|
+
"import": "./markdown/index.js",
|
|
56
|
+
"default": "./markdown/index.js"
|
|
57
|
+
},
|
|
58
|
+
"./markdown/markdown-it": {
|
|
59
|
+
"types": "./markdown/markdown-it.d.ts",
|
|
60
|
+
"import": "./markdown/markdown-it.js",
|
|
61
|
+
"default": "./markdown/markdown-it.js"
|
|
62
|
+
},
|
|
63
|
+
"./markdown/fixtures": {
|
|
64
|
+
"types": "./markdown/fixtures.d.ts",
|
|
65
|
+
"import": "./markdown/fixtures.js",
|
|
66
|
+
"default": "./markdown/fixtures.js"
|
|
67
|
+
},
|
|
53
68
|
"./vue/*": "./vue/*",
|
|
54
69
|
"./vanilla": "./vanilla/index.js",
|
|
55
70
|
"./vanilla/*": "./vanilla/*",
|
|
@@ -68,6 +83,7 @@
|
|
|
68
83
|
"dist/",
|
|
69
84
|
"vue/",
|
|
70
85
|
"editor/",
|
|
86
|
+
"markdown/",
|
|
71
87
|
"vanilla/",
|
|
72
88
|
"components/",
|
|
73
89
|
"styles/",
|