@threadlabs/looma 0.28.15 → 0.28.17

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.
@@ -8,8 +8,8 @@ import { dropCursor } from "@tiptap/pm/dropcursor";
8
8
  import { gapCursor } from "@tiptap/pm/gapcursor";
9
9
  import { createLowlight } from "lowlight";
10
10
  import { Mapping } from "@tiptap/pm/transform";
11
- import MarkdownIt from "markdown-it";
12
11
  import { MarkdownSerializer, defaultMarkdownSerializer } from "@tiptap/pm/markdown";
12
+ import MarkdownIt from "markdown-it";
13
13
  import { CellSelection, TableMap, addColumn, addColumnAfter, addColumnBefore, addRow, addRowAfter, addRowBefore, cellAround, columnResizing, deleteCellSelection, deleteColumn, deleteRow, deleteTable, fixTables, goToNextCell, mergeCells, moveTableColumn, moveTableRow, setCellAttr, splitCell, tableEditing, toggleHeader, toggleHeaderCell } from "@tiptap/pm/tables";
14
14
  //#region src/editor/extensions/active-block.ts
15
15
  /**
@@ -127,10 +127,10 @@ var LoomaActiveBlock = Extension.create({
127
127
  }
128
128
  });
129
129
  //#endregion
130
- //#region src/editor/extensions/callout.ts
130
+ //#region src/markdown/index.ts
131
131
  /**
132
- * Canonical tone values that may be persisted in a Looma callout node: the shared block
133
- * vocabulary of Callout, Markdown `:::` containers and GitHub alerts.
132
+ * Callout tones a document stores, in the order the editor offers them. The Callout component also
133
+ * takes `neutral` and `success`, which Markdown and the editor store as `note`.
134
134
  */
135
135
  var LOOMA_CALLOUT_TONES = [
136
136
  "note",
@@ -140,17 +140,202 @@ var LOOMA_CALLOUT_TONES = [
140
140
  "warning",
141
141
  "danger"
142
142
  ];
143
- var TONE_ALIASES = {
143
+ /** Names that read as a stored tone: `caution` is `danger`, and `neutral` is the same muted aside as `note`. */
144
+ var LOOMA_CALLOUT_TONE_ALIASES = {
144
145
  caution: "danger",
145
146
  neutral: "note"
146
147
  };
147
- /** Maps a tone name, alias, or unknown value onto a persisted tone. */
148
+ /**
149
+ * Maps a tone name or alias, in any case, onto a stored tone.
150
+ * @failure Unknown and non-string values become `note`.
151
+ */
148
152
  function normalizeLoomaCalloutTone(value) {
149
153
  if (typeof value !== "string") return "note";
150
154
  const tone = value.toLowerCase();
151
- return LOOMA_CALLOUT_TONES.includes(tone) ? tone : TONE_ALIASES[tone] ?? "note";
155
+ return LOOMA_CALLOUT_TONES.includes(tone) ? tone : LOOMA_CALLOUT_TONE_ALIASES[tone] ?? "note";
156
+ }
157
+ /** The hues chips and swatches take as their tone, written `:chip[Label]{tone=green}`. */
158
+ var LOOMA_CHIP_TONES = [
159
+ "neutral",
160
+ "blue",
161
+ "green",
162
+ "yellow",
163
+ "red",
164
+ "purple"
165
+ ];
166
+ /**
167
+ * Keeps a chip tone within the palette.
168
+ * @failure Unknown values become `neutral`.
169
+ */
170
+ function normalizeLoomaChipTone(value) {
171
+ return LOOMA_CHIP_TONES.includes(value) ? value : "neutral";
172
+ }
173
+ /**
174
+ * Names that open a `:::` container, matched without regard to case. A container with any other
175
+ * name is ordinary text.
176
+ */
177
+ var LOOMA_CONTAINER_NAMES = /* @__PURE__ */ new Set([
178
+ ...LOOMA_CALLOUT_TONES,
179
+ "caution",
180
+ "details",
181
+ "figure",
182
+ "tabs",
183
+ "code-group"
184
+ ]);
185
+ var ATTRIBUTE_KEY = /[A-Za-z][\w-]*/y;
186
+ var ATTRIBUTE_ID = /#([\w-]+)/y;
187
+ var ATTRIBUTE_VALUE = /[^\s"'=<>`{}]+/y;
188
+ function sticky(pattern, source, index) {
189
+ pattern.lastIndex = index;
190
+ return pattern.exec(source);
191
+ }
192
+ /**
193
+ * Reads a brace attribute list starting at `source[start]`, which must be `{`.
194
+ * @contract Returns the attributes and the index just past the closing brace.
195
+ * @failure Returns null for anything that is not a complete, well-formed list, so the text stays literal.
196
+ */
197
+ function readLoomaAttributes(source, start = 0) {
198
+ if (source[start] !== "{") return null;
199
+ const attributes = {};
200
+ let index = start + 1;
201
+ for (;;) {
202
+ while (source[index] === " " || source[index] === " ") index++;
203
+ if (source[index] === "}") return {
204
+ attributes,
205
+ end: index + 1
206
+ };
207
+ if (index > start + 1 && !/\s/.test(source[index - 1])) return null;
208
+ const id = sticky(ATTRIBUTE_ID, source, index);
209
+ if (id) {
210
+ attributes.id = id[1];
211
+ index += id[0].length;
212
+ continue;
213
+ }
214
+ const key = sticky(ATTRIBUTE_KEY, source, index);
215
+ if (!key) return null;
216
+ index += key[0].length;
217
+ if (source[index] !== "=") {
218
+ attributes[key[0]] = true;
219
+ continue;
220
+ }
221
+ index++;
222
+ const quote = source[index];
223
+ if (quote === "\"" || quote === "'") {
224
+ const close = source.indexOf(quote, index + 1);
225
+ if (close < 0) return null;
226
+ attributes[key[0]] = source.slice(index + 1, close);
227
+ index = close + 1;
228
+ continue;
229
+ }
230
+ const value = sticky(ATTRIBUTE_VALUE, source, index);
231
+ if (!value) return null;
232
+ attributes[key[0]] = value[0];
233
+ index += value[0].length;
234
+ }
235
+ }
236
+ /**
237
+ * Reads a whole string as one brace attribute list.
238
+ * @failure Returns null unless the string is exactly one well-formed list.
239
+ */
240
+ function parseLoomaAttributes(source) {
241
+ const read = readLoomaAttributes(source);
242
+ return read && read.end === source.length ? read.attributes : null;
243
+ }
244
+ /**
245
+ * Writes attributes as a brace list, `id` first as `#id`, then the rest in order.
246
+ * @contract `true` writes a bare key; values are quoted only when they need it. `false`, null and
247
+ * undefined values are left out, and an empty set writes an empty string. An ID that `#` cannot
248
+ * carry, or any ID when `hashId` is false, is written `id=…`.
249
+ */
250
+ function formatLoomaAttributes(attributes, { hashId = true } = {}) {
251
+ const items = [];
252
+ const { id, ...rest } = attributes;
253
+ const hashed = hashId && typeof id === "string" && /^[\w-]+$/.test(id);
254
+ if (hashed) items.push(`#${id}`);
255
+ for (const [key, value] of Object.entries(hashed ? rest : attributes)) if (value === true) items.push(key);
256
+ else if (value === false || value === null || value === void 0) continue;
257
+ else {
258
+ const text = String(value);
259
+ items.push(/^[^\s"'=<>`{}]+$/.test(text) ? `${key}=${text}` : text.includes("\"") ? `${key}='${text}'` : `${key}="${text}"`);
260
+ }
261
+ return items.length ? `{${items.join(" ")}}` : "";
262
+ }
263
+ /**
264
+ * The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
265
+ * marks, with every other run of characters one hyphen.
266
+ * @failure Text with no letters or numbers, such as only emoji, derives `heading`.
267
+ */
268
+ function loomaHeadingSlug(text) {
269
+ return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
152
270
  }
153
271
  /**
272
+ * Gives every heading of a document its anchor, in document order.
273
+ * @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
274
+ * its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
275
+ */
276
+ function assignLoomaHeadingIds(headings) {
277
+ const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
278
+ const used = /* @__PURE__ */ new Set();
279
+ return headings.map(({ text, id: custom }) => {
280
+ let id = custom || loomaHeadingSlug(text);
281
+ if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
282
+ const stem = id;
283
+ let suffix = 1;
284
+ while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
285
+ id = `${stem}-${suffix}`;
286
+ }
287
+ used.add(id);
288
+ return id;
289
+ });
290
+ }
291
+ /** Reads a fence's info string: ```` ```ts title="a.ts" {1,3-5} :line-numbers ````. */
292
+ function parseLoomaFenceInfo(info) {
293
+ const source = info.trim();
294
+ const language = /^[^\s={:]+(?=[\s{:]|$)/.exec(source)?.[0];
295
+ const meta = (language ? source.slice(language.length) : source).trim();
296
+ const title = /(?:^|\s)title="([^"]*)"/.exec(meta)?.[1];
297
+ const rest = meta.replace(/(?:^|\s)title="[^"]*"/, "");
298
+ const lines = /* @__PURE__ */ new Set();
299
+ for (const range of /\{([\d\s,-]+)\}/.exec(rest)?.[1].split(",") ?? []) {
300
+ const [from = NaN, to = from] = range.split("-").map((part) => Number.parseInt(part, 10));
301
+ for (let line = from; line <= to; line++) lines.add(line);
302
+ }
303
+ const numbers = /:(no-)?line-numbers(?![\w-])/.exec(rest);
304
+ return {
305
+ ...language ? { language } : {},
306
+ meta,
307
+ ...title !== void 0 ? { title } : {},
308
+ lines: [...lines].sort((a, b) => a - b),
309
+ ...numbers ? { lineNumbers: numbers[1] === void 0 } : {}
310
+ };
311
+ }
312
+ /**
313
+ * Splits YAML front matter from the start of a document.
314
+ * @contract Front matter opens with a `---` line at the very start, holds at least one `key: value`
315
+ * line, and closes at a `---` or `...` line. `source` is the block as written, delimiters included.
316
+ * @failure Returns null when the document has none.
317
+ */
318
+ function splitLoomaFrontmatter(document) {
319
+ const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(document.slice(0, 8192));
320
+ if (!match || !/^[A-Za-z][\w-]*:[ \t]*\S/m.test(match[1])) return null;
321
+ return {
322
+ source: match[0].trimEnd(),
323
+ yaml: match[1],
324
+ body: document.slice(match[0].length)
325
+ };
326
+ }
327
+ /** The colons a container opens and closes with, given how deeply containers nest inside it. */
328
+ function loomaContainerMarker(nestedDepth) {
329
+ return ":".repeat(3 + Math.max(0, nestedDepth));
330
+ }
331
+ /** A backtick fence longer than any run of backticks in the code, and at least three long. */
332
+ function loomaCodeFence(code) {
333
+ const longest = Math.max(0, ...(code.match(/`+/g) ?? []).map((run) => run.length));
334
+ return "`".repeat(Math.max(3, longest + 1));
335
+ }
336
+ //#endregion
337
+ //#region src/editor/extensions/callout.ts
338
+ /**
154
339
  * A durable block container for informational editor content, saved as Looma's Callout.
155
340
  *
156
341
  * @invariant Rendered `tone` values always belong to `LOOMA_CALLOUT_TONES`; missing or
@@ -196,19 +381,12 @@ var LoomaCallout = Node$1.create({
196
381
  });
197
382
  //#endregion
198
383
  //#region src/editor/extensions/chip.ts
199
- /** The six palette values a chip may persist; other values fall back to neutral. */
200
- var LOOMA_CHIP_COLORS = [
201
- "neutral",
202
- "blue",
203
- "green",
204
- "yellow",
205
- "red",
206
- "purple"
207
- ];
384
+ /** The six palette values a chip may persist, Looma Markdown's chip tones; other values fall back to neutral. */
385
+ var LOOMA_CHIP_COLORS = LOOMA_CHIP_TONES;
208
386
  var CHIP_PLACEHOLDER = "Set a label";
209
387
  /** Keep imported or programmatic colors within the supported palette. */
210
388
  function normalizeLoomaChipColor(value) {
211
- return LOOMA_CHIP_COLORS.includes(value) ? value : "neutral";
389
+ return normalizeLoomaChipTone(value);
212
390
  }
213
391
  /**
214
392
  * An inline, atomic label. The editor's popover edits its attributes; its text
@@ -466,6 +644,144 @@ var LoomaExpand = Node$1.create({
466
644
  }
467
645
  });
468
646
  //#endregion
647
+ //#region src/editor/extensions/figure.ts
648
+ /**
649
+ * An image with a caption, saved as `<figure><img><figcaption>` and written in Looma Markdown as
650
+ * `::: figure Caption`. The caption is inline content the author edits in place.
651
+ * @invariant A figure holds exactly one block image and one caption; an empty caption stays empty.
652
+ */
653
+ var LoomaFigure = Node$1.create({
654
+ name: "loomaFigure",
655
+ group: "block",
656
+ content: "image loomaFigcaption",
657
+ defining: true,
658
+ parseHTML() {
659
+ return [{
660
+ tag: "figure",
661
+ getAttrs: (element) => element.querySelector(":scope > img") ? null : false
662
+ }];
663
+ },
664
+ renderHTML() {
665
+ return ["figure", 0];
666
+ }
667
+ });
668
+ /**
669
+ * A figure's caption: inline content under its image.
670
+ * @invariant It exists only as the second child of `LoomaFigure`.
671
+ */
672
+ var LoomaFigcaption = Node$1.create({
673
+ name: "loomaFigcaption",
674
+ content: "inline*",
675
+ defining: true,
676
+ parseHTML() {
677
+ return [{ tag: "figcaption" }];
678
+ },
679
+ renderHTML() {
680
+ return ["figcaption", 0];
681
+ }
682
+ });
683
+ //#endregion
684
+ //#region src/editor/extensions/tabs.ts
685
+ /**
686
+ * Tabs or a code group, kept whole so Looma Markdown's `::: tabs` and `::: code-group` survive
687
+ * editing. Saved as `<ui-tabs group>` with a `<section aria-label>` per panel.
688
+ * @contract While editing, panels show one after another under their labels; the Tabs component
689
+ * draws them only where the saved HTML renders.
690
+ */
691
+ var LoomaTabs = Node$1.create({
692
+ name: "loomaTabs",
693
+ group: "block",
694
+ content: "loomaTab+",
695
+ defining: true,
696
+ isolating: true,
697
+ addAttributes() {
698
+ return {
699
+ group: {
700
+ default: "",
701
+ parseHTML: (element) => element.getAttribute("group") ?? "",
702
+ renderHTML: (attributes) => attributes.group ? { group: attributes.group } : {}
703
+ },
704
+ codeGroup: {
705
+ default: false,
706
+ parseHTML: (element) => element.classList.contains("code-group"),
707
+ renderHTML: (attributes) => attributes.codeGroup ? { class: "code-group" } : {}
708
+ }
709
+ };
710
+ },
711
+ parseHTML() {
712
+ return [{ tag: "ui-tabs" }];
713
+ },
714
+ renderHTML({ HTMLAttributes }) {
715
+ return [
716
+ "ui-tabs",
717
+ HTMLAttributes,
718
+ 0
719
+ ];
720
+ },
721
+ addNodeView() {
722
+ return () => {
723
+ const dom = document.createElement("div");
724
+ dom.dataset.loomaTabs = "";
725
+ return {
726
+ dom,
727
+ contentDOM: dom
728
+ };
729
+ };
730
+ }
731
+ });
732
+ /**
733
+ * One panel of `LoomaTabs`, labelled by `label`.
734
+ * @invariant It exists only inside `LoomaTabs`; its label is the tab Tabs shows and the `== Label` line Markdown writes.
735
+ */
736
+ var LoomaTab = Node$1.create({
737
+ name: "loomaTab",
738
+ content: "block+",
739
+ defining: true,
740
+ isolating: true,
741
+ addAttributes() {
742
+ return { label: {
743
+ default: "",
744
+ parseHTML: (element) => element.getAttribute("aria-label") ?? "",
745
+ renderHTML: (attributes) => ({ "aria-label": attributes.label })
746
+ } };
747
+ },
748
+ parseHTML() {
749
+ return [{ tag: "ui-tabs > section" }];
750
+ },
751
+ renderHTML({ HTMLAttributes }) {
752
+ return [
753
+ "section",
754
+ HTMLAttributes,
755
+ 0
756
+ ];
757
+ },
758
+ addNodeView() {
759
+ return ({ node }) => {
760
+ let current = node;
761
+ const dom = document.createElement("section");
762
+ const label = document.createElement("div");
763
+ label.contentEditable = "false";
764
+ label.dataset.loomaTabLabel = "";
765
+ label.textContent = node.attrs.label;
766
+ const contentDOM = document.createElement("div");
767
+ dom.setAttribute("aria-label", node.attrs.label);
768
+ dom.append(label, contentDOM);
769
+ return {
770
+ dom,
771
+ contentDOM,
772
+ update(next) {
773
+ if (next.type !== current.type) return false;
774
+ current = next;
775
+ label.textContent = next.attrs.label;
776
+ dom.setAttribute("aria-label", next.attrs.label);
777
+ return true;
778
+ },
779
+ ignoreMutation: (mutation) => mutation.type !== "selection" && !contentDOM.contains(mutation.target)
780
+ };
781
+ };
782
+ }
783
+ });
784
+ //#endregion
469
785
  //#region src/editor/extensions/table-of-contents.ts
470
786
  /** Normalize loaded configuration without persisting unsupported presentation values. */
471
787
  function normalizeTableOfContentsConfig(attributes) {
@@ -512,7 +828,17 @@ function tableOfContentsTree(entries) {
512
828
  }
513
829
  return roots;
514
830
  }
515
- function anchorTransaction(state) {
831
+ var derivedFrom = (id, text) => {
832
+ const slug = loomaHeadingSlug(text);
833
+ return id === slug || id.startsWith(`${slug}-`) && /^\d+$/.test(id.slice(slug.length + 1));
834
+ };
835
+ /**
836
+ * Keeps heading anchors on Looma Markdown's rule. An anchor derived from its heading's text
837
+ * (as it read before this change) follows the text; any other, including `looma-heading-N` anchors
838
+ * saved before this rule, is a custom anchor and stays, so links to it keep working. With
839
+ * a table of contents every heading gets an anchor; without one, only headings that have one.
840
+ */
841
+ function anchorTransaction(state, previous) {
516
842
  let hasToc = false;
517
843
  const headings = [];
518
844
  state.doc.descendants((node, position) => {
@@ -522,24 +848,28 @@ function anchorTransaction(state) {
522
848
  position
523
849
  });
524
850
  });
525
- if (!hasToc) return null;
526
- const reserved = new Set(headings.map(({ node }) => String(node.attrs.id ?? "")).filter(Boolean));
527
- const used = /* @__PURE__ */ new Set();
851
+ if (!headings.length) return null;
852
+ const before = /* @__PURE__ */ new Map();
853
+ previous?.descendants((node) => {
854
+ if (node.type.name === "heading" && typeof node.attrs.id === "string") before.set(node.attrs.id, node.textContent.trim());
855
+ });
856
+ const custom = (node) => {
857
+ const id = node.attrs.id;
858
+ if (typeof id !== "string" || !id || /\s/.test(id)) return void 0;
859
+ return derivedFrom(id, before.get(id) ?? node.textContent.trim()) ? void 0 : id;
860
+ };
861
+ const ids = assignLoomaHeadingIds(headings.map(({ node }) => ({
862
+ text: node.textContent.trim(),
863
+ id: custom(node)
864
+ })));
528
865
  const transaction = state.tr;
529
- let sequence = 1;
530
- for (const { node, position } of headings) {
531
- let id = typeof node.attrs.id === "string" ? node.attrs.id : "";
532
- if (!id || /\s/.test(id) || used.has(id)) {
533
- do
534
- id = `looma-heading-${sequence++}`;
535
- while (reserved.has(id) || used.has(id));
536
- transaction.setNodeMarkup(position, void 0, {
537
- ...node.attrs,
538
- id
539
- });
540
- }
541
- used.add(id);
542
- }
866
+ headings.forEach(({ node, position }, index) => {
867
+ if (node.attrs.id === ids[index] || !hasToc && !node.attrs.id) return;
868
+ transaction.setNodeMarkup(position, void 0, {
869
+ ...node.attrs,
870
+ id: ids[index]
871
+ });
872
+ });
543
873
  return transaction.docChanged ? transaction : null;
544
874
  }
545
875
  var LoomaHeadingAnchors = Extension.create({
@@ -555,7 +885,7 @@ var LoomaHeadingAnchors = Extension.create({
555
885
  if (transaction) this.editor.view.dispatch(transaction.setMeta("addToHistory", false));
556
886
  },
557
887
  addProseMirrorPlugins() {
558
- return [new Plugin({ appendTransaction: (transactions, _oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state) : null })];
888
+ return [new Plugin({ appendTransaction: (transactions, oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state, oldState.doc) : null })];
559
889
  }
560
890
  });
561
891
  function contentsHtml(doc, config) {
@@ -9165,47 +9495,96 @@ function createDOMPurify() {
9165
9495
  }
9166
9496
  var purify = createDOMPurify();
9167
9497
  //#endregion
9168
- //#region src/editor/extensions/markdown.ts
9169
- var CONTAINER_NAMES = /* @__PURE__ */ new Set([
9170
- "note",
9171
- "tip",
9172
- "info",
9173
- "important",
9174
- "warning",
9175
- "danger",
9176
- "caution",
9177
- "details"
9178
- ]);
9179
- var CONTAINER_OPEN = /^(:{3,})[ \t]*([A-Za-z]+)(?:[ \t]+(.*?))?[ \t]*$/;
9498
+ //#region src/markdown/markdown-it.ts
9499
+ var CONTAINER_OPEN = /^(:{3,})[ \t]*([A-Za-z][\w-]*)(?:[ \t]+(.*?))?[ \t]*$/;
9180
9500
  var CONTAINER_CLOSE = /^:{3,}[ \t]*$/;
9501
+ var FENCE_OPEN = /^(`{3,}|~{3,})/;
9502
+ var TAB_LABEL = /^==[ \t]+(\S.*?)[ \t]*$/;
9181
9503
  var OPEN_FLAG = /[ \t]*\{open\}$/;
9182
9504
  var ALERT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)/i;
9505
+ var LEAF = /^::([A-Za-z][\w-]*)(\{.*\})?[ \t]*$/;
9506
+ var TASK = /^\[([ xX])\](?:[ \t]+|$)/;
9507
+ var TOC_FORMATS = /* @__PURE__ */ new Set([
9508
+ "plain",
9509
+ "bulleted",
9510
+ "numbered"
9511
+ ]);
9512
+ var PLACEMENTS = /* @__PURE__ */ new Set([
9513
+ "block",
9514
+ "center",
9515
+ "wrap-left",
9516
+ "wrap-right"
9517
+ ]);
9518
+ var opensContainer = (text) => {
9519
+ const match = CONTAINER_OPEN.exec(text);
9520
+ return match && LOOMA_CONTAINER_NAMES.has(match[2].toLowerCase()) ? match : null;
9521
+ };
9183
9522
  function lineText(state, line) {
9184
9523
  return state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]);
9185
9524
  }
9525
+ function frontmatter(state, startLine, _endLine, silent) {
9526
+ if (startLine !== 0 || state.blkIndent !== 0) return false;
9527
+ const split = splitLoomaFrontmatter(state.src);
9528
+ if (!split) return false;
9529
+ if (silent) return true;
9530
+ const lines = split.source.split("\n").length;
9531
+ const token = state.push("looma_frontmatter", "", 0);
9532
+ Object.assign(token, {
9533
+ content: split.yaml,
9534
+ block: true,
9535
+ hidden: true,
9536
+ map: [0, lines]
9537
+ });
9538
+ state.env.frontmatter = split.yaml;
9539
+ state.line = lines;
9540
+ return true;
9541
+ }
9186
9542
  function container(state, startLine, endLine, silent) {
9187
9543
  if (state.sCount[startLine] - state.blkIndent >= 4) return false;
9188
- const match = CONTAINER_OPEN.exec(lineText(state, startLine));
9189
- if (!match || !CONTAINER_NAMES.has(match[2].toLowerCase())) return false;
9544
+ const match = opensContainer(lineText(state, startLine));
9545
+ if (!match) return false;
9190
9546
  if (silent) return true;
9547
+ const name = match[2].toLowerCase();
9548
+ const grouped = name === "tabs" || name === "code-group";
9549
+ const labels = [];
9191
9550
  let depth = 1;
9551
+ let fence = null;
9192
9552
  let next = startLine + 1;
9193
9553
  for (; next < endLine; next++) {
9194
- if (state.sCount[next] < state.blkIndent) break;
9195
9554
  const text = lineText(state, next);
9555
+ if (text && state.sCount[next] < state.blkIndent) break;
9556
+ if (fence) {
9557
+ const close = FENCE_OPEN.exec(text);
9558
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length && !text.slice(close[1].length).trim()) fence = null;
9559
+ continue;
9560
+ }
9561
+ const opener = FENCE_OPEN.exec(text);
9562
+ if (opener) {
9563
+ fence = {
9564
+ char: opener[1][0],
9565
+ length: opener[1].length
9566
+ };
9567
+ continue;
9568
+ }
9196
9569
  if (CONTAINER_CLOSE.test(text)) {
9197
9570
  if (--depth === 0) break;
9198
- } else if (CONTAINER_OPEN.test(text) && CONTAINER_NAMES.has(CONTAINER_OPEN.exec(text)[2].toLowerCase())) depth++;
9571
+ } else if (opensContainer(text)) depth++;
9572
+ else if (grouped && depth === 1) {
9573
+ const label = TAB_LABEL.exec(text);
9574
+ if (label) labels.push({
9575
+ line: next,
9576
+ label: label[1]
9577
+ });
9578
+ }
9199
9579
  }
9200
- const name = match[2].toLowerCase();
9201
9580
  let title = (match[3] ?? "").trim();
9202
- const open = OPEN_FLAG.test(title);
9203
- title = title.replace(OPEN_FLAG, "");
9581
+ const open = name === "details" && OPEN_FLAG.test(title);
9582
+ if (name === "details") title = title.replace(OPEN_FLAG, "");
9204
9583
  const parentType = state.parentType;
9205
9584
  const lineMax = state.lineMax;
9206
9585
  state.parentType = "container";
9207
9586
  state.lineMax = next;
9208
- const opening = state.push("looma_container_open", "", 1);
9587
+ const opening = state.push("looma_container_open", "div", 1);
9209
9588
  Object.assign(opening, {
9210
9589
  info: name,
9211
9590
  meta: {
@@ -9216,10 +9595,30 @@ function container(state, startLine, endLine, silent) {
9216
9595
  map: [startLine, next],
9217
9596
  markup: match[1]
9218
9597
  });
9219
- state.md.block.tokenize(state, startLine + 1, next);
9220
- const closing = state.push("looma_container_close", "", -1);
9598
+ const panel = (label, from, to) => {
9599
+ state.push("looma_tab_open", "section", 1).meta = { label };
9600
+ state.md.block.tokenize(state, from, to);
9601
+ state.push("looma_tab_close", "section", -1);
9602
+ };
9603
+ if (labels.length) {
9604
+ state.md.block.tokenize(state, startLine + 1, labels[0].line);
9605
+ labels.forEach(({ line, label }, index) => panel(label, line + 1, labels[index + 1]?.line ?? next));
9606
+ } else {
9607
+ const first = state.tokens.length;
9608
+ state.md.block.tokenize(state, startLine + 1, next);
9609
+ if (name === "code-group") for (let index = state.tokens.length - 1; index >= first; index--) {
9610
+ const token = state.tokens[index];
9611
+ if (token.type !== "fence" || token.level !== opening.level + 1) continue;
9612
+ const { title: fenceTitle, language } = parseLoomaFenceInfo(token.info);
9613
+ const tabOpen = new state.Token("looma_tab_open", "section", 1);
9614
+ tabOpen.meta = { label: fenceTitle ?? language ?? "Code" };
9615
+ state.tokens.splice(index, 1, tabOpen, token, new state.Token("looma_tab_close", "section", -1));
9616
+ }
9617
+ }
9618
+ const closing = state.push("looma_container_close", "div", -1);
9221
9619
  Object.assign(closing, {
9222
9620
  info: name,
9621
+ meta: { title },
9223
9622
  block: true,
9224
9623
  markup: match[1]
9225
9624
  });
@@ -9228,6 +9627,27 @@ function container(state, startLine, endLine, silent) {
9228
9627
  state.line = Math.min(next + 1, endLine);
9229
9628
  return true;
9230
9629
  }
9630
+ function leaf(state, startLine, _endLine, silent) {
9631
+ if (state.sCount[startLine] - state.blkIndent >= 4) return false;
9632
+ const match = LEAF.exec(lineText(state, startLine));
9633
+ if (!match || match[1].toLowerCase() !== "toc") return false;
9634
+ const attributes = match[2] ? parseLoomaAttributes(match[2]) : {};
9635
+ if (!attributes) return false;
9636
+ if (silent) return true;
9637
+ const format = typeof attributes.format === "string" && TOC_FORMATS.has(attributes.format) ? attributes.format : "plain";
9638
+ const depth = attributes.depth === "1" || attributes.depth === "2" ? Number(attributes.depth) : 3;
9639
+ const token = state.push("looma_toc", "nav", 0);
9640
+ Object.assign(token, {
9641
+ block: true,
9642
+ map: [startLine, startLine + 1],
9643
+ meta: {
9644
+ format,
9645
+ depth
9646
+ }
9647
+ });
9648
+ state.line = startLine + 1;
9649
+ return true;
9650
+ }
9231
9651
  function alerts(state) {
9232
9652
  const tokens = state.tokens;
9233
9653
  for (let index = 0; index < tokens.length; index++) {
@@ -9246,78 +9666,363 @@ function alerts(state) {
9246
9666
  }
9247
9667
  }
9248
9668
  }
9249
- function withSharedBlocks(md) {
9669
+ function taskLists(state) {
9670
+ const tokens = state.tokens;
9671
+ for (let index = 0; index < tokens.length; index++) {
9672
+ if (tokens[index].type !== "bullet_list_open") continue;
9673
+ const level = tokens[index].level;
9674
+ const items = [];
9675
+ let isTaskList = true;
9676
+ for (let cursor = index + 1; cursor < tokens.length; cursor++) {
9677
+ const token = tokens[cursor];
9678
+ if (token.type === "bullet_list_close" && token.level === level) break;
9679
+ if (token.type !== "list_item_open" || token.level !== level + 1) continue;
9680
+ const inline = tokens[cursor + 2];
9681
+ const match = tokens[cursor + 1]?.type === "paragraph_open" && inline?.type === "inline" ? TASK.exec(inline.content) : null;
9682
+ if (!match) {
9683
+ isTaskList = false;
9684
+ break;
9685
+ }
9686
+ items.push({
9687
+ item: token,
9688
+ inline,
9689
+ checked: match[1] !== " "
9690
+ });
9691
+ }
9692
+ if (!isTaskList || !items.length) continue;
9693
+ tokens[index].attrSet("data-type", "taskList");
9694
+ for (const { item, inline, checked } of items) {
9695
+ inline.content = inline.content.replace(TASK, "");
9696
+ item.attrSet("data-type", "taskItem");
9697
+ item.attrSet("data-checked", String(checked));
9698
+ item.meta = { checked };
9699
+ }
9700
+ }
9701
+ }
9702
+ function directive(state, silent) {
9703
+ const { src, pos } = state;
9704
+ if (src.charCodeAt(pos) !== 58) return false;
9705
+ const name = /^:(chip|mention)\[/.exec(src.slice(pos, pos + 10))?.[1];
9706
+ if (!name) return false;
9707
+ let label = "";
9708
+ let index = pos + name.length + 2;
9709
+ for (; index < state.posMax && src[index] !== "]"; index++) {
9710
+ if (src[index] === "\\" && index + 1 < state.posMax) index++;
9711
+ label += src[index];
9712
+ }
9713
+ if (src[index] !== "]") return false;
9714
+ const read = src[index + 1] === "{" ? readLoomaAttributes(src, index + 1) : {
9715
+ attributes: {},
9716
+ end: index + 1
9717
+ };
9718
+ if (!read || read.end > state.posMax) return false;
9719
+ if (name === "mention" && typeof read.attributes.id !== "string") return false;
9720
+ if (!silent) {
9721
+ const token = state.push(`looma_${name}`, "span", 0);
9722
+ token.meta = {
9723
+ label,
9724
+ attributes: read.attributes
9725
+ };
9726
+ }
9727
+ state.pos = read.end;
9728
+ return true;
9729
+ }
9730
+ function inlineText(token) {
9731
+ return (token?.children ?? []).map((child) => child.type === "image" ? inlineText(child) : child.content).join("");
9732
+ }
9733
+ function attributes(state) {
9734
+ const headings = [];
9735
+ const tokens = state.tokens;
9736
+ for (let index = 0; index < tokens.length; index++) {
9737
+ const token = tokens[index];
9738
+ if (token.type !== "inline" || !token.children) continue;
9739
+ const children = token.children;
9740
+ for (let child = children.length - 3; child >= 0; child--) {
9741
+ const text = children[child + 1];
9742
+ if (children[child].type !== "link_open" || children[child].markup !== "linkify" || /^(?:[a-z][\w+.-]*:|www\.)/i.test(text.content)) continue;
9743
+ children.splice(child, 3, Object.assign(new state.Token("text", "", 0), { content: text.content }));
9744
+ }
9745
+ if (tokens[index - 1]?.type === "heading_open") {
9746
+ const last = children.at(-1);
9747
+ const marker = last?.type === "text" ? /[ \t]+(\{[^{}]*\})$/.exec(last.content) : null;
9748
+ const parsed = marker && parseLoomaAttributes(marker[1]);
9749
+ let id;
9750
+ if (last && marker && parsed && typeof parsed.id === "string") {
9751
+ id = parsed.id;
9752
+ last.content = last.content.slice(0, marker.index);
9753
+ }
9754
+ headings.push({
9755
+ open: tokens[index - 1],
9756
+ text: inlineText(token).trim(),
9757
+ id
9758
+ });
9759
+ }
9760
+ for (let child = 0; child < children.length; child++) {
9761
+ const next = children[child + 1];
9762
+ if (children[child].type !== "image" || next?.type !== "text" || !next.content.startsWith("{")) continue;
9763
+ const read = readLoomaAttributes(next.content);
9764
+ if (!read) continue;
9765
+ applyImageAttributes(children[child], read.attributes);
9766
+ next.content = next.content.slice(read.end);
9767
+ if (!next.content) children.splice(child + 1, 1);
9768
+ }
9769
+ if (tokens[index - 1]?.type === "paragraph_open" && children.length === 1 && children[0].type === "image") {
9770
+ tokens[index - 1].hidden = true;
9771
+ tokens[index + 1].hidden = true;
9772
+ }
9773
+ }
9774
+ const ids = assignLoomaHeadingIds(headings);
9775
+ headings.forEach(({ open }, index) => open.attrSet("id", ids[index]));
9776
+ }
9777
+ function applyImageAttributes(image, attributes) {
9778
+ for (const key of ["width", "height"]) {
9779
+ const value = Number(attributes[key]);
9780
+ if (Number.isSafeInteger(value) && value > 0) image.attrSet(key, String(value));
9781
+ }
9782
+ const placement = attributes.placement;
9783
+ if (typeof placement === "string" && PLACEMENTS.has(placement) && placement !== "block") image.attrSet("data-placement", placement);
9784
+ if (attributes.responsive === true) image.attrSet("data-looma-responsive", "");
9785
+ }
9786
+ function tableOfContents(tokens, format, depth, escape) {
9787
+ const roots = [];
9788
+ const ancestors = [];
9789
+ tokens.forEach((token, index) => {
9790
+ if (token.type !== "heading_open") return;
9791
+ const level = Number(token.tag.slice(1));
9792
+ const text = inlineText(tokens[index + 1]).trim();
9793
+ if (level > depth || !text) return;
9794
+ while (ancestors.length && ancestors[ancestors.length - 1].level >= level) ancestors.pop();
9795
+ const entry = {
9796
+ id: token.attrGet("id") ?? "",
9797
+ text,
9798
+ children: []
9799
+ };
9800
+ (ancestors.at(-1)?.entry.children ?? roots).push(entry);
9801
+ ancestors.push({
9802
+ level,
9803
+ entry
9804
+ });
9805
+ });
9806
+ const [list, item] = format === "plain" ? ["div", "div"] : [format === "numbered" ? "ol" : "ul", "li"];
9807
+ 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}>`;
9808
+ 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`;
9809
+ }
9810
+ /**
9811
+ * Adds Looma Markdown to a markdown-it instance: containers, alerts, task lists, the table of
9812
+ * contents, chips, mentions, brace attributes, heading anchors, code meta and front matter.
9813
+ * @contract Renders the canonical HTML, which is the HTML LoomaEditor saves. It is not sanitized.
9814
+ * Linkify and raw HTML are instance options; `createLoomaMarkdownIt` turns both on.
9815
+ */
9816
+ function loomaMarkdown$1(md) {
9250
9817
  const escape = md.utils.escapeHtml;
9818
+ md.block.ruler.before("table", "looma_frontmatter", frontmatter);
9251
9819
  md.block.ruler.before("fence", "looma_container", container, { alt: [
9252
9820
  "paragraph",
9253
9821
  "reference",
9254
9822
  "blockquote",
9255
9823
  "list"
9256
9824
  ] });
9825
+ md.block.ruler.before("paragraph", "looma_leaf", leaf, { alt: [
9826
+ "paragraph",
9827
+ "reference",
9828
+ "blockquote",
9829
+ "list"
9830
+ ] });
9831
+ md.inline.ruler.before("link", "looma_directive", directive);
9832
+ md.core.ruler.after("block", "looma_task_lists", taskLists);
9257
9833
  md.core.ruler.before("inline", "looma_alerts", alerts);
9258
- md.renderer.rules.looma_container_open = (tokens, index) => {
9834
+ md.core.ruler.before("text_join", "looma_attributes", attributes);
9835
+ const rules = md.renderer.rules;
9836
+ rules.looma_frontmatter = () => "";
9837
+ rules.looma_container_open = (tokens, index) => {
9259
9838
  const { info, meta } = tokens[index];
9260
9839
  if (info === "details") return `<details${meta.open ? " open" : ""}><summary>${escape(meta.title || "Details")}</summary>\n`;
9840
+ if (info === "figure") return "<figure>\n";
9841
+ if (info === "tabs" || info === "code-group") return `<ui-tabs${info === "code-group" ? " class=\"code-group\"" : ""} group="${escape(meta.title || info)}">\n`;
9261
9842
  return `<ui-callout tone="${normalizeLoomaCalloutTone(info)}"${meta.title ? ` heading="${escape(meta.title)}"` : ""}>\n`;
9262
9843
  };
9263
- md.renderer.rules.looma_container_close = (tokens, index) => tokens[index].info === "details" ? "</details>\n" : "</ui-callout>\n";
9264
- return md;
9844
+ rules.looma_container_close = (tokens, index, _options, env) => {
9845
+ const { info } = tokens[index];
9846
+ if (info === "details") return "</details>\n";
9847
+ if (info === "figure") {
9848
+ const title = tokens[index].meta.title;
9849
+ return `${title ? `<figcaption>${md.renderInline(title, env)}</figcaption>\n` : ""}</figure>\n`;
9850
+ }
9851
+ if (info === "tabs" || info === "code-group") return "</ui-tabs>\n";
9852
+ return "</ui-callout>\n";
9853
+ };
9854
+ rules.looma_tab_open = (tokens, index) => `<section aria-label="${escape(tokens[index].meta.label)}">\n`;
9855
+ rules.looma_tab_close = () => "</section>\n";
9856
+ rules.looma_toc = (tokens, index) => tableOfContents(tokens, tokens[index].meta.format, tokens[index].meta.depth, escape);
9857
+ rules.looma_chip = (tokens, index) => {
9858
+ const { label, attributes } = tokens[index].meta;
9859
+ const tone = normalizeLoomaChipTone(attributes.tone);
9860
+ return `<span data-looma-chip="" data-label="${escape(label)}" data-color="${tone}"${label ? "" : " data-placeholder=\"\""}>${escape(label)}</span>`;
9861
+ };
9862
+ rules.looma_mention = (tokens, index) => {
9863
+ const { label, attributes } = tokens[index].meta;
9864
+ const id = String(attributes.id);
9865
+ return `<span data-type="mention" class="looma-mention" data-id="${escape(id)}" data-label="${escape(label || id)}">@${escape(label || id)}</span>`;
9866
+ };
9867
+ const listItem = rules.list_item_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options));
9868
+ rules.list_item_open = (tokens, index, options, env, self) => {
9869
+ const html = listItem(tokens, index, options, env, self);
9870
+ const task = tokens[index].meta;
9871
+ return task && typeof task.checked === "boolean" ? `${html}<input type="checkbox" disabled=""${task.checked ? " checked=\"\"" : ""}>` : html;
9872
+ };
9873
+ const code = (content, info) => {
9874
+ const { language, meta } = parseLoomaFenceInfo(info);
9875
+ return `<pre><code${language ? ` class="language-${escape(language)}"` : ""}${meta ? ` data-meta="${escape(meta)}"` : ""}>${escape(content.replace(/\n$/, ""))}</code></pre>\n`;
9876
+ };
9877
+ rules.fence = (tokens, index) => code(tokens[index].content, tokens[index].info);
9878
+ rules.code_block = (tokens, index) => code(tokens[index].content, "");
9265
9879
  }
9266
- /** The markdown-it instance LoomaEditor imports Markdown with, shared block vocabulary included. */
9267
- var loomaMarkdown = withSharedBlocks(new MarkdownIt({
9268
- html: true,
9269
- linkify: false,
9270
- typographer: false
9271
- }));
9272
9880
  /**
9273
- * Renders Markdown, including the shared block vocabulary, as HTML the editor parses.
9881
+ * A markdown-it instance for Looma Markdown: raw HTML and GFM autolinks on, with `loomaMarkdown`.
9882
+ * @contract Each call returns a new instance that callers may extend.
9883
+ */
9884
+ function createLoomaMarkdownIt() {
9885
+ return new MarkdownIt({
9886
+ html: true,
9887
+ linkify: true,
9888
+ typographer: false
9889
+ }).use(loomaMarkdown$1);
9890
+ }
9891
+ //#endregion
9892
+ //#region src/editor/extensions/markdown.ts
9893
+ /** The markdown-it instance LoomaEditor reads Looma Markdown with. */
9894
+ var loomaMarkdown = createLoomaMarkdownIt();
9895
+ /**
9896
+ * Renders Looma Markdown as the HTML the editor parses.
9274
9897
  * @contract The result is not sanitized; pass it to the editor's `setContent`, whose schema
9275
- * keeps only content it knows.
9898
+ * keeps only content it knows. Front matter renders nothing.
9276
9899
  */
9277
9900
  function loomaMarkdownToHtml(source) {
9278
- return loomaMarkdown.render(source).replace(/\n<\/code><\/pre>/g, "</code></pre>");
9901
+ return loomaMarkdown.render(source);
9279
9902
  }
9903
+ var CONTAINERS = /* @__PURE__ */ new Set([
9904
+ "loomaCallout",
9905
+ "loomaExpand",
9906
+ "loomaFigure",
9907
+ "loomaTabs"
9908
+ ]);
9280
9909
  var containerDepth = (node) => {
9281
9910
  let depth = 0;
9282
9911
  node.descendants((child) => {
9283
- if (child.type.name === "loomaCallout" || child.type.name === "loomaExpand") depth = Math.max(depth, 1 + containerDepth(child));
9912
+ if (!CONTAINERS.has(child.type.name)) return true;
9913
+ depth = Math.max(depth, 1 + containerDepth(child));
9284
9914
  return false;
9285
9915
  });
9286
9916
  return depth;
9287
9917
  };
9288
- function writeContainer(state, node, opening) {
9289
- const marker = ":".repeat(3 + containerDepth(node));
9918
+ var endLine = (state) => state.flushClose(1);
9919
+ function writeContainer(state, node, opening, content = () => state.renderContent(node)) {
9920
+ const marker = loomaContainerMarker(containerDepth(node));
9290
9921
  state.write(`${marker} ${opening}`.trimEnd());
9291
9922
  state.ensureNewLine();
9292
- state.renderContent(node);
9923
+ content();
9924
+ endLine(state);
9293
9925
  state.write(marker);
9294
9926
  state.closeBlock(node);
9295
9927
  }
9296
9928
  var defaults = defaultMarkdownSerializer.nodes;
9929
+ var label = (text) => text.replace(/[\\\]]/g, "\\$&");
9930
+ var ALIGNMENT = {
9931
+ left: ":---",
9932
+ center: ":---:",
9933
+ right: "---:"
9934
+ };
9297
9935
  var NODES = {
9298
9936
  paragraph: defaults.paragraph,
9299
- text: defaults.text,
9937
+ text: (state, node, parent, index) => {
9938
+ const marker = /^:{3,}[ \t]*([A-Za-z][\w-]*)?/.exec(node.text ?? "");
9939
+ if ((marker ? !marker[1] || LOOMA_CONTAINER_NAMES.has(marker[1].toLowerCase()) : /^::toc(?![\w-])/i.test(node.text ?? "")) && state.atBlank()) state.write("\\");
9940
+ defaults.text(state, node, parent, index);
9941
+ },
9300
9942
  blockquote: defaults.blockquote,
9301
- heading: defaults.heading,
9302
9943
  horizontalRule: defaults.horizontal_rule,
9303
- bulletList: defaults.bullet_list,
9944
+ bulletList: (state, node) => state.renderList(node, " ", () => "- "),
9304
9945
  orderedList: (state, node) => {
9305
9946
  const start = Number(node.attrs.start ?? 1);
9306
9947
  state.renderList(node, " ", (index) => `${start + index}. `);
9307
9948
  },
9308
9949
  listItem: defaults.list_item,
9950
+ taskList: (state, node) => state.renderList(node, " ", () => "- "),
9951
+ taskItem: (state, node) => {
9952
+ state.write(node.attrs.checked ? "[x] " : "[ ] ");
9953
+ state.renderContent(node);
9954
+ },
9309
9955
  hardBreak: defaults.hard_break,
9310
9956
  codeBlock: (state, node) => {
9311
- const longest = Math.max(0, ...(node.textContent.match(/`+/g) ?? []).map((run) => run.length));
9312
- const fence = "`".repeat(Math.max(3, longest + 1));
9313
- state.write(`${fence}${node.attrs.language ?? ""}\n`);
9957
+ const fence = loomaCodeFence(node.textContent);
9958
+ state.write(`${fence}${node.attrs.language ?? ""}${node.attrs.meta ? ` ${node.attrs.meta}` : ""}\n`);
9314
9959
  state.text(node.textContent, false);
9315
9960
  state.ensureNewLine();
9316
9961
  state.write(fence);
9317
9962
  state.closeBlock(node);
9318
9963
  },
9964
+ image: (state, node, parent, index) => {
9965
+ defaults.image(state, node, parent, index);
9966
+ const { width, height, placement, responsive } = node.attrs;
9967
+ state.write(formatLoomaAttributes({
9968
+ width,
9969
+ height,
9970
+ placement: placement && placement !== "block" ? placement : null,
9971
+ responsive: responsive === true
9972
+ }));
9973
+ if (node.isBlock) state.closeBlock(node);
9974
+ },
9319
9975
  loomaCallout: (state, node) => writeContainer(state, node, `${node.attrs.tone} ${node.attrs.heading ?? ""}`),
9320
- loomaExpand: (state, node) => writeContainer(state, node, `details ${node.attrs.summary}${node.attrs.open ? " {open}" : ""}`)
9976
+ loomaExpand: (state, node) => writeContainer(state, node, `details ${node.attrs.summary}${node.attrs.open ? " {open}" : ""}`),
9977
+ loomaFigure: (state, node) => {
9978
+ const caption = node.lastChild;
9979
+ const marker = loomaContainerMarker(containerDepth(node));
9980
+ state.write(`${marker} figure`);
9981
+ if (caption.content.size) {
9982
+ state.write(" ");
9983
+ state.renderInline(caption, false);
9984
+ }
9985
+ state.ensureNewLine();
9986
+ state.render(node.firstChild, node, 0);
9987
+ endLine(state);
9988
+ state.write(marker);
9989
+ state.closeBlock(node);
9990
+ },
9991
+ loomaFigcaption: (state, node) => state.renderInline(node),
9992
+ loomaTabs: (state, node) => {
9993
+ const name = node.attrs.codeGroup ? "code-group" : "tabs";
9994
+ const bare = node.attrs.codeGroup && Array.from({ length: node.childCount }, (_, index) => node.child(index)).every((tab) => {
9995
+ const code = tab.firstChild;
9996
+ if (tab.childCount !== 1 || code?.type.name !== "codeBlock") return false;
9997
+ const { title, language } = parseLoomaFenceInfo(`${code.attrs.language ?? ""} ${code.attrs.meta ?? ""}`);
9998
+ return tab.attrs.label === (title ?? language ?? "Code");
9999
+ });
10000
+ writeContainer(state, node, `${name} ${node.attrs.group && node.attrs.group !== name ? node.attrs.group : ""}`, () => node.forEach((tab, _offset, index) => {
10001
+ if (index) endLine(state);
10002
+ if (!bare) {
10003
+ state.write(`== ${tab.attrs.label}`);
10004
+ state.ensureNewLine();
10005
+ }
10006
+ state.renderContent(tab);
10007
+ }));
10008
+ },
10009
+ loomaTab: (state, node) => state.renderContent(node),
10010
+ loomaTableOfContents: (state, node) => {
10011
+ const { format, depth } = node.attrs;
10012
+ state.write(`::toc${formatLoomaAttributes({
10013
+ depth: depth !== 3 ? depth : null,
10014
+ format: format !== "plain" ? format : null
10015
+ })}`);
10016
+ state.closeBlock(node);
10017
+ },
10018
+ loomaChip: (state, node) => {
10019
+ const tone = normalizeLoomaChipTone(node.attrs.color);
10020
+ state.write(`:chip[${label(String(node.attrs.label ?? ""))}]${formatLoomaAttributes({ tone: tone !== "neutral" ? tone : null })}`);
10021
+ },
10022
+ mention: (state, node) => {
10023
+ const id = String(node.attrs.id ?? "");
10024
+ state.write(`:mention[${label(String(node.attrs.label ?? id))}]${formatLoomaAttributes({ id }, { hashId: false })}`);
10025
+ }
9321
10026
  };
9322
10027
  var MARKS = {
9323
10028
  bold: defaultMarkdownSerializer.marks.strong,
@@ -9341,31 +10046,96 @@ var MARKS = {
9341
10046
  mixable: true
9342
10047
  }
9343
10048
  };
10049
+ function customHeadingIds(doc) {
10050
+ const headings = [];
10051
+ doc.descendants((node) => {
10052
+ if (node.type.name === "heading") headings.push({
10053
+ text: node.textContent.trim(),
10054
+ id: typeof node.attrs.id === "string" && node.attrs.id ? node.attrs.id : void 0
10055
+ });
10056
+ });
10057
+ const marked = /* @__PURE__ */ new Set();
10058
+ for (;;) {
10059
+ const derived = assignLoomaHeadingIds(headings.map(({ text, id }, index) => ({
10060
+ text,
10061
+ id: marked.has(index) ? id : void 0
10062
+ })));
10063
+ const next = headings.findIndex(({ id }, index) => id && !marked.has(index) && derived[index] !== id);
10064
+ if (next < 0) return marked;
10065
+ marked.add(next);
10066
+ }
10067
+ }
9344
10068
  /**
9345
- * Writes an editor document as Markdown, with callouts and expand sections in the shared
9346
- * `:::` syntax that `loomaMarkdownToHtml` and Markupress read.
9347
- * @contract Content Markdown has no syntax for (tables, task lists, images, chips, mentions,
9348
- * the table of contents) is written as its saved HTML, which Markdown carries unchanged.
9349
- * Marks without a Markdown form other than underline and highlight are dropped.
10069
+ * Writes an editor document as Looma Markdown, the form `loomaMarkdownToHtml` and Markupress read.
10070
+ * @contract Every node in the default preset has a Markdown form. A table that fits GFM is a GFM
10071
+ * table and any other is HTML. Content from extensions outside the preset is written as its saved
10072
+ * HTML, and marks without a Markdown form other than underline and highlight are dropped.
9350
10073
  */
9351
10074
  function loomaMarkdownFromDoc(doc) {
9352
- const dom = DOMSerializer.fromSchema(doc.type.schema);
10075
+ const schema = doc.type.schema;
10076
+ const dom = DOMSerializer.fromSchema(schema);
9353
10077
  const html = (node) => {
9354
10078
  const holder = document.createElement("div");
9355
10079
  holder.append(dom.serializeNode(node));
9356
10080
  return holder.innerHTML;
9357
10081
  };
9358
- const nodes = {};
9359
- for (const name of Object.keys(doc.type.schema.nodes)) nodes[name] = NODES[name] ?? ((state, node) => {
10082
+ const custom = customHeadingIds(doc);
10083
+ let heading = 0;
10084
+ const nodes = {
10085
+ heading: (state, node) => {
10086
+ state.write(`${"#".repeat(node.attrs.level)} `);
10087
+ state.renderInline(node, false);
10088
+ if (custom.has(heading++)) state.write(` {#${node.attrs.id}}`);
10089
+ else {
10090
+ const out = state;
10091
+ out.out = out.out.replace(/([ \t])\{([^{}]*\})$/, "$1\\{$2");
10092
+ }
10093
+ state.closeBlock(node);
10094
+ },
10095
+ table: (state, node) => {
10096
+ state.write(gfmTable(node) ?? `<table><tbody>${Array.from({ length: node.childCount }, (_, index) => html(node.child(index))).join("")}</tbody></table>`.replace(/ (?:colspan|rowspan)="1"/g, ""));
10097
+ state.closeBlock(node);
10098
+ }
10099
+ };
10100
+ for (const name of Object.keys(schema.nodes)) nodes[name] ??= NODES[name] ?? ((state, node) => {
9360
10101
  state.write(html(node));
9361
10102
  if (node.isBlock) state.closeBlock(node);
9362
10103
  });
9363
10104
  const marks = {};
9364
- for (const name of Object.keys(doc.type.schema.marks)) marks[name] = MARKS[name] ?? {
10105
+ for (const name of Object.keys(schema.marks)) marks[name] = MARKS[name] ?? {
9365
10106
  open: "",
9366
10107
  close: ""
9367
10108
  };
9368
- return new MarkdownSerializer(nodes, marks).serialize(doc, { tightLists: true });
10109
+ const serializer = new MarkdownSerializer(nodes, marks);
10110
+ const inline = (paragraph) => serializer.serialize(schema.topNodeType.create(null, paragraph)).replace(/\|/g, "\\|");
10111
+ function gfmTable(table) {
10112
+ const rows = Array.from({ length: table.childCount }, (_, index) => table.child(index));
10113
+ const width = rows[0]?.childCount ?? 0;
10114
+ const alignments = [];
10115
+ const lines = [];
10116
+ for (const [rowIndex, row] of rows.entries()) {
10117
+ if (row.childCount !== width) return null;
10118
+ const cells = [];
10119
+ for (let column = 0; column < width; column++) {
10120
+ const cell = row.child(column);
10121
+ const { colspan, rowspan, colwidth, backgroundColor, textAlign } = cell.attrs;
10122
+ const paragraph = cell.firstChild;
10123
+ if (cell.type.name === "tableHeader" !== (rowIndex === 0) || colspan !== 1 || rowspan !== 1 || colwidth || backgroundColor || cell.childCount !== 1 || paragraph?.type.name !== "paragraph") return null;
10124
+ let hasBreak = false;
10125
+ paragraph.descendants((child) => {
10126
+ if (child.type.name === "hardBreak") hasBreak = true;
10127
+ });
10128
+ if (hasBreak) return null;
10129
+ if (rowIndex === 0) alignments.push(textAlign ?? null);
10130
+ else if ((textAlign ?? null) !== alignments[column]) return null;
10131
+ cells.push(inline(paragraph));
10132
+ }
10133
+ lines.push(`| ${cells.join(" | ")} |`);
10134
+ if (rowIndex === 0) lines.push(`| ${alignments.map((alignment) => ALIGNMENT[alignment] ?? "---").join(" | ")} |`);
10135
+ }
10136
+ return width ? lines.join("\n") : null;
10137
+ }
10138
+ return serializer.serialize(doc, { tightLists: true });
9369
10139
  }
9370
10140
  //#endregion
9371
10141
  //#region src/editor/extensions/smart-paste.ts
@@ -9391,12 +10161,14 @@ function looksLikeMarkdown(value) {
9391
10161
  "blockquote_open",
9392
10162
  "fence",
9393
10163
  "table_open",
9394
- "looma_container_open"
10164
+ "looma_container_open",
10165
+ "looma_toc"
9395
10166
  ].includes(token.type)) return true;
9396
- return token.type === "inline" && Boolean(token.children?.some((child) => [
9397
- "link_open",
10167
+ return token.type === "inline" && Boolean(token.children?.some((child) => child.type === "link_open" && child.markup !== "linkify" || [
9398
10168
  "image",
9399
- "strong_open"
10169
+ "strong_open",
10170
+ "looma_chip",
10171
+ "looma_mention"
9400
10172
  ].includes(child.type)));
9401
10173
  });
9402
10174
  }
@@ -9411,7 +10183,7 @@ function hasRichClipboardStructure(value) {
9411
10183
  if (body.querySelector("[data-pm-slice]") && !body.querySelector("pre")) return true;
9412
10184
  return body.querySelectorAll("p").length > 1;
9413
10185
  }
9414
- function parseHtmlSlice(schema, value) {
10186
+ function parseHtmlSlice(schema, value, fromHtml = false) {
9415
10187
  const parsed = new globalThis.DOMParser().parseFromString(value, "text/html");
9416
10188
  if (parsed.body.style.display === "none") return Slice.empty;
9417
10189
  for (const element of parsed.body.querySelectorAll("[style]")) if (element.style.display === "none") element.remove();
@@ -9422,7 +10194,7 @@ function parseHtmlSlice(schema, value) {
9422
10194
  if (/\s$/.test(child.textContent) || /^\s/.test(next.textContent)) continue;
9423
10195
  wrapper.insertBefore(parsed.createTextNode(" "), next);
9424
10196
  }
9425
- for (const pre of parsed.body.querySelectorAll("pre")) {
10197
+ for (const pre of fromHtml ? parsed.body.querySelectorAll("pre") : []) {
9426
10198
  let last = pre;
9427
10199
  while (last.lastChild) last = last.lastChild;
9428
10200
  if (last.nodeType === Node.TEXT_NODE) last.textContent = last.textContent?.replace(/\r?\n$/, "") ?? "";
@@ -9439,28 +10211,24 @@ function parseHtmlSlice(schema, value) {
9439
10211
  "template"
9440
10212
  ],
9441
10213
  FORBID_ATTR: ["style"],
9442
- ADD_TAGS: ["ui-callout"],
9443
- ADD_ATTR: ["tone", "heading"],
10214
+ ADD_TAGS: ["ui-callout", "ui-tabs"],
10215
+ ADD_ATTR: [
10216
+ "tone",
10217
+ "heading",
10218
+ "group"
10219
+ ],
9444
10220
  RETURN_DOM_FRAGMENT: true
9445
10221
  }));
9446
10222
  if (!container.textContent?.trim() && !container.querySelector("img, hr")) return Slice.empty;
9447
10223
  return DOMParser.fromSchema(schema).parseSlice(container, { preserveWhitespace: false });
9448
10224
  }
9449
- function markdownFrontmatter(value) {
9450
- const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(value.slice(0, 8192));
9451
- if (!match || !/^[A-Za-z][\w-]*:[ \t]*\S/m.test(match[1])) return null;
9452
- return {
9453
- source: match[0].trimEnd(),
9454
- body: value.slice(match[0].length)
9455
- };
9456
- }
9457
10225
  function escapeHtmlText(value) {
9458
10226
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9459
10227
  }
9460
10228
  function documentSlice(schema, plainText) {
9461
- const frontmatter = markdownFrontmatter(plainText);
10229
+ const frontmatter = splitLoomaFrontmatter(plainText);
9462
10230
  if (frontmatter || looksLikeMarkdown(plainText)) return parseHtmlSlice(schema, frontmatter ? `<pre><code class="language-yaml">${escapeHtmlText(frontmatter.source)}</code></pre>${loomaMarkdown.render(frontmatter.body)}` : loomaMarkdown.render(plainText));
9463
- if (looksLikeDocumentHtml(plainText)) return parseHtmlSlice(schema, plainText);
10231
+ if (looksLikeDocumentHtml(plainText)) return parseHtmlSlice(schema, plainText, true);
9464
10232
  return null;
9465
10233
  }
9466
10234
  /**
@@ -9992,6 +10760,16 @@ function setActiveTableCellBackground(editor, backgroundColor) {
9992
10760
  * Uses the Vanilla JS Tiptap API; apps provide @tiptap/core and Looma ships the preset extensions.
9993
10761
  */
9994
10762
  var LoomaCodeBlock = CodeBlockLowlight.extend({
10763
+ addAttributes() {
10764
+ return {
10765
+ ...this.parent?.(),
10766
+ meta: {
10767
+ default: null,
10768
+ parseHTML: (element) => element.querySelector("code")?.getAttribute("data-meta") || element.getAttribute("data-meta") || null,
10769
+ renderHTML: () => ({})
10770
+ }
10771
+ };
10772
+ },
9995
10773
  renderHTML({ node, HTMLAttributes }) {
9996
10774
  return [
9997
10775
  "pre",
@@ -10000,6 +10778,7 @@ var LoomaCodeBlock = CodeBlockLowlight.extend({
10000
10778
  "code",
10001
10779
  {
10002
10780
  class: node.attrs.language ? `${this.options.languageClassPrefix}${node.attrs.language}` : null,
10781
+ "data-meta": node.attrs.meta || null,
10003
10782
  spellcheck: "false"
10004
10783
  },
10005
10784
  0
@@ -10075,7 +10854,10 @@ function getDefaultEditorExtensions(options = {}) {
10075
10854
  Heading.configure({ levels: [
10076
10855
  1,
10077
10856
  2,
10078
- 3
10857
+ 3,
10858
+ 4,
10859
+ 5,
10860
+ 6
10079
10861
  ] }),
10080
10862
  BulletList,
10081
10863
  OrderedList,
@@ -10085,6 +10867,9 @@ function getDefaultEditorExtensions(options = {}) {
10085
10867
  Blockquote,
10086
10868
  calloutNodeView ? LoomaCallout.extend({ addNodeView: () => calloutNodeView }) : LoomaCallout,
10087
10869
  LoomaChip,
10870
+ LoomaTabs,
10871
+ LoomaTab,
10872
+ ...imageInline ? [] : [LoomaFigure, LoomaFigcaption],
10088
10873
  LoomaExpand.configure({ nodeView: expandNodeView ?? null }),
10089
10874
  LoomaTableOfContents.configure({ nodeView: tableOfContentsNodeView ?? null }),
10090
10875
  dividerNodeView ? HorizontalRule.extend({ addNodeView: () => dividerNodeView }) : HorizontalRule,
@@ -11076,4 +11861,4 @@ function createLoomaSlashCommandExtension(options = {}) {
11076
11861
  });
11077
11862
  }
11078
11863
  //#endregion
11079
- export { filterLoomaMentionItems as A, LOOMA_CHIP_COLORS as B, loomaMarkdownToHtml as C, createLoomaMentionExtension as D, LoomaMentionSuggestionPluginKey as E, LoomaTableOfContents as F, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS as G, normalizeLoomaChipColor as H, getTableOfContentsEntries as I, LoomaActiveBlock as J, LOOMA_ACTIVE_BLOCK_CLASS as K, normalizeTableOfContentsConfig as L, LoomaImage as M, normalizeImageDimension as N, DEFAULT_MENTION_RESULT_LIMIT as O, normalizeImagePlacement as P, LoomaExpand as R, loomaMarkdownFromDoc as S, siteRelativeHref as T, LOOMA_CALLOUT_TONES as U, LoomaChip as V, LoomaCallout as W, LoomaActiveBlockPluginKey as Y, getActiveTableCellAlignment as _, getActiveTableUiState as a, setActiveTableCellBackground as b, insertTableAtRange as c, LoomaTableKit as d, getDefaultEditorExtensions as f, LoomaTableHeader as g, LoomaTableCell as h, getDefaultSlashCommands as i, normalizeMentionResultLimit as j, MAX_MENTION_RESULT_LIMIT as k, normalizeActiveTableColumnWidths as l, LoomaTable as m, createLoomaSlashCommandExtension as n, handleTableAction as o, getLoomaTableExtensions as p, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS as q, filterLoomaSlashCommands as r, handleTableOverlayAction as s, LoomaSlashCommand as t, shouldShowTextFormattingToolbar as u, getActiveTableCellBackground as v, createSiteRelativeLinks as w, LoomaSmartPaste as x, setActiveTableCellAlignment as y, revealLoomaExpandAt as z };
11864
+ export { LoomaActiveBlockPluginKey as $, filterLoomaMentionItems as A, LoomaFigcaption as B, loomaMarkdownToHtml as C, createLoomaMentionExtension as D, LoomaMentionSuggestionPluginKey as E, LoomaTableOfContents as F, LoomaChip as G, LoomaExpand as H, getTableOfContentsEntries as I, LOOMA_CALLOUT_TONES as J, normalizeLoomaChipColor as K, normalizeTableOfContentsConfig as L, LoomaImage as M, normalizeImageDimension as N, DEFAULT_MENTION_RESULT_LIMIT as O, normalizeImagePlacement as P, LoomaActiveBlock as Q, LoomaTab as R, loomaMarkdownFromDoc as S, siteRelativeHref as T, revealLoomaExpandAt as U, LoomaFigure as V, LOOMA_CHIP_COLORS as W, LOOMA_ACTIVE_BLOCK_CLASS as X, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS as Y, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS as Z, getActiveTableCellAlignment as _, getActiveTableUiState as a, setActiveTableCellBackground as b, insertTableAtRange as c, LoomaTableKit as d, getDefaultEditorExtensions as f, LoomaTableHeader as g, LoomaTableCell as h, getDefaultSlashCommands as i, normalizeMentionResultLimit as j, MAX_MENTION_RESULT_LIMIT as k, normalizeActiveTableColumnWidths as l, LoomaTable as m, createLoomaSlashCommandExtension as n, handleTableAction as o, getLoomaTableExtensions as p, LoomaCallout as q, filterLoomaSlashCommands as r, handleTableOverlayAction as s, LoomaSlashCommand as t, shouldShowTextFormattingToolbar as u, getActiveTableCellBackground as v, createSiteRelativeLinks as w, LoomaSmartPaste as x, setActiveTableCellAlignment as y, LoomaTabs as z };