@threadlabs/looma 0.28.16 → 0.28.18

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.
@@ -242,6 +242,25 @@ function parseLoomaAttributes(source) {
242
242
  return read && read.end === source.length ? read.attributes : null;
243
243
  }
244
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
+ /**
245
264
  * The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
246
265
  * marks, with every other run of characters one hyphen.
247
266
  * @failure Text with no letters or numbers, such as only emoji, derives `heading`.
@@ -290,15 +309,46 @@ function parseLoomaFenceInfo(info) {
290
309
  ...numbers ? { lineNumbers: numbers[1] === void 0 } : {}
291
310
  };
292
311
  }
312
+ var MAPPING_KEY = /^(?:[^\s#:'"\-?,[\]{}&*!|>%@`][^:\n]*?|"[^"\n]*"|'[^'\n]*')[ \t]*:(?:[ \t]|$)/;
313
+ var NOTATION = /[ \t]*(?:\/\/|#|--|<!--|\{?\/\*)[ \t]*\[!code highlight(?::(\d+))?\][ \t]*(?:-->|\*\/\}?)?[ \t]*$/;
314
+ /**
315
+ * Applies code notation: a line ending in a `[!code highlight]` comment is highlighted and loses
316
+ * the comment, `[!code highlight:3]` covers three lines, and a marker alone on its line is removed
317
+ * with the line and applies to the lines after it.
318
+ * @contract Returns the code a reader sees and its highlighted line numbers, from 1, ascending.
319
+ * ponytail: matched on source lines, so a string that ends in the marker counts too.
320
+ */
321
+ function applyLoomaCodeNotation(code) {
322
+ const kept = [];
323
+ const lines = /* @__PURE__ */ new Set();
324
+ for (const line of code.split("\n")) {
325
+ const match = NOTATION.exec(line);
326
+ if (!match) {
327
+ kept.push(line);
328
+ continue;
329
+ }
330
+ const rest = line.slice(0, match.index);
331
+ const own = rest.trim() !== "";
332
+ if (own) kept.push(rest);
333
+ for (let count = 0; count < Number(match[1] ?? 1); count++) lines.add(kept.length + (own ? 0 : 1) + count);
334
+ }
335
+ return {
336
+ code: kept.join("\n"),
337
+ lines: [...lines].sort((a, b) => a - b)
338
+ };
339
+ }
293
340
  /**
294
341
  * Splits YAML front matter from the start of a document.
295
- * @contract Front matter opens with a `---` line at the very start, holds at least one `key: value`
296
- * line, and closes at a `---` or `...` line. `source` is the block as written, delimiters included.
342
+ * @contract Front matter opens with a `---` line at the very start and closes at a `---` or `...`
343
+ * line, and what lies between is a YAML mapping: its first line that is not blank or a comment
344
+ * starts with a key, whose value may be a scalar, a list or a mapping. `source` is the block as
345
+ * written, delimiters included.
297
346
  * @failure Returns null when the document has none.
298
347
  */
299
348
  function splitLoomaFrontmatter(document) {
300
349
  const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(document.slice(0, 8192));
301
- if (!match || !/^[A-Za-z][\w-]*:[ \t]*\S/m.test(match[1])) return null;
350
+ const first = match?.[1].split(/\r?\n/).find((line) => line.trim() && !line.trimStart().startsWith("#"));
351
+ if (!match || !first || !MAPPING_KEY.test(first)) return null;
302
352
  return {
303
353
  source: match[0].trimEnd(),
304
354
  yaml: match[1],
@@ -625,6 +675,144 @@ var LoomaExpand = Node$1.create({
625
675
  }
626
676
  });
627
677
  //#endregion
678
+ //#region src/editor/extensions/figure.ts
679
+ /**
680
+ * An image with a caption, saved as `<figure><img><figcaption>` and written in Looma Markdown as
681
+ * `::: figure Caption`. The caption is inline content the author edits in place.
682
+ * @invariant A figure holds exactly one block image and one caption; an empty caption stays empty.
683
+ */
684
+ var LoomaFigure = Node$1.create({
685
+ name: "loomaFigure",
686
+ group: "block",
687
+ content: "image loomaFigcaption",
688
+ defining: true,
689
+ parseHTML() {
690
+ return [{
691
+ tag: "figure",
692
+ getAttrs: (element) => element.querySelector(":scope > img") ? null : false
693
+ }];
694
+ },
695
+ renderHTML() {
696
+ return ["figure", 0];
697
+ }
698
+ });
699
+ /**
700
+ * A figure's caption: inline content under its image.
701
+ * @invariant It exists only as the second child of `LoomaFigure`.
702
+ */
703
+ var LoomaFigcaption = Node$1.create({
704
+ name: "loomaFigcaption",
705
+ content: "inline*",
706
+ defining: true,
707
+ parseHTML() {
708
+ return [{ tag: "figcaption" }];
709
+ },
710
+ renderHTML() {
711
+ return ["figcaption", 0];
712
+ }
713
+ });
714
+ //#endregion
715
+ //#region src/editor/extensions/tabs.ts
716
+ /**
717
+ * Tabs or a code group, kept whole so Looma Markdown's `::: tabs` and `::: code-group` survive
718
+ * editing. Saved as `<ui-tabs group>` with a `<section aria-label>` per panel.
719
+ * @contract While editing, panels show one after another under their labels; the Tabs component
720
+ * draws them only where the saved HTML renders.
721
+ */
722
+ var LoomaTabs = Node$1.create({
723
+ name: "loomaTabs",
724
+ group: "block",
725
+ content: "loomaTab+",
726
+ defining: true,
727
+ isolating: true,
728
+ addAttributes() {
729
+ return {
730
+ group: {
731
+ default: "",
732
+ parseHTML: (element) => element.getAttribute("group") ?? "",
733
+ renderHTML: (attributes) => attributes.group ? { group: attributes.group } : {}
734
+ },
735
+ codeGroup: {
736
+ default: false,
737
+ parseHTML: (element) => element.classList.contains("code-group"),
738
+ renderHTML: (attributes) => attributes.codeGroup ? { class: "code-group" } : {}
739
+ }
740
+ };
741
+ },
742
+ parseHTML() {
743
+ return [{ tag: "ui-tabs" }];
744
+ },
745
+ renderHTML({ HTMLAttributes }) {
746
+ return [
747
+ "ui-tabs",
748
+ HTMLAttributes,
749
+ 0
750
+ ];
751
+ },
752
+ addNodeView() {
753
+ return () => {
754
+ const dom = document.createElement("div");
755
+ dom.dataset.loomaTabs = "";
756
+ return {
757
+ dom,
758
+ contentDOM: dom
759
+ };
760
+ };
761
+ }
762
+ });
763
+ /**
764
+ * One panel of `LoomaTabs`, labelled by `label`.
765
+ * @invariant It exists only inside `LoomaTabs`; its label is the tab Tabs shows and the `== Label` line Markdown writes.
766
+ */
767
+ var LoomaTab = Node$1.create({
768
+ name: "loomaTab",
769
+ content: "block+",
770
+ defining: true,
771
+ isolating: true,
772
+ addAttributes() {
773
+ return { label: {
774
+ default: "",
775
+ parseHTML: (element) => element.getAttribute("aria-label") ?? "",
776
+ renderHTML: (attributes) => ({ "aria-label": attributes.label })
777
+ } };
778
+ },
779
+ parseHTML() {
780
+ return [{ tag: "ui-tabs > section" }];
781
+ },
782
+ renderHTML({ HTMLAttributes }) {
783
+ return [
784
+ "section",
785
+ HTMLAttributes,
786
+ 0
787
+ ];
788
+ },
789
+ addNodeView() {
790
+ return ({ node }) => {
791
+ let current = node;
792
+ const dom = document.createElement("section");
793
+ const label = document.createElement("div");
794
+ label.contentEditable = "false";
795
+ label.dataset.loomaTabLabel = "";
796
+ label.textContent = node.attrs.label;
797
+ const contentDOM = document.createElement("div");
798
+ dom.setAttribute("aria-label", node.attrs.label);
799
+ dom.append(label, contentDOM);
800
+ return {
801
+ dom,
802
+ contentDOM,
803
+ update(next) {
804
+ if (next.type !== current.type) return false;
805
+ current = next;
806
+ label.textContent = next.attrs.label;
807
+ dom.setAttribute("aria-label", next.attrs.label);
808
+ return true;
809
+ },
810
+ ignoreMutation: (mutation) => mutation.type !== "selection" && !contentDOM.contains(mutation.target)
811
+ };
812
+ };
813
+ }
814
+ });
815
+ //#endregion
628
816
  //#region src/editor/extensions/table-of-contents.ts
629
817
  /** Normalize loaded configuration without persisting unsupported presentation values. */
630
818
  function normalizeTableOfContentsConfig(attributes) {
@@ -671,7 +859,17 @@ function tableOfContentsTree(entries) {
671
859
  }
672
860
  return roots;
673
861
  }
674
- function anchorTransaction(state) {
862
+ var derivedFrom = (id, text) => {
863
+ const slug = loomaHeadingSlug(text);
864
+ return id === slug || id.startsWith(`${slug}-`) && /^\d+$/.test(id.slice(slug.length + 1));
865
+ };
866
+ /**
867
+ * Keeps heading anchors on Looma Markdown's rule. An anchor derived from its heading's text
868
+ * (as it read before this change) follows the text; any other, including `looma-heading-N` anchors
869
+ * saved before this rule, is a custom anchor and stays, so links to it keep working. With
870
+ * a table of contents every heading gets an anchor; without one, only headings that have one.
871
+ */
872
+ function anchorTransaction(state, previous) {
675
873
  let hasToc = false;
676
874
  const headings = [];
677
875
  state.doc.descendants((node, position) => {
@@ -681,24 +879,28 @@ function anchorTransaction(state) {
681
879
  position
682
880
  });
683
881
  });
684
- if (!hasToc) return null;
685
- const reserved = new Set(headings.map(({ node }) => String(node.attrs.id ?? "")).filter(Boolean));
686
- const used = /* @__PURE__ */ new Set();
882
+ if (!headings.length) return null;
883
+ const before = /* @__PURE__ */ new Map();
884
+ previous?.descendants((node) => {
885
+ if (node.type.name === "heading" && typeof node.attrs.id === "string") before.set(node.attrs.id, node.textContent.trim());
886
+ });
887
+ const custom = (node) => {
888
+ const id = node.attrs.id;
889
+ if (typeof id !== "string" || !id || /\s/.test(id)) return void 0;
890
+ return derivedFrom(id, before.get(id) ?? node.textContent.trim()) ? void 0 : id;
891
+ };
892
+ const ids = assignLoomaHeadingIds(headings.map(({ node }) => ({
893
+ text: node.textContent.trim(),
894
+ id: custom(node)
895
+ })));
687
896
  const transaction = state.tr;
688
- let sequence = 1;
689
- for (const { node, position } of headings) {
690
- let id = typeof node.attrs.id === "string" ? node.attrs.id : "";
691
- if (!id || /\s/.test(id) || used.has(id)) {
692
- do
693
- id = `looma-heading-${sequence++}`;
694
- while (reserved.has(id) || used.has(id));
695
- transaction.setNodeMarkup(position, void 0, {
696
- ...node.attrs,
697
- id
698
- });
699
- }
700
- used.add(id);
701
- }
897
+ headings.forEach(({ node, position }, index) => {
898
+ if (node.attrs.id === ids[index] || !hasToc && !node.attrs.id) return;
899
+ transaction.setNodeMarkup(position, void 0, {
900
+ ...node.attrs,
901
+ id: ids[index]
902
+ });
903
+ });
702
904
  return transaction.docChanged ? transaction : null;
703
905
  }
704
906
  var LoomaHeadingAnchors = Extension.create({
@@ -714,7 +916,7 @@ var LoomaHeadingAnchors = Extension.create({
714
916
  if (transaction) this.editor.view.dispatch(transaction.setMeta("addToHistory", false));
715
917
  },
716
918
  addProseMirrorPlugins() {
717
- return [new Plugin({ appendTransaction: (transactions, _oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state) : null })];
919
+ return [new Plugin({ appendTransaction: (transactions, oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state, oldState.doc) : null })];
718
920
  }
719
921
  });
720
922
  function contentsHtml(doc, config) {
@@ -9332,7 +9534,7 @@ var TAB_LABEL = /^==[ \t]+(\S.*?)[ \t]*$/;
9332
9534
  var OPEN_FLAG = /[ \t]*\{open\}$/;
9333
9535
  var ALERT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)/i;
9334
9536
  var LEAF = /^::([A-Za-z][\w-]*)(\{.*\})?[ \t]*$/;
9335
- var TASK = /^\[([ xX])\][ \t]+/;
9537
+ var TASK = /^\[([ xX])\](?:[ \t]+|$)/;
9336
9538
  var TOC_FORMATS = /* @__PURE__ */ new Set([
9337
9539
  "plain",
9338
9540
  "bulleted",
@@ -9524,7 +9726,10 @@ function taskLists(state) {
9524
9726
  inline.content = inline.content.replace(TASK, "");
9525
9727
  item.attrSet("data-type", "taskItem");
9526
9728
  item.attrSet("data-checked", String(checked));
9527
- item.meta = { checked };
9729
+ inline.meta = {
9730
+ ...inline.meta,
9731
+ task: checked
9732
+ };
9528
9733
  }
9529
9734
  }
9530
9735
  }
@@ -9566,6 +9771,11 @@ function attributes(state) {
9566
9771
  const token = tokens[index];
9567
9772
  if (token.type !== "inline" || !token.children) continue;
9568
9773
  const children = token.children;
9774
+ if (typeof token.meta?.task === "boolean") {
9775
+ const html = (content) => Object.assign(new state.Token("html_inline", "", 0), { content });
9776
+ children.unshift(html(`<label><input type="checkbox" disabled=""${token.meta.task ? " checked=\"\"" : ""}> `));
9777
+ children.push(html("</label>"));
9778
+ }
9569
9779
  for (let child = children.length - 3; child >= 0; child--) {
9570
9780
  const text = children[child + 1];
9571
9781
  if (children[child].type !== "link_open" || children[child].markup !== "linkify" || /^(?:[a-z][\w+.-]*:|www\.)/i.test(text.content)) continue;
@@ -9642,7 +9852,7 @@ function tableOfContents(tokens, format, depth, escape) {
9642
9852
  * @contract Renders the canonical HTML, which is the HTML LoomaEditor saves. It is not sanitized.
9643
9853
  * Linkify and raw HTML are instance options; `createLoomaMarkdownIt` turns both on.
9644
9854
  */
9645
- function loomaMarkdown$1(md) {
9855
+ function loomaMarkdown$1(md, { codeNotation = "apply" } = {}) {
9646
9856
  const escape = md.utils.escapeHtml;
9647
9857
  md.block.ruler.before("table", "looma_frontmatter", frontmatter);
9648
9858
  md.block.ruler.before("fence", "looma_container", container, { alt: [
@@ -9693,15 +9903,10 @@ function loomaMarkdown$1(md) {
9693
9903
  const id = String(attributes.id);
9694
9904
  return `<span data-type="mention" class="looma-mention" data-id="${escape(id)}" data-label="${escape(label || id)}">@${escape(label || id)}</span>`;
9695
9905
  };
9696
- const listItem = rules.list_item_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options));
9697
- rules.list_item_open = (tokens, index, options, env, self) => {
9698
- const html = listItem(tokens, index, options, env, self);
9699
- const task = tokens[index].meta;
9700
- return task && typeof task.checked === "boolean" ? `${html}<input type="checkbox" disabled=""${task.checked ? " checked=\"\"" : ""}>` : html;
9701
- };
9702
9906
  const code = (content, info) => {
9703
9907
  const { language, meta } = parseLoomaFenceInfo(info);
9704
- return `<pre><code${language ? ` class="language-${escape(language)}"` : ""}${meta ? ` data-meta="${escape(meta)}"` : ""}>${escape(content.replace(/\n$/, ""))}</code></pre>\n`;
9908
+ const source = content.replace(/\n$/, "");
9909
+ return `<pre><code${language ? ` class="language-${escape(language)}"` : ""}${meta ? ` data-meta="${escape(meta)}"` : ""}>${escape(codeNotation === "keep" ? source : applyLoomaCodeNotation(source).code)}</code></pre>\n`;
9705
9910
  };
9706
9911
  rules.fence = (tokens, index) => code(tokens[index].content, tokens[index].info);
9707
9912
  rules.code_block = (tokens, index) => code(tokens[index].content, "");
@@ -9710,17 +9915,17 @@ function loomaMarkdown$1(md) {
9710
9915
  * A markdown-it instance for Looma Markdown: raw HTML and GFM autolinks on, with `loomaMarkdown`.
9711
9916
  * @contract Each call returns a new instance that callers may extend.
9712
9917
  */
9713
- function createLoomaMarkdownIt() {
9918
+ function createLoomaMarkdownIt(options = {}) {
9714
9919
  return new MarkdownIt({
9715
9920
  html: true,
9716
9921
  linkify: true,
9717
9922
  typographer: false
9718
- }).use(loomaMarkdown$1);
9923
+ }).use(loomaMarkdown$1, options);
9719
9924
  }
9720
9925
  //#endregion
9721
9926
  //#region src/editor/extensions/markdown.ts
9722
- /** The markdown-it instance LoomaEditor reads Looma Markdown with. */
9723
- var loomaMarkdown = createLoomaMarkdownIt();
9927
+ /** The markdown-it instance LoomaEditor reads Looma Markdown with; code keeps its notation as source. */
9928
+ var loomaMarkdown = createLoomaMarkdownIt({ codeNotation: "keep" });
9724
9929
  /**
9725
9930
  * Renders Looma Markdown as the HTML the editor parses.
9726
9931
  * @contract The result is not sanitized; pass it to the editor's `setContent`, whose schema
@@ -9729,29 +9934,46 @@ var loomaMarkdown = createLoomaMarkdownIt();
9729
9934
  function loomaMarkdownToHtml(source) {
9730
9935
  return loomaMarkdown.render(source);
9731
9936
  }
9937
+ var CONTAINERS = /* @__PURE__ */ new Set([
9938
+ "loomaCallout",
9939
+ "loomaExpand",
9940
+ "loomaFigure",
9941
+ "loomaTabs"
9942
+ ]);
9732
9943
  var containerDepth = (node) => {
9733
9944
  let depth = 0;
9734
9945
  node.descendants((child) => {
9735
- if (child.type.name === "loomaCallout" || child.type.name === "loomaExpand") depth = Math.max(depth, 1 + containerDepth(child));
9946
+ if (!CONTAINERS.has(child.type.name)) return true;
9947
+ depth = Math.max(depth, 1 + containerDepth(child));
9736
9948
  return false;
9737
9949
  });
9738
9950
  return depth;
9739
9951
  };
9740
- function writeContainer(state, node, opening) {
9952
+ var endLine = (state) => state.flushClose(1);
9953
+ function writeContainer(state, node, opening, content = () => state.renderContent(node)) {
9741
9954
  const marker = loomaContainerMarker(containerDepth(node));
9742
9955
  state.write(`${marker} ${opening}`.trimEnd());
9743
9956
  state.ensureNewLine();
9744
- state.renderContent(node);
9745
- state.flushClose(1);
9957
+ content();
9958
+ endLine(state);
9746
9959
  state.write(marker);
9747
9960
  state.closeBlock(node);
9748
9961
  }
9749
9962
  var defaults = defaultMarkdownSerializer.nodes;
9963
+ var label = (text) => text.replace(/[\\\]]/g, "\\$&");
9964
+ var ALIGNMENT = {
9965
+ left: ":---",
9966
+ center: ":---:",
9967
+ right: "---:"
9968
+ };
9750
9969
  var NODES = {
9751
9970
  paragraph: defaults.paragraph,
9752
- text: defaults.text,
9971
+ text: (state, node, parent, index) => {
9972
+ const marker = /^:{3,}[ \t]*([A-Za-z][\w-]*)?/.exec(node.text ?? "");
9973
+ if ((marker ? !marker[1] || LOOMA_CONTAINER_NAMES.has(marker[1].toLowerCase()) : /^::toc(?![\w-])/i.test(node.text ?? "")) && state.atBlank()) state.write("\\");
9974
+ defaults.text(state, node, parent, index);
9975
+ },
9753
9976
  blockquote: defaults.blockquote,
9754
- heading: defaults.heading,
9755
9977
  horizontalRule: defaults.horizontal_rule,
9756
9978
  bulletList: (state, node) => state.renderList(node, " ", () => "- "),
9757
9979
  orderedList: (state, node) => {
@@ -9759,17 +9981,82 @@ var NODES = {
9759
9981
  state.renderList(node, " ", (index) => `${start + index}. `);
9760
9982
  },
9761
9983
  listItem: defaults.list_item,
9984
+ taskList: (state, node) => state.renderList(node, " ", () => "- "),
9985
+ taskItem: (state, node) => {
9986
+ state.write(node.attrs.checked ? "[x] " : "[ ] ");
9987
+ state.renderContent(node);
9988
+ },
9762
9989
  hardBreak: defaults.hard_break,
9763
9990
  codeBlock: (state, node) => {
9764
9991
  const fence = loomaCodeFence(node.textContent);
9765
- state.write(`${fence}${node.attrs.language ?? ""}\n`);
9992
+ state.write(`${fence}${node.attrs.language ?? ""}${node.attrs.meta ? ` ${node.attrs.meta}` : ""}\n`);
9766
9993
  state.text(node.textContent, false);
9767
9994
  state.ensureNewLine();
9768
9995
  state.write(fence);
9769
9996
  state.closeBlock(node);
9770
9997
  },
9998
+ image: (state, node, parent, index) => {
9999
+ defaults.image(state, node, parent, index);
10000
+ const { width, height, placement, responsive } = node.attrs;
10001
+ state.write(formatLoomaAttributes({
10002
+ width,
10003
+ height,
10004
+ placement: placement && placement !== "block" ? placement : null,
10005
+ responsive: responsive === true
10006
+ }));
10007
+ if (node.isBlock) state.closeBlock(node);
10008
+ },
9771
10009
  loomaCallout: (state, node) => writeContainer(state, node, `${node.attrs.tone} ${node.attrs.heading ?? ""}`),
9772
- loomaExpand: (state, node) => writeContainer(state, node, `details ${node.attrs.summary}${node.attrs.open ? " {open}" : ""}`)
10010
+ loomaExpand: (state, node) => writeContainer(state, node, `details ${node.attrs.summary}${node.attrs.open ? " {open}" : ""}`),
10011
+ loomaFigure: (state, node) => {
10012
+ const caption = node.lastChild;
10013
+ const marker = loomaContainerMarker(containerDepth(node));
10014
+ state.write(`${marker} figure`);
10015
+ if (caption.content.size) {
10016
+ state.write(" ");
10017
+ state.renderInline(caption, false);
10018
+ }
10019
+ state.ensureNewLine();
10020
+ state.render(node.firstChild, node, 0);
10021
+ endLine(state);
10022
+ state.write(marker);
10023
+ state.closeBlock(node);
10024
+ },
10025
+ loomaFigcaption: (state, node) => state.renderInline(node),
10026
+ loomaTabs: (state, node) => {
10027
+ const name = node.attrs.codeGroup ? "code-group" : "tabs";
10028
+ const bare = node.attrs.codeGroup && Array.from({ length: node.childCount }, (_, index) => node.child(index)).every((tab) => {
10029
+ const code = tab.firstChild;
10030
+ if (tab.childCount !== 1 || code?.type.name !== "codeBlock") return false;
10031
+ const { title, language } = parseLoomaFenceInfo(`${code.attrs.language ?? ""} ${code.attrs.meta ?? ""}`);
10032
+ return tab.attrs.label === (title ?? language ?? "Code");
10033
+ });
10034
+ writeContainer(state, node, `${name} ${node.attrs.group && node.attrs.group !== name ? node.attrs.group : ""}`, () => node.forEach((tab, _offset, index) => {
10035
+ if (index) endLine(state);
10036
+ if (!bare) {
10037
+ state.write(`== ${tab.attrs.label}`);
10038
+ state.ensureNewLine();
10039
+ }
10040
+ state.renderContent(tab);
10041
+ }));
10042
+ },
10043
+ loomaTab: (state, node) => state.renderContent(node),
10044
+ loomaTableOfContents: (state, node) => {
10045
+ const { format, depth } = node.attrs;
10046
+ state.write(`::toc${formatLoomaAttributes({
10047
+ depth: depth !== 3 ? depth : null,
10048
+ format: format !== "plain" ? format : null
10049
+ })}`);
10050
+ state.closeBlock(node);
10051
+ },
10052
+ loomaChip: (state, node) => {
10053
+ const tone = normalizeLoomaChipTone(node.attrs.color);
10054
+ state.write(`:chip[${label(String(node.attrs.label ?? ""))}]${formatLoomaAttributes({ tone: tone !== "neutral" ? tone : null })}`);
10055
+ },
10056
+ mention: (state, node) => {
10057
+ const id = String(node.attrs.id ?? "");
10058
+ state.write(`:mention[${label(String(node.attrs.label ?? id))}]${formatLoomaAttributes({ id }, { hashId: false })}`);
10059
+ }
9773
10060
  };
9774
10061
  var MARKS = {
9775
10062
  bold: defaultMarkdownSerializer.marks.strong,
@@ -9793,31 +10080,96 @@ var MARKS = {
9793
10080
  mixable: true
9794
10081
  }
9795
10082
  };
10083
+ function customHeadingIds(doc) {
10084
+ const headings = [];
10085
+ doc.descendants((node) => {
10086
+ if (node.type.name === "heading") headings.push({
10087
+ text: node.textContent.trim(),
10088
+ id: typeof node.attrs.id === "string" && node.attrs.id ? node.attrs.id : void 0
10089
+ });
10090
+ });
10091
+ const marked = /* @__PURE__ */ new Set();
10092
+ for (;;) {
10093
+ const derived = assignLoomaHeadingIds(headings.map(({ text, id }, index) => ({
10094
+ text,
10095
+ id: marked.has(index) ? id : void 0
10096
+ })));
10097
+ const next = headings.findIndex(({ id }, index) => id && !marked.has(index) && derived[index] !== id);
10098
+ if (next < 0) return marked;
10099
+ marked.add(next);
10100
+ }
10101
+ }
9796
10102
  /**
9797
- * Writes an editor document as Looma Markdown, with callouts and expand sections as `:::`
9798
- * containers that `loomaMarkdownToHtml` and Markupress read.
9799
- * @contract Content Markdown has no syntax for (tables, task lists, images, chips, mentions,
9800
- * the table of contents) is written as its saved HTML, which Markdown carries unchanged.
9801
- * Marks without a Markdown form other than underline and highlight are dropped.
10103
+ * Writes an editor document as Looma Markdown, the form `loomaMarkdownToHtml` and Markupress read.
10104
+ * @contract Every node in the default preset has a Markdown form. A table that fits GFM is a GFM
10105
+ * table and any other is HTML. Content from extensions outside the preset is written as its saved
10106
+ * HTML, and marks without a Markdown form other than underline and highlight are dropped.
9802
10107
  */
9803
10108
  function loomaMarkdownFromDoc(doc) {
9804
- const dom = DOMSerializer.fromSchema(doc.type.schema);
10109
+ const schema = doc.type.schema;
10110
+ const dom = DOMSerializer.fromSchema(schema);
9805
10111
  const html = (node) => {
9806
10112
  const holder = document.createElement("div");
9807
10113
  holder.append(dom.serializeNode(node));
9808
10114
  return holder.innerHTML;
9809
10115
  };
9810
- const nodes = {};
9811
- for (const name of Object.keys(doc.type.schema.nodes)) nodes[name] = NODES[name] ?? ((state, node) => {
10116
+ const custom = customHeadingIds(doc);
10117
+ let heading = 0;
10118
+ const nodes = {
10119
+ heading: (state, node) => {
10120
+ state.write(`${"#".repeat(node.attrs.level)} `);
10121
+ state.renderInline(node, false);
10122
+ if (custom.has(heading++)) state.write(` {#${node.attrs.id}}`);
10123
+ else {
10124
+ const out = state;
10125
+ out.out = out.out.replace(/([ \t])\{([^{}]*\})$/, "$1\\{$2");
10126
+ }
10127
+ state.closeBlock(node);
10128
+ },
10129
+ table: (state, node) => {
10130
+ state.write(gfmTable(node) ?? `<table><tbody>${Array.from({ length: node.childCount }, (_, index) => html(node.child(index))).join("")}</tbody></table>`.replace(/ (?:colspan|rowspan)="1"/g, ""));
10131
+ state.closeBlock(node);
10132
+ }
10133
+ };
10134
+ for (const name of Object.keys(schema.nodes)) nodes[name] ??= NODES[name] ?? ((state, node) => {
9812
10135
  state.write(html(node));
9813
10136
  if (node.isBlock) state.closeBlock(node);
9814
10137
  });
9815
10138
  const marks = {};
9816
- for (const name of Object.keys(doc.type.schema.marks)) marks[name] = MARKS[name] ?? {
10139
+ for (const name of Object.keys(schema.marks)) marks[name] = MARKS[name] ?? {
9817
10140
  open: "",
9818
10141
  close: ""
9819
10142
  };
9820
- return new MarkdownSerializer(nodes, marks).serialize(doc, { tightLists: true });
10143
+ const serializer = new MarkdownSerializer(nodes, marks);
10144
+ const inline = (paragraph) => serializer.serialize(schema.topNodeType.create(null, paragraph)).replace(/\|/g, "\\|");
10145
+ function gfmTable(table) {
10146
+ const rows = Array.from({ length: table.childCount }, (_, index) => table.child(index));
10147
+ const width = rows[0]?.childCount ?? 0;
10148
+ const alignments = [];
10149
+ const lines = [];
10150
+ for (const [rowIndex, row] of rows.entries()) {
10151
+ if (row.childCount !== width) return null;
10152
+ const cells = [];
10153
+ for (let column = 0; column < width; column++) {
10154
+ const cell = row.child(column);
10155
+ const { colspan, rowspan, colwidth, backgroundColor, textAlign } = cell.attrs;
10156
+ const paragraph = cell.firstChild;
10157
+ if (cell.type.name === "tableHeader" !== (rowIndex === 0) || colspan !== 1 || rowspan !== 1 || colwidth || backgroundColor || cell.childCount !== 1 || paragraph?.type.name !== "paragraph") return null;
10158
+ let hasBreak = false;
10159
+ paragraph.descendants((child) => {
10160
+ if (child.type.name === "hardBreak") hasBreak = true;
10161
+ });
10162
+ if (hasBreak) return null;
10163
+ if (rowIndex === 0) alignments.push(textAlign ?? null);
10164
+ else if ((textAlign ?? null) !== alignments[column]) return null;
10165
+ cells.push(inline(paragraph));
10166
+ }
10167
+ lines.push(`| ${cells.join(" | ")} |`);
10168
+ if (rowIndex === 0) lines.push(`| ${alignments.map((alignment) => ALIGNMENT[alignment] ?? "---").join(" | ")} |`);
10169
+ }
10170
+ return width ? lines.join("\n") : null;
10171
+ }
10172
+ return serializer.serialize(doc, { tightLists: true });
9821
10173
  }
9822
10174
  //#endregion
9823
10175
  //#region src/editor/extensions/smart-paste.ts
@@ -9893,8 +10245,12 @@ function parseHtmlSlice(schema, value, fromHtml = false) {
9893
10245
  "template"
9894
10246
  ],
9895
10247
  FORBID_ATTR: ["style"],
9896
- ADD_TAGS: ["ui-callout"],
9897
- ADD_ATTR: ["tone", "heading"],
10248
+ ADD_TAGS: ["ui-callout", "ui-tabs"],
10249
+ ADD_ATTR: [
10250
+ "tone",
10251
+ "heading",
10252
+ "group"
10253
+ ],
9898
10254
  RETURN_DOM_FRAGMENT: true
9899
10255
  }));
9900
10256
  if (!container.textContent?.trim() && !container.querySelector("img, hr")) return Slice.empty;
@@ -10438,6 +10794,16 @@ function setActiveTableCellBackground(editor, backgroundColor) {
10438
10794
  * Uses the Vanilla JS Tiptap API; apps provide @tiptap/core and Looma ships the preset extensions.
10439
10795
  */
10440
10796
  var LoomaCodeBlock = CodeBlockLowlight.extend({
10797
+ addAttributes() {
10798
+ return {
10799
+ ...this.parent?.(),
10800
+ meta: {
10801
+ default: null,
10802
+ parseHTML: (element) => element.querySelector("code")?.getAttribute("data-meta") || element.getAttribute("data-meta") || null,
10803
+ renderHTML: () => ({})
10804
+ }
10805
+ };
10806
+ },
10441
10807
  renderHTML({ node, HTMLAttributes }) {
10442
10808
  return [
10443
10809
  "pre",
@@ -10446,6 +10812,7 @@ var LoomaCodeBlock = CodeBlockLowlight.extend({
10446
10812
  "code",
10447
10813
  {
10448
10814
  class: node.attrs.language ? `${this.options.languageClassPrefix}${node.attrs.language}` : null,
10815
+ "data-meta": node.attrs.meta || null,
10449
10816
  spellcheck: "false"
10450
10817
  },
10451
10818
  0
@@ -10521,7 +10888,10 @@ function getDefaultEditorExtensions(options = {}) {
10521
10888
  Heading.configure({ levels: [
10522
10889
  1,
10523
10890
  2,
10524
- 3
10891
+ 3,
10892
+ 4,
10893
+ 5,
10894
+ 6
10525
10895
  ] }),
10526
10896
  BulletList,
10527
10897
  OrderedList,
@@ -10531,6 +10901,9 @@ function getDefaultEditorExtensions(options = {}) {
10531
10901
  Blockquote,
10532
10902
  calloutNodeView ? LoomaCallout.extend({ addNodeView: () => calloutNodeView }) : LoomaCallout,
10533
10903
  LoomaChip,
10904
+ LoomaTabs,
10905
+ LoomaTab,
10906
+ ...imageInline ? [] : [LoomaFigure, LoomaFigcaption],
10534
10907
  LoomaExpand.configure({ nodeView: expandNodeView ?? null }),
10535
10908
  LoomaTableOfContents.configure({ nodeView: tableOfContentsNodeView ?? null }),
10536
10909
  dividerNodeView ? HorizontalRule.extend({ addNodeView: () => dividerNodeView }) : HorizontalRule,
@@ -11522,4 +11895,4 @@ function createLoomaSlashCommandExtension(options = {}) {
11522
11895
  });
11523
11896
  }
11524
11897
  //#endregion
11525
- 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, LoomaCallout as U, LoomaChip as V, LOOMA_CALLOUT_TONES 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 };
11898
+ 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 };
@@ -0,0 +1,12 @@
1
+ import { Node } from "@tiptap/core";
2
+ /**
3
+ * An image with a caption, saved as `<figure><img><figcaption>` and written in Looma Markdown as
4
+ * `::: figure Caption`. The caption is inline content the author edits in place.
5
+ * @invariant A figure holds exactly one block image and one caption; an empty caption stays empty.
6
+ */
7
+ export declare const LoomaFigure: Node<any, any>;
8
+ /**
9
+ * A figure's caption: inline content under its image.
10
+ * @invariant It exists only as the second child of `LoomaFigure`.
11
+ */
12
+ export declare const LoomaFigcaption: Node<any, any>;
@@ -10,6 +10,8 @@ export { LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, LOOMA_ACTIVE_BLOCK_CLASS, LOOMA_ACTIV
10
10
  export { LoomaCallout, LOOMA_CALLOUT_TONES, type LoomaCalloutTone, } from "./callout.js";
11
11
  export { LoomaChip, LOOMA_CHIP_COLORS, normalizeLoomaChipColor, type LoomaChipColor } from "./chip.js";
12
12
  export { LoomaExpand, revealLoomaExpandAt, type LoomaExpandAttributes } from "./expand.js";
13
+ export { LoomaFigcaption, LoomaFigure } from "./figure.js";
14
+ export { LoomaTab, LoomaTabs } from "./tabs.js";
13
15
  export { LoomaTableOfContents, getTableOfContentsEntries, normalizeTableOfContentsConfig, type LoomaTableOfContentsConfig, type LoomaTableOfContentsEntry, type LoomaTableOfContentsFormat, } from "./table-of-contents.js";
14
16
  export { getDefaultEditorExtensions, getLoomaTableExtensions, LoomaTableKit, type DefaultEditorExtensionsOptions, type LoomaCodeLanguages, } from "./preset.js";
15
17
  export { LoomaSmartPaste } from "./smart-paste.js";
@@ -1,3 +1,3 @@
1
1
  import { n as TABLE_CELL_BACKGROUND_PRESETS } from "../chunks/table-backgrounds.js";
2
- import { A as filterLoomaMentionItems, B as LOOMA_CHIP_COLORS, C as loomaMarkdownToHtml, D as createLoomaMentionExtension, E as LoomaMentionSuggestionPluginKey, F as LoomaTableOfContents, G as LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, H as normalizeLoomaChipColor, I as getTableOfContentsEntries, J as LoomaActiveBlock, K as LOOMA_ACTIVE_BLOCK_CLASS, L as normalizeTableOfContentsConfig, M as LoomaImage, N as normalizeImageDimension, O as DEFAULT_MENTION_RESULT_LIMIT, P as normalizeImagePlacement, R as LoomaExpand, S as loomaMarkdownFromDoc, T as siteRelativeHref, U as LoomaCallout, V as LoomaChip, W as LOOMA_CALLOUT_TONES, Y as LoomaActiveBlockPluginKey, _ as getActiveTableCellAlignment, a as getActiveTableUiState, b as setActiveTableCellBackground, c as insertTableAtRange, d as LoomaTableKit, f as getDefaultEditorExtensions, g as LoomaTableHeader, h as LoomaTableCell, i as getDefaultSlashCommands, j as normalizeMentionResultLimit, k as MAX_MENTION_RESULT_LIMIT, l as normalizeActiveTableColumnWidths, m as LoomaTable, n as createLoomaSlashCommandExtension, o as handleTableAction, p as getLoomaTableExtensions, q as LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, r as filterLoomaSlashCommands, s as handleTableOverlayAction, t as LoomaSlashCommand, u as shouldShowTextFormattingToolbar, v as getActiveTableCellBackground, w as createSiteRelativeLinks, x as LoomaSmartPaste, y as setActiveTableCellAlignment, z as revealLoomaExpandAt } from "../chunks/extensions.js";
3
- export { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, LOOMA_ACTIVE_BLOCK_CLASS, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, LOOMA_CALLOUT_TONES, LOOMA_CHIP_COLORS, LoomaActiveBlock, LoomaActiveBlockPluginKey, LoomaCallout, LoomaChip, LoomaExpand, LoomaImage, LoomaMentionSuggestionPluginKey, LoomaSlashCommand, LoomaSmartPaste, LoomaTable, LoomaTableCell, LoomaTableHeader, LoomaTableKit, LoomaTableOfContents, MAX_MENTION_RESULT_LIMIT, TABLE_CELL_BACKGROUND_PRESETS, createLoomaMentionExtension, createLoomaSlashCommandExtension, createSiteRelativeLinks, filterLoomaMentionItems, filterLoomaSlashCommands, getActiveTableCellAlignment, getActiveTableCellBackground, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getLoomaTableExtensions, getTableOfContentsEntries, handleTableAction, handleTableOverlayAction, insertTableAtRange, loomaMarkdownFromDoc, loomaMarkdownToHtml, normalizeActiveTableColumnWidths, normalizeImageDimension, normalizeImagePlacement, normalizeLoomaChipColor, normalizeMentionResultLimit, normalizeTableOfContentsConfig, revealLoomaExpandAt, setActiveTableCellAlignment, setActiveTableCellBackground, shouldShowTextFormattingToolbar, siteRelativeHref };
2
+ import { $ as LoomaActiveBlockPluginKey, A as filterLoomaMentionItems, B as LoomaFigcaption, C as loomaMarkdownToHtml, D as createLoomaMentionExtension, E as LoomaMentionSuggestionPluginKey, F as LoomaTableOfContents, G as LoomaChip, H as LoomaExpand, I as getTableOfContentsEntries, J as LOOMA_CALLOUT_TONES, K as normalizeLoomaChipColor, L as normalizeTableOfContentsConfig, M as LoomaImage, N as normalizeImageDimension, O as DEFAULT_MENTION_RESULT_LIMIT, P as normalizeImagePlacement, Q as LoomaActiveBlock, R as LoomaTab, S as loomaMarkdownFromDoc, T as siteRelativeHref, U as revealLoomaExpandAt, V as LoomaFigure, W as LOOMA_CHIP_COLORS, X as LOOMA_ACTIVE_BLOCK_CLASS, Y as LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, Z as LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, _ as getActiveTableCellAlignment, a as getActiveTableUiState, b as setActiveTableCellBackground, c as insertTableAtRange, d as LoomaTableKit, f as getDefaultEditorExtensions, g as LoomaTableHeader, h as LoomaTableCell, i as getDefaultSlashCommands, j as normalizeMentionResultLimit, k as MAX_MENTION_RESULT_LIMIT, l as normalizeActiveTableColumnWidths, m as LoomaTable, n as createLoomaSlashCommandExtension, o as handleTableAction, p as getLoomaTableExtensions, q as LoomaCallout, r as filterLoomaSlashCommands, s as handleTableOverlayAction, t as LoomaSlashCommand, u as shouldShowTextFormattingToolbar, v as getActiveTableCellBackground, w as createSiteRelativeLinks, x as LoomaSmartPaste, y as setActiveTableCellAlignment, z as LoomaTabs } from "../chunks/extensions.js";
3
+ export { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, LOOMA_ACTIVE_BLOCK_CLASS, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, LOOMA_CALLOUT_TONES, LOOMA_CHIP_COLORS, LoomaActiveBlock, LoomaActiveBlockPluginKey, LoomaCallout, LoomaChip, LoomaExpand, LoomaFigcaption, LoomaFigure, LoomaImage, LoomaMentionSuggestionPluginKey, LoomaSlashCommand, LoomaSmartPaste, LoomaTab, LoomaTable, LoomaTableCell, LoomaTableHeader, LoomaTableKit, LoomaTableOfContents, LoomaTabs, MAX_MENTION_RESULT_LIMIT, TABLE_CELL_BACKGROUND_PRESETS, createLoomaMentionExtension, createLoomaSlashCommandExtension, createSiteRelativeLinks, filterLoomaMentionItems, filterLoomaSlashCommands, getActiveTableCellAlignment, getActiveTableCellBackground, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getLoomaTableExtensions, getTableOfContentsEntries, handleTableAction, handleTableOverlayAction, insertTableAtRange, loomaMarkdownFromDoc, loomaMarkdownToHtml, normalizeActiveTableColumnWidths, normalizeImageDimension, normalizeImagePlacement, normalizeLoomaChipColor, normalizeMentionResultLimit, normalizeTableOfContentsConfig, revealLoomaExpandAt, setActiveTableCellAlignment, setActiveTableCellBackground, shouldShowTextFormattingToolbar, siteRelativeHref };
@@ -1,5 +1,5 @@
1
1
  import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
2
- /** The markdown-it instance LoomaEditor reads Looma Markdown with. */
2
+ /** The markdown-it instance LoomaEditor reads Looma Markdown with; code keeps its notation as source. */
3
3
  export declare const loomaMarkdown: import("markdown-it").default;
4
4
  /**
5
5
  * Renders Looma Markdown as the HTML the editor parses.
@@ -8,10 +8,9 @@ export declare const loomaMarkdown: import("markdown-it").default;
8
8
  */
9
9
  export declare function loomaMarkdownToHtml(source: string): string;
10
10
  /**
11
- * Writes an editor document as Looma Markdown, with callouts and expand sections as `:::`
12
- * containers that `loomaMarkdownToHtml` and Markupress read.
13
- * @contract Content Markdown has no syntax for (tables, task lists, images, chips, mentions,
14
- * the table of contents) is written as its saved HTML, which Markdown carries unchanged.
15
- * Marks without a Markdown form other than underline and highlight are dropped.
11
+ * Writes an editor document as Looma Markdown, the form `loomaMarkdownToHtml` and Markupress read.
12
+ * @contract Every node in the default preset has a Markdown form. A table that fits GFM is a GFM
13
+ * table and any other is HTML. Content from extensions outside the preset is written as its saved
14
+ * HTML, and marks without a Markdown form other than underline and highlight are dropped.
16
15
  */
17
16
  export declare function loomaMarkdownFromDoc(doc: ProseMirrorNode): string;
@@ -10,7 +10,7 @@ export interface LoomaTableOfContentsConfig {
10
10
  /**
11
11
  * One current heading for navigation or host projections.
12
12
  * @lifecycle Positions belong to the supplied document; recompute after edits.
13
- * IDs stay with headings across renames and moves once a TOC assigns anchors.
13
+ * An ID derived from a heading's text follows the text; a custom ID stays with its heading.
14
14
  */
15
15
  export interface LoomaTableOfContentsEntry {
16
16
  id: string;
@@ -0,0 +1,13 @@
1
+ import { Node } from "@tiptap/core";
2
+ /**
3
+ * Tabs or a code group, kept whole so Looma Markdown's `::: tabs` and `::: code-group` survive
4
+ * editing. Saved as `<ui-tabs group>` with a `<section aria-label>` per panel.
5
+ * @contract While editing, panels show one after another under their labels; the Tabs component
6
+ * draws them only where the saved HTML renders.
7
+ */
8
+ export declare const LoomaTabs: Node<any, any>;
9
+ /**
10
+ * One panel of `LoomaTabs`, labelled by `label`.
11
+ * @invariant It exists only inside `LoomaTabs`; its label is the tab Tabs shows and the `== Label` line Markdown writes.
12
+ */
13
+ export declare const LoomaTab: Node<any, any>;
package/editor/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { n as TABLE_CELL_BACKGROUND_PRESETS, t as TABLE_CELL_BACKGROUND_OPTIONS } from "./chunks/table-backgrounds.js";
2
2
  import { n as resolveTableCellAt, t as measureTableOverlayGeometry } from "./chunks/ui.js";
3
- import { A as filterLoomaMentionItems, B as LOOMA_CHIP_COLORS, C as loomaMarkdownToHtml, D as createLoomaMentionExtension, E as LoomaMentionSuggestionPluginKey, F as LoomaTableOfContents, G as LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, H as normalizeLoomaChipColor, I as getTableOfContentsEntries, J as LoomaActiveBlock, K as LOOMA_ACTIVE_BLOCK_CLASS, L as normalizeTableOfContentsConfig, M as LoomaImage, N as normalizeImageDimension, O as DEFAULT_MENTION_RESULT_LIMIT, P as normalizeImagePlacement, R as LoomaExpand, S as loomaMarkdownFromDoc, T as siteRelativeHref, U as LoomaCallout, V as LoomaChip, W as LOOMA_CALLOUT_TONES, Y as LoomaActiveBlockPluginKey, _ as getActiveTableCellAlignment, a as getActiveTableUiState, b as setActiveTableCellBackground, c as insertTableAtRange, d as LoomaTableKit, f as getDefaultEditorExtensions, g as LoomaTableHeader, h as LoomaTableCell, i as getDefaultSlashCommands, j as normalizeMentionResultLimit, k as MAX_MENTION_RESULT_LIMIT, l as normalizeActiveTableColumnWidths, m as LoomaTable, n as createLoomaSlashCommandExtension, o as handleTableAction, p as getLoomaTableExtensions, q as LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, r as filterLoomaSlashCommands, s as handleTableOverlayAction, t as LoomaSlashCommand, u as shouldShowTextFormattingToolbar, v as getActiveTableCellBackground, w as createSiteRelativeLinks, x as LoomaSmartPaste, y as setActiveTableCellAlignment, z as revealLoomaExpandAt } from "./chunks/extensions.js";
3
+ import { $ as LoomaActiveBlockPluginKey, A as filterLoomaMentionItems, B as LoomaFigcaption, C as loomaMarkdownToHtml, D as createLoomaMentionExtension, E as LoomaMentionSuggestionPluginKey, F as LoomaTableOfContents, G as LoomaChip, H as LoomaExpand, I as getTableOfContentsEntries, J as LOOMA_CALLOUT_TONES, K as normalizeLoomaChipColor, L as normalizeTableOfContentsConfig, M as LoomaImage, N as normalizeImageDimension, O as DEFAULT_MENTION_RESULT_LIMIT, P as normalizeImagePlacement, Q as LoomaActiveBlock, R as LoomaTab, S as loomaMarkdownFromDoc, T as siteRelativeHref, U as revealLoomaExpandAt, V as LoomaFigure, W as LOOMA_CHIP_COLORS, X as LOOMA_ACTIVE_BLOCK_CLASS, Y as LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, Z as LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, _ as getActiveTableCellAlignment, a as getActiveTableUiState, b as setActiveTableCellBackground, c as insertTableAtRange, d as LoomaTableKit, f as getDefaultEditorExtensions, g as LoomaTableHeader, h as LoomaTableCell, i as getDefaultSlashCommands, j as normalizeMentionResultLimit, k as MAX_MENTION_RESULT_LIMIT, l as normalizeActiveTableColumnWidths, m as LoomaTable, n as createLoomaSlashCommandExtension, o as handleTableAction, p as getLoomaTableExtensions, q as LoomaCallout, r as filterLoomaSlashCommands, s as handleTableOverlayAction, t as LoomaSlashCommand, u as shouldShowTextFormattingToolbar, v as getActiveTableCellBackground, w as createSiteRelativeLinks, x as LoomaSmartPaste, y as setActiveTableCellAlignment, z as LoomaTabs } from "./chunks/extensions.js";
4
4
  import { AlignCenter, AlignLeft, AlignRight, ArrowLeft, Bell, Bold, BookUser, Braces, Calculator, Check, ChevronDown, ChevronLeft, ChevronRight, CircleCheck, CircleDot, CircleQuestionMark, CircleX, CodeXml, Columns3, Compass, CreditCard, Ellipsis, Eraser, ExternalLink, FileText, Folder, GripHorizontal, GripVertical, Heading1, Heading2, Heading3, Highlighter, History, Image, Info, Italic, LayoutDashboard, Lightbulb, Link, List, ListOrdered, ListTodo, LoaderCircle, Merge, MessageSquareWarning, Minus, NotebookPen, PaintBucket, PanelBottom, PanelLeft, PanelRight, PanelTop, Pencil, Pilcrow, Plus, Quote, Receipt, Redo2, Rows3, Search, Settings, Split, Strikethrough, Table2, Tag, Trash2, TriangleAlert, Truck, Underline, Undo2, Unlink, UserRound, Users, X } from "lucide";
5
5
  //#region src/editor/icons.ts
6
6
  /** The opinionated Lucide set used by Looma's shipped interaction surfaces. */
@@ -123,4 +123,4 @@ function getVisualViewportRect(owner = window) {
123
123
  };
124
124
  }
125
125
  //#endregion
126
- export { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, LOOMA_ACTIVE_BLOCK_CLASS, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, LOOMA_CALLOUT_TONES, LOOMA_CHIP_COLORS, LOOMA_ICONS, LoomaActiveBlock, LoomaActiveBlockPluginKey, LoomaCallout, LoomaChip, LoomaExpand, LoomaImage, LoomaMentionSuggestionPluginKey, LoomaSlashCommand, LoomaSmartPaste, LoomaTable, LoomaTableCell, LoomaTableHeader, LoomaTableKit, LoomaTableOfContents, MAX_MENTION_RESULT_LIMIT, TABLE_CELL_BACKGROUND_OPTIONS, TABLE_CELL_BACKGROUND_PRESETS, createLoomaMentionExtension, createLoomaSlashCommandExtension, createSiteRelativeLinks, filterLoomaMentionItems, filterLoomaSlashCommands, getActiveTableCellAlignment, getActiveTableCellBackground, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getLoomaTableExtensions, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, insertTableAtRange, loomaIconMarkup, loomaMarkdownFromDoc, loomaMarkdownToHtml, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeImageDimension, normalizeImagePlacement, normalizeLoomaChipColor, normalizeMentionResultLimit, normalizeTableOfContentsConfig, resolveTableCellAt, revealLoomaExpandAt, setActiveTableCellAlignment, setActiveTableCellBackground, shouldShowTextFormattingToolbar, siteRelativeHref };
126
+ export { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS, LOOMA_ACTIVE_BLOCK_CLASS, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, LOOMA_CALLOUT_TONES, LOOMA_CHIP_COLORS, LOOMA_ICONS, LoomaActiveBlock, LoomaActiveBlockPluginKey, LoomaCallout, LoomaChip, LoomaExpand, LoomaFigcaption, LoomaFigure, LoomaImage, LoomaMentionSuggestionPluginKey, LoomaSlashCommand, LoomaSmartPaste, LoomaTab, LoomaTable, LoomaTableCell, LoomaTableHeader, LoomaTableKit, LoomaTableOfContents, LoomaTabs, MAX_MENTION_RESULT_LIMIT, TABLE_CELL_BACKGROUND_OPTIONS, TABLE_CELL_BACKGROUND_PRESETS, createLoomaMentionExtension, createLoomaSlashCommandExtension, createSiteRelativeLinks, filterLoomaMentionItems, filterLoomaSlashCommands, getActiveTableCellAlignment, getActiveTableCellBackground, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getLoomaTableExtensions, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, insertTableAtRange, loomaIconMarkup, loomaMarkdownFromDoc, loomaMarkdownToHtml, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeImageDimension, normalizeImagePlacement, normalizeLoomaChipColor, normalizeMentionResultLimit, normalizeTableOfContentsConfig, resolveTableCellAt, revealLoomaExpandAt, setActiveTableCellAlignment, setActiveTableCellBackground, shouldShowTextFormattingToolbar, siteRelativeHref };
@@ -14,6 +14,8 @@ var files = /* #__PURE__ */ Object.assign({
14
14
  "../../markdown-fixtures/callouts/open-in-title.md": "::: tip Keep {open}\nA callout title keeps its braces.\n:::\n",
15
15
  "../../markdown-fixtures/callouts/titled.html": "<ui-callout tone=\"tip\" heading=\"Before you start\">\n<p>Install <strong>first</strong>.</p>\n</ui-callout>\n",
16
16
  "../../markdown-fixtures/callouts/titled.md": "::: tip Before you start\nInstall **first**.\n:::\n",
17
+ "../../markdown-fixtures/chips/escaped-label.html": "<p><span data-looma-chip data-label=\"Draft ] v2\" data-color=\"red\">Draft ] v2</span></p>\n",
18
+ "../../markdown-fixtures/chips/escaped-label.md": ":chip[Draft \\] v2]{tone=red}\n",
17
19
  "../../markdown-fixtures/chips/tones.html": "<p>Status <span data-looma-chip data-label=\"Ready\" data-color=\"green\">Ready</span> and <span data-looma-chip data-label=\"Draft\" data-color=\"neutral\">Draft</span>.</p>\n",
18
20
  "../../markdown-fixtures/chips/tones.md": "Status :chip[Ready]{tone=green} and :chip[Draft].\n",
19
21
  "../../markdown-fixtures/code-blocks/fences.canonical.md": "```ts\nconst answer = 42;\n```\n\n```\nplain text\n```\n\n```\nindented\n```\n",
@@ -21,10 +23,14 @@ var files = /* #__PURE__ */ Object.assign({
21
23
  "../../markdown-fixtures/code-blocks/fences.md": "```ts\nconst answer = 42;\n```\n\n```\nplain text\n```\n\n indented\n",
22
24
  "../../markdown-fixtures/code-blocks/long-fence.html": "<pre><code class=\"language-md\">```ts\nconst a = 1;\n```</code></pre>\n",
23
25
  "../../markdown-fixtures/code-blocks/long-fence.md": "````md\n```ts\nconst a = 1;\n```\n````\n",
24
- "../../markdown-fixtures/code-meta/notation.html": "<pre><code class=\"language-ts\">const port = 8080; // [!code highlight]</code></pre>\n",
26
+ "../../markdown-fixtures/code-meta/notation-alone.html": "<pre><code class=\"language-ts\">const host = &quot;localhost&quot;;\nconst port = 8080;</code></pre>\n",
27
+ "../../markdown-fixtures/code-meta/notation-alone.md": "```ts\n// [!code highlight:2]\nconst host = \"localhost\";\nconst port = 8080;\n```\n",
28
+ "../../markdown-fixtures/code-meta/notation.html": "<pre><code class=\"language-ts\">const port = 8080;</code></pre>\n",
25
29
  "../../markdown-fixtures/code-meta/notation.md": "```ts\nconst port = 8080; // [!code highlight]\n```\n",
26
30
  "../../markdown-fixtures/code-meta/title-lines.html": "<pre><code class=\"language-ts\" data-meta=\"title=&quot;config.ts&quot; {1,3-4} :line-numbers\">export default {\n name: &quot;docs&quot;,\n base: &quot;/docs/&quot;,\n};</code></pre>\n",
27
31
  "../../markdown-fixtures/code-meta/title-lines.md": "```ts title=\"config.ts\" {1,3-4} :line-numbers\nexport default {\n name: \"docs\",\n base: \"/docs/\",\n};\n```\n",
32
+ "../../markdown-fixtures/containers/escaped-marker.html": "<p>::: tip is text here</p>\n",
33
+ "../../markdown-fixtures/containers/escaped-marker.md": "\\::: tip is text here\n",
28
34
  "../../markdown-fixtures/containers/fence-inside.html": "<ui-callout tone=\"tip\">\n<pre><code class=\"language-md\">:::</code></pre>\n</ui-callout>\n",
29
35
  "../../markdown-fixtures/containers/fence-inside.md": "::: tip\n```md\n:::\n```\n:::\n",
30
36
  "../../markdown-fixtures/containers/nesting.canonical.md": ":::: details More\n::: warning\nNested.\n:::\n::::\n",
@@ -43,8 +49,18 @@ var files = /* #__PURE__ */ Object.assign({
43
49
  "../../markdown-fixtures/details/open.md": "::: details Show the full log {open}\nThe build finished in 42 seconds.\n:::\n",
44
50
  "../../markdown-fixtures/figures/caption.html": "<figure>\n<img src=\"./pipeline.svg\" alt=\"The build pipeline\">\n<figcaption>The build, from <em>source</em> to release</figcaption>\n</figure>\n",
45
51
  "../../markdown-fixtures/figures/caption.md": "::: figure The build, from *source* to release\n![The build pipeline](./pipeline.svg)\n:::\n",
52
+ "../../markdown-fixtures/front-matter/list-only.canonical.md": "# Notes\n",
53
+ "../../markdown-fixtures/front-matter/list-only.html": "<h1 id=\"notes\">Notes</h1>\n",
54
+ "../../markdown-fixtures/front-matter/list-only.md": "---\naliases:\n - /old/\n - /older/\n---\n\n# Notes\n",
55
+ "../../markdown-fixtures/front-matter/mapping-only.canonical.md": "# Notes\n",
56
+ "../../markdown-fixtures/front-matter/mapping-only.html": "<h1 id=\"notes\">Notes</h1>\n",
57
+ "../../markdown-fixtures/front-matter/mapping-only.md": "---\nhead:\n meta:\n robots: noindex\n---\n\n# Notes\n",
58
+ "../../markdown-fixtures/front-matter/metadata.canonical.md": "# Notes\n",
46
59
  "../../markdown-fixtures/front-matter/metadata.html": "<h1 id=\"notes\">Notes</h1>\n",
47
60
  "../../markdown-fixtures/front-matter/metadata.md": "---\ntitle: Release notes\n---\n\n# Notes\n",
61
+ "../../markdown-fixtures/front-matter/nested.canonical.md": "# Notes\n",
62
+ "../../markdown-fixtures/front-matter/nested.html": "<h1 id=\"notes\">Notes</h1>\n",
63
+ "../../markdown-fixtures/front-matter/nested.md": "---\n# Page settings\nsidebar:\n position: 2\n items:\n - label: Intro\n href: /intro/\ntitle: Notes\n---\n\n# Notes\n",
48
64
  "../../markdown-fixtures/heading-ids/accented.html": "<h2 id=\"über-uns\">Über uns</h2>\n<h2 id=\"café-crème\">Café crème</h2>\n",
49
65
  "../../markdown-fixtures/heading-ids/accented.md": "## Über uns\n\n## Café crème\n",
50
66
  "../../markdown-fixtures/heading-ids/cjk.html": "<h2 id=\"はじめに\">はじめに</h2>\n<h2 id=\"設定-と-使い方\">設定 と 使い方</h2>\n",
@@ -57,6 +73,8 @@ var files = /* #__PURE__ */ Object.assign({
57
73
  "../../markdown-fixtures/heading-ids/emoji-only.md": "## 🚀\n\n## 🎉 🎉\n",
58
74
  "../../markdown-fixtures/heading-ids/generated.html": "<h2 id=\"getting-started\">Getting started</h2>\n<h2 id=\"getting-started-1\">Getting started</h2>\n<h2 id=\"über-code-more\">Über <code>code</code> &amp; more</h2>\n",
59
75
  "../../markdown-fixtures/heading-ids/generated.md": "## Getting started\n\n## Getting started\n\n## Über `code` & more\n",
76
+ "../../markdown-fixtures/heading-ids/literal-braces.html": "<h2 id=\"price-usd\">Price {#usd}</h2>\n",
77
+ "../../markdown-fixtures/heading-ids/literal-braces.md": "## Price \\{#usd}\n",
60
78
  "../../markdown-fixtures/headings/levels.html": "<h1 id=\"one\">One</h1>\n<h2 id=\"two\">Two</h2>\n<h3 id=\"three\">Three</h3>\n<h4 id=\"four\">Four</h4>\n<h5 id=\"five\">Five</h5>\n<h6 id=\"six\">Six</h6>\n",
61
79
  "../../markdown-fixtures/headings/levels.md": "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six\n",
62
80
  "../../markdown-fixtures/image-attributes/placed.html": "<img src=\"./pipeline.svg\" alt=\"Pipeline\" width=\"640\" height=\"360\" data-placement=\"wrap-left\" data-looma-responsive>\n",
@@ -69,14 +87,20 @@ var files = /* #__PURE__ */ Object.assign({
69
87
  "../../markdown-fixtures/mentions/person.md": "Thanks, :mention[Ada Lovelace]{id=u1}.\n",
70
88
  "../../markdown-fixtures/tables/gfm.html": "<table>\n<thead>\n<tr>\n<th>Package</th>\n<th style=\"text-align:right\">Version</th>\n<th style=\"text-align:center\">Notes</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>looma</td>\n<td style=\"text-align:right\">0.28</td>\n<td style=\"text-align:center\">Components</td>\n</tr>\n</tbody>\n</table>\n",
71
89
  "../../markdown-fixtures/tables/gfm.md": "| Package | Version | Notes |\n| --- | ---: | :---: |\n| looma | 0.28 | Components |\n",
90
+ "../../markdown-fixtures/tables/left-aligned.html": "<table>\n<thead>\n<tr>\n<th style=\"text-align:left\">Name</th>\n<th>Notes</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td style=\"text-align:left\">a | b</td>\n<td><code>x | y</code></td>\n</tr>\n</tbody>\n</table>\n",
91
+ "../../markdown-fixtures/tables/left-aligned.md": "| Name | Notes |\n| :--- | --- |\n| a \\| b | `x \\| y` |\n",
92
+ "../../markdown-fixtures/tables/rich.canonical.md": "<table><tbody><tr><th><p>Status</p></th><th><p>Owner</p></th></tr><tr><td style=\"background-color: rgb(220, 252, 231);\"><p>Done</p><ul><li><p>Shipped</p></li></ul></td><td style=\"text-align: center;\"><p>Ada</p></td></tr></tbody></table>\n",
72
93
  "../../markdown-fixtures/tables/rich.html": "<table><tbody><tr><th><p>Status</p></th><th><p>Owner</p></th></tr><tr><td style=\"background-color: #dcfce7\"><p>Done</p><ul><li><p>Shipped</p></li></ul></td><td colspan=\"1\" rowspan=\"1\" style=\"text-align: center\"><p>Ada</p></td></tr></tbody></table>\n",
73
94
  "../../markdown-fixtures/tables/rich.md": "<table><tbody><tr><th><p>Status</p></th><th><p>Owner</p></th></tr><tr><td style=\"background-color: #dcfce7\"><p>Done</p><ul><li><p>Shipped</p></li></ul></td><td colspan=\"1\" rowspan=\"1\" style=\"text-align: center\"><p>Ada</p></td></tr></tbody></table>\n",
74
95
  "../../markdown-fixtures/tabs/code-group.html": "<ui-tabs class=\"code-group\" group=\"code-group\">\n<section aria-label=\"looma.config.js\">\n<pre><code class=\"language-js\" data-meta=\"title=&quot;looma.config.js&quot;\">export default { theme: &quot;light&quot; };</code></pre>\n</section>\n<section aria-label=\"ts\">\n<pre><code class=\"language-ts\">export default { theme: &quot;light&quot; } satisfies Config;</code></pre>\n</section>\n</ui-tabs>\n",
75
96
  "../../markdown-fixtures/tabs/code-group.md": "::: code-group\n```js title=\"looma.config.js\"\nexport default { theme: \"light\" };\n```\n```ts\nexport default { theme: \"light\" } satisfies Config;\n```\n:::\n",
76
97
  "../../markdown-fixtures/tabs/labelled.html": "<ui-tabs group=\"install\">\n<section aria-label=\"npm\">\n<pre><code class=\"language-sh\">npm install @threadlabs/looma</code></pre>\n</section>\n<section aria-label=\"pnpm\">\n<pre><code class=\"language-sh\">pnpm add @threadlabs/looma</code></pre>\n</section>\n</ui-tabs>\n",
77
98
  "../../markdown-fixtures/tabs/labelled.md": "::: tabs install\n== npm\n```sh\nnpm install @threadlabs/looma\n```\n== pnpm\n```sh\npnpm add @threadlabs/looma\n```\n:::\n",
78
- "../../markdown-fixtures/task-lists/checked.html": "<ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"true\"><input type=\"checkbox\" disabled checked>Read the changelog</li>\n<li data-type=\"taskItem\" data-checked=\"false\"><input type=\"checkbox\" disabled>Run the migration</li>\n</ul>\n",
99
+ "../../markdown-fixtures/task-lists/checked.html": "<ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"true\"><label><input type=\"checkbox\" disabled checked> Read the changelog</label></li>\n<li data-type=\"taskItem\" data-checked=\"false\"><label><input type=\"checkbox\" disabled> Run the migration</label></li>\n</ul>\n",
79
100
  "../../markdown-fixtures/task-lists/checked.md": "- [x] Read the changelog\n- [ ] Run the migration\n",
101
+ "../../markdown-fixtures/task-lists/loose.canonical.md": "- [x] Read the changelog\n\n Then tell the team.\n- [ ] Run the migration\n",
102
+ "../../markdown-fixtures/task-lists/loose.html": "<ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"true\">\n<p><label><input type=\"checkbox\" disabled checked> Read the changelog</label></p>\n<p>Then tell the team.</p>\n</li>\n<li data-type=\"taskItem\" data-checked=\"false\">\n<p><label><input type=\"checkbox\" disabled> Run the migration</label></p>\n</li>\n</ul>\n",
103
+ "../../markdown-fixtures/task-lists/loose.md": "- [x] Read the changelog\n\n Then tell the team.\n\n- [ ] Run the migration\n",
80
104
  "../../markdown-fixtures/task-lists/mixed.canonical.md": "- \\[x\\] Done\n- Not a task\n",
81
105
  "../../markdown-fixtures/task-lists/mixed.html": "<ul>\n<li>[x] Done</li>\n<li>Not a task</li>\n</ul>\n",
82
106
  "../../markdown-fixtures/task-lists/mixed.md": "- [x] Done\n- Not a task\n",
@@ -56,9 +56,12 @@ export declare function parseLoomaAttributes(source: string): LoomaAttributes |
56
56
  /**
57
57
  * Writes attributes as a brace list, `id` first as `#id`, then the rest in order.
58
58
  * @contract `true` writes a bare key; values are quoted only when they need it. `false`, null and
59
- * undefined values are left out, and an empty set writes an empty string.
59
+ * undefined values are left out, and an empty set writes an empty string. An ID that `#` cannot
60
+ * carry, or any ID when `hashId` is false, is written `id=…`.
60
61
  */
61
- export declare function formatLoomaAttributes(attributes: Readonly<Record<string, string | number | boolean | null | undefined>>): string;
62
+ export declare function formatLoomaAttributes(attributes: Readonly<Record<string, string | number | boolean | null | undefined>>, { hashId }?: {
63
+ hashId?: boolean | undefined;
64
+ }): string;
62
65
  /**
63
66
  * The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
64
67
  * marks, with every other run of characters one hyphen.
@@ -89,10 +92,23 @@ export interface LoomaFenceInfo {
89
92
  }
90
93
  /** Reads a fence's info string: ```` ```ts title="a.ts" {1,3-5} :line-numbers ````. */
91
94
  export declare function parseLoomaFenceInfo(info: string): LoomaFenceInfo;
95
+ /**
96
+ * Applies code notation: a line ending in a `[!code highlight]` comment is highlighted and loses
97
+ * the comment, `[!code highlight:3]` covers three lines, and a marker alone on its line is removed
98
+ * with the line and applies to the lines after it.
99
+ * @contract Returns the code a reader sees and its highlighted line numbers, from 1, ascending.
100
+ * ponytail: matched on source lines, so a string that ends in the marker counts too.
101
+ */
102
+ export declare function applyLoomaCodeNotation(code: string): {
103
+ code: string;
104
+ lines: number[];
105
+ };
92
106
  /**
93
107
  * Splits YAML front matter from the start of a document.
94
- * @contract Front matter opens with a `---` line at the very start, holds at least one `key: value`
95
- * line, and closes at a `---` or `...` line. `source` is the block as written, delimiters included.
108
+ * @contract Front matter opens with a `---` line at the very start and closes at a `---` or `...`
109
+ * line, and what lies between is a YAML mapping: its first line that is not blank or a comment
110
+ * starts with a key, whose value may be a scalar, a list or a mapping. `source` is the block as
111
+ * written, delimiters included.
96
112
  * @failure Returns null when the document has none.
97
113
  */
98
114
  export declare function splitLoomaFrontmatter(document: string): {
package/markdown/index.js CHANGED
@@ -139,13 +139,15 @@ function parseLoomaAttributes(source) {
139
139
  /**
140
140
  * Writes attributes as a brace list, `id` first as `#id`, then the rest in order.
141
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.
142
+ * undefined values are left out, and an empty set writes an empty string. An ID that `#` cannot
143
+ * carry, or any ID when `hashId` is false, is written `id=…`.
143
144
  */
144
- function formatLoomaAttributes(attributes) {
145
+ function formatLoomaAttributes(attributes, { hashId = true } = {}) {
145
146
  const items = [];
146
147
  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);
148
+ const hashed = hashId && typeof id === "string" && /^[\w-]+$/.test(id);
149
+ if (hashed) items.push(`#${id}`);
150
+ for (const [key, value] of Object.entries(hashed ? rest : attributes)) if (value === true) items.push(key);
149
151
  else if (value === false || value === null || value === void 0) continue;
150
152
  else {
151
153
  const text = String(value);
@@ -202,15 +204,46 @@ function parseLoomaFenceInfo(info) {
202
204
  ...numbers ? { lineNumbers: numbers[1] === void 0 } : {}
203
205
  };
204
206
  }
207
+ var MAPPING_KEY = /^(?:[^\s#:'"\-?,[\]{}&*!|>%@`][^:\n]*?|"[^"\n]*"|'[^'\n]*')[ \t]*:(?:[ \t]|$)/;
208
+ var NOTATION = /[ \t]*(?:\/\/|#|--|<!--|\{?\/\*)[ \t]*\[!code highlight(?::(\d+))?\][ \t]*(?:-->|\*\/\}?)?[ \t]*$/;
209
+ /**
210
+ * Applies code notation: a line ending in a `[!code highlight]` comment is highlighted and loses
211
+ * the comment, `[!code highlight:3]` covers three lines, and a marker alone on its line is removed
212
+ * with the line and applies to the lines after it.
213
+ * @contract Returns the code a reader sees and its highlighted line numbers, from 1, ascending.
214
+ * ponytail: matched on source lines, so a string that ends in the marker counts too.
215
+ */
216
+ function applyLoomaCodeNotation(code) {
217
+ const kept = [];
218
+ const lines = /* @__PURE__ */ new Set();
219
+ for (const line of code.split("\n")) {
220
+ const match = NOTATION.exec(line);
221
+ if (!match) {
222
+ kept.push(line);
223
+ continue;
224
+ }
225
+ const rest = line.slice(0, match.index);
226
+ const own = rest.trim() !== "";
227
+ if (own) kept.push(rest);
228
+ for (let count = 0; count < Number(match[1] ?? 1); count++) lines.add(kept.length + (own ? 0 : 1) + count);
229
+ }
230
+ return {
231
+ code: kept.join("\n"),
232
+ lines: [...lines].sort((a, b) => a - b)
233
+ };
234
+ }
205
235
  /**
206
236
  * 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.
237
+ * @contract Front matter opens with a `---` line at the very start and closes at a `---` or `...`
238
+ * line, and what lies between is a YAML mapping: its first line that is not blank or a comment
239
+ * starts with a key, whose value may be a scalar, a list or a mapping. `source` is the block as
240
+ * written, delimiters included.
209
241
  * @failure Returns null when the document has none.
210
242
  */
211
243
  function splitLoomaFrontmatter(document) {
212
244
  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;
245
+ const first = match?.[1].split(/\r?\n/).find((line) => line.trim() && !line.trimStart().startsWith("#"));
246
+ if (!match || !first || !MAPPING_KEY.test(first)) return null;
214
247
  return {
215
248
  source: match[0].trimEnd(),
216
249
  yaml: match[1],
@@ -227,4 +260,4 @@ function loomaCodeFence(code) {
227
260
  return "`".repeat(Math.max(3, longest + 1));
228
261
  }
229
262
  //#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 };
263
+ export { LOOMA_BADGE_TONES, LOOMA_CALLOUT_TONES, LOOMA_CALLOUT_TONE_ALIASES, LOOMA_CHIP_TONES, LOOMA_CONTAINER_NAMES, LOOMA_MEANING_TONES, applyLoomaCodeNotation, assignLoomaHeadingIds, formatLoomaAttributes, loomaCodeFence, loomaContainerMarker, loomaHeadingSlug, normalizeLoomaCalloutTone, normalizeLoomaChipTone, parseLoomaAttributes, parseLoomaFenceInfo, readLoomaAttributes, splitLoomaFrontmatter };
@@ -1,13 +1,21 @@
1
1
  import MarkdownIt from "markdown-it";
2
+ /** How a Looma Markdown instance renders. */
3
+ export interface LoomaMarkdownOptions {
4
+ /**
5
+ * `apply` (default) renders code as a reader sees it, without its `[!code highlight]` comments;
6
+ * `keep` keeps them in the code, for an editor that edits the source.
7
+ */
8
+ codeNotation?: "apply" | "keep";
9
+ }
2
10
  /**
3
11
  * Adds Looma Markdown to a markdown-it instance: containers, alerts, task lists, the table of
4
12
  * contents, chips, mentions, brace attributes, heading anchors, code meta and front matter.
5
13
  * @contract Renders the canonical HTML, which is the HTML LoomaEditor saves. It is not sanitized.
6
14
  * Linkify and raw HTML are instance options; `createLoomaMarkdownIt` turns both on.
7
15
  */
8
- export declare function loomaMarkdown(md: MarkdownIt): void;
16
+ export declare function loomaMarkdown(md: MarkdownIt, { codeNotation }?: LoomaMarkdownOptions): void;
9
17
  /**
10
18
  * A markdown-it instance for Looma Markdown: raw HTML and GFM autolinks on, with `loomaMarkdown`.
11
19
  * @contract Each call returns a new instance that callers may extend.
12
20
  */
13
- export declare function createLoomaMarkdownIt(): MarkdownIt;
21
+ export declare function createLoomaMarkdownIt(options?: LoomaMarkdownOptions): MarkdownIt;
@@ -1,4 +1,4 @@
1
- import { LOOMA_CONTAINER_NAMES, assignLoomaHeadingIds, normalizeLoomaCalloutTone, normalizeLoomaChipTone, parseLoomaAttributes, parseLoomaFenceInfo, readLoomaAttributes, splitLoomaFrontmatter } from "./index.js";
1
+ import { LOOMA_CONTAINER_NAMES, applyLoomaCodeNotation, assignLoomaHeadingIds, normalizeLoomaCalloutTone, normalizeLoomaChipTone, parseLoomaAttributes, parseLoomaFenceInfo, readLoomaAttributes, splitLoomaFrontmatter } from "./index.js";
2
2
  import MarkdownIt from "markdown-it";
3
3
  //#region src/markdown/markdown-it.ts
4
4
  var CONTAINER_OPEN = /^(:{3,})[ \t]*([A-Za-z][\w-]*)(?:[ \t]+(.*?))?[ \t]*$/;
@@ -8,7 +8,7 @@ var TAB_LABEL = /^==[ \t]+(\S.*?)[ \t]*$/;
8
8
  var OPEN_FLAG = /[ \t]*\{open\}$/;
9
9
  var ALERT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)/i;
10
10
  var LEAF = /^::([A-Za-z][\w-]*)(\{.*\})?[ \t]*$/;
11
- var TASK = /^\[([ xX])\][ \t]+/;
11
+ var TASK = /^\[([ xX])\](?:[ \t]+|$)/;
12
12
  var TOC_FORMATS = /* @__PURE__ */ new Set([
13
13
  "plain",
14
14
  "bulleted",
@@ -200,7 +200,10 @@ function taskLists(state) {
200
200
  inline.content = inline.content.replace(TASK, "");
201
201
  item.attrSet("data-type", "taskItem");
202
202
  item.attrSet("data-checked", String(checked));
203
- item.meta = { checked };
203
+ inline.meta = {
204
+ ...inline.meta,
205
+ task: checked
206
+ };
204
207
  }
205
208
  }
206
209
  }
@@ -242,6 +245,11 @@ function attributes(state) {
242
245
  const token = tokens[index];
243
246
  if (token.type !== "inline" || !token.children) continue;
244
247
  const children = token.children;
248
+ if (typeof token.meta?.task === "boolean") {
249
+ const html = (content) => Object.assign(new state.Token("html_inline", "", 0), { content });
250
+ children.unshift(html(`<label><input type="checkbox" disabled=""${token.meta.task ? " checked=\"\"" : ""}> `));
251
+ children.push(html("</label>"));
252
+ }
245
253
  for (let child = children.length - 3; child >= 0; child--) {
246
254
  const text = children[child + 1];
247
255
  if (children[child].type !== "link_open" || children[child].markup !== "linkify" || /^(?:[a-z][\w+.-]*:|www\.)/i.test(text.content)) continue;
@@ -318,7 +326,7 @@ function tableOfContents(tokens, format, depth, escape) {
318
326
  * @contract Renders the canonical HTML, which is the HTML LoomaEditor saves. It is not sanitized.
319
327
  * Linkify and raw HTML are instance options; `createLoomaMarkdownIt` turns both on.
320
328
  */
321
- function loomaMarkdown(md) {
329
+ function loomaMarkdown(md, { codeNotation = "apply" } = {}) {
322
330
  const escape = md.utils.escapeHtml;
323
331
  md.block.ruler.before("table", "looma_frontmatter", frontmatter);
324
332
  md.block.ruler.before("fence", "looma_container", container, { alt: [
@@ -369,15 +377,10 @@ function loomaMarkdown(md) {
369
377
  const id = String(attributes.id);
370
378
  return `<span data-type="mention" class="looma-mention" data-id="${escape(id)}" data-label="${escape(label || id)}">@${escape(label || id)}</span>`;
371
379
  };
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
380
  const code = (content, info) => {
379
381
  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`;
382
+ const source = content.replace(/\n$/, "");
383
+ return `<pre><code${language ? ` class="language-${escape(language)}"` : ""}${meta ? ` data-meta="${escape(meta)}"` : ""}>${escape(codeNotation === "keep" ? source : applyLoomaCodeNotation(source).code)}</code></pre>\n`;
381
384
  };
382
385
  rules.fence = (tokens, index) => code(tokens[index].content, tokens[index].info);
383
386
  rules.code_block = (tokens, index) => code(tokens[index].content, "");
@@ -386,12 +389,12 @@ function loomaMarkdown(md) {
386
389
  * A markdown-it instance for Looma Markdown: raw HTML and GFM autolinks on, with `loomaMarkdown`.
387
390
  * @contract Each call returns a new instance that callers may extend.
388
391
  */
389
- function createLoomaMarkdownIt() {
392
+ function createLoomaMarkdownIt(options = {}) {
390
393
  return new MarkdownIt({
391
394
  html: true,
392
395
  linkify: true,
393
396
  typographer: false
394
- }).use(loomaMarkdown);
397
+ }).use(loomaMarkdown, options);
395
398
  }
396
399
  //#endregion
397
400
  export { createLoomaMarkdownIt, loomaMarkdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.16",
3
+ "version": "0.28.18",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -171,6 +171,7 @@
171
171
  "@tiptap/vue-3": "^2.11.5",
172
172
  "@types/markdown-it": "14.2.0",
173
173
  "@vitest/browser": "~3.2.6",
174
+ "axe-core": "^4.10.3",
174
175
  "jsdom": "^27.0.0"
175
176
  },
176
177
  "engines": {
@@ -1840,6 +1840,34 @@ function formatEditorShortcut(keys, apple) {
1840
1840
  ].filter(Boolean).join("+");
1841
1841
  }
1842
1842
  var isApplePlatform = () => typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent);
1843
+ /**
1844
+ * The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
1845
+ * marks, with every other run of characters one hyphen.
1846
+ * @failure Text with no letters or numbers, such as only emoji, derives `heading`.
1847
+ */
1848
+ function loomaHeadingSlug(text) {
1849
+ return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
1850
+ }
1851
+ /**
1852
+ * Gives every heading of a document its anchor, in document order.
1853
+ * @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
1854
+ * its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
1855
+ */
1856
+ function assignLoomaHeadingIds(headings) {
1857
+ const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
1858
+ const used = /* @__PURE__ */ new Set();
1859
+ return headings.map(({ text, id: custom }) => {
1860
+ let id = custom || loomaHeadingSlug(text);
1861
+ if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
1862
+ const stem = id;
1863
+ let suffix = 1;
1864
+ while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
1865
+ id = `${stem}-${suffix}`;
1866
+ }
1867
+ used.add(id);
1868
+ return id;
1869
+ });
1870
+ }
1843
1871
  //#endregion
1844
1872
  //#region src/editor/extensions/expand.ts
1845
1873
  /**
@@ -2049,7 +2077,17 @@ function tableOfContentsTree(entries) {
2049
2077
  }
2050
2078
  return roots;
2051
2079
  }
2052
- function anchorTransaction(state) {
2080
+ var derivedFrom = (id, text) => {
2081
+ const slug = loomaHeadingSlug(text);
2082
+ return id === slug || id.startsWith(`${slug}-`) && /^\d+$/.test(id.slice(slug.length + 1));
2083
+ };
2084
+ /**
2085
+ * Keeps heading anchors on Looma Markdown's rule. An anchor derived from its heading's text
2086
+ * (as it read before this change) follows the text; any other, including `looma-heading-N` anchors
2087
+ * saved before this rule, is a custom anchor and stays, so links to it keep working. With
2088
+ * a table of contents every heading gets an anchor; without one, only headings that have one.
2089
+ */
2090
+ function anchorTransaction(state, previous) {
2053
2091
  let hasToc = false;
2054
2092
  const headings = [];
2055
2093
  state.doc.descendants((node, position) => {
@@ -2059,24 +2097,28 @@ function anchorTransaction(state) {
2059
2097
  position
2060
2098
  });
2061
2099
  });
2062
- if (!hasToc) return null;
2063
- const reserved = new Set(headings.map(({ node }) => String(node.attrs.id ?? "")).filter(Boolean));
2064
- const used = /* @__PURE__ */ new Set();
2100
+ if (!headings.length) return null;
2101
+ const before = /* @__PURE__ */ new Map();
2102
+ previous?.descendants((node) => {
2103
+ if (node.type.name === "heading" && typeof node.attrs.id === "string") before.set(node.attrs.id, node.textContent.trim());
2104
+ });
2105
+ const custom = (node) => {
2106
+ const id = node.attrs.id;
2107
+ if (typeof id !== "string" || !id || /\s/.test(id)) return void 0;
2108
+ return derivedFrom(id, before.get(id) ?? node.textContent.trim()) ? void 0 : id;
2109
+ };
2110
+ const ids = assignLoomaHeadingIds(headings.map(({ node }) => ({
2111
+ text: node.textContent.trim(),
2112
+ id: custom(node)
2113
+ })));
2065
2114
  const transaction = state.tr;
2066
- let sequence = 1;
2067
- for (const { node, position } of headings) {
2068
- let id = typeof node.attrs.id === "string" ? node.attrs.id : "";
2069
- if (!id || /\s/.test(id) || used.has(id)) {
2070
- do
2071
- id = `looma-heading-${sequence++}`;
2072
- while (reserved.has(id) || used.has(id));
2073
- transaction.setNodeMarkup(position, void 0, {
2074
- ...node.attrs,
2075
- id
2076
- });
2077
- }
2078
- used.add(id);
2079
- }
2115
+ headings.forEach(({ node, position }, index) => {
2116
+ if (node.attrs.id === ids[index] || !hasToc && !node.attrs.id) return;
2117
+ transaction.setNodeMarkup(position, void 0, {
2118
+ ...node.attrs,
2119
+ id: ids[index]
2120
+ });
2121
+ });
2080
2122
  return transaction.docChanged ? transaction : null;
2081
2123
  }
2082
2124
  var LoomaHeadingAnchors = Extension.create({
@@ -2092,7 +2134,7 @@ var LoomaHeadingAnchors = Extension.create({
2092
2134
  if (transaction) this.editor.view.dispatch(transaction.setMeta("addToHistory", false));
2093
2135
  },
2094
2136
  addProseMirrorPlugins() {
2095
- return [new Plugin({ appendTransaction: (transactions, _oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state) : null })];
2137
+ return [new Plugin({ appendTransaction: (transactions, oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state, oldState.doc) : null })];
2096
2138
  }
2097
2139
  });
2098
2140
  function contentsHtml(doc, config) {