@threadlabs/looma 0.28.16 → 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.
@@ -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`.
@@ -625,6 +644,144 @@ var LoomaExpand = Node$1.create({
625
644
  }
626
645
  });
627
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
628
785
  //#region src/editor/extensions/table-of-contents.ts
629
786
  /** Normalize loaded configuration without persisting unsupported presentation values. */
630
787
  function normalizeTableOfContentsConfig(attributes) {
@@ -671,7 +828,17 @@ function tableOfContentsTree(entries) {
671
828
  }
672
829
  return roots;
673
830
  }
674
- 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) {
675
842
  let hasToc = false;
676
843
  const headings = [];
677
844
  state.doc.descendants((node, position) => {
@@ -681,24 +848,28 @@ function anchorTransaction(state) {
681
848
  position
682
849
  });
683
850
  });
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();
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
+ })));
687
865
  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
- }
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
+ });
702
873
  return transaction.docChanged ? transaction : null;
703
874
  }
704
875
  var LoomaHeadingAnchors = Extension.create({
@@ -714,7 +885,7 @@ var LoomaHeadingAnchors = Extension.create({
714
885
  if (transaction) this.editor.view.dispatch(transaction.setMeta("addToHistory", false));
715
886
  },
716
887
  addProseMirrorPlugins() {
717
- 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 })];
718
889
  }
719
890
  });
720
891
  function contentsHtml(doc, config) {
@@ -9332,7 +9503,7 @@ var TAB_LABEL = /^==[ \t]+(\S.*?)[ \t]*$/;
9332
9503
  var OPEN_FLAG = /[ \t]*\{open\}$/;
9333
9504
  var ALERT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)/i;
9334
9505
  var LEAF = /^::([A-Za-z][\w-]*)(\{.*\})?[ \t]*$/;
9335
- var TASK = /^\[([ xX])\][ \t]+/;
9506
+ var TASK = /^\[([ xX])\](?:[ \t]+|$)/;
9336
9507
  var TOC_FORMATS = /* @__PURE__ */ new Set([
9337
9508
  "plain",
9338
9509
  "bulleted",
@@ -9729,29 +9900,46 @@ var loomaMarkdown = createLoomaMarkdownIt();
9729
9900
  function loomaMarkdownToHtml(source) {
9730
9901
  return loomaMarkdown.render(source);
9731
9902
  }
9903
+ var CONTAINERS = /* @__PURE__ */ new Set([
9904
+ "loomaCallout",
9905
+ "loomaExpand",
9906
+ "loomaFigure",
9907
+ "loomaTabs"
9908
+ ]);
9732
9909
  var containerDepth = (node) => {
9733
9910
  let depth = 0;
9734
9911
  node.descendants((child) => {
9735
- 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));
9736
9914
  return false;
9737
9915
  });
9738
9916
  return depth;
9739
9917
  };
9740
- function writeContainer(state, node, opening) {
9918
+ var endLine = (state) => state.flushClose(1);
9919
+ function writeContainer(state, node, opening, content = () => state.renderContent(node)) {
9741
9920
  const marker = loomaContainerMarker(containerDepth(node));
9742
9921
  state.write(`${marker} ${opening}`.trimEnd());
9743
9922
  state.ensureNewLine();
9744
- state.renderContent(node);
9745
- state.flushClose(1);
9923
+ content();
9924
+ endLine(state);
9746
9925
  state.write(marker);
9747
9926
  state.closeBlock(node);
9748
9927
  }
9749
9928
  var defaults = defaultMarkdownSerializer.nodes;
9929
+ var label = (text) => text.replace(/[\\\]]/g, "\\$&");
9930
+ var ALIGNMENT = {
9931
+ left: ":---",
9932
+ center: ":---:",
9933
+ right: "---:"
9934
+ };
9750
9935
  var NODES = {
9751
9936
  paragraph: defaults.paragraph,
9752
- 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
+ },
9753
9942
  blockquote: defaults.blockquote,
9754
- heading: defaults.heading,
9755
9943
  horizontalRule: defaults.horizontal_rule,
9756
9944
  bulletList: (state, node) => state.renderList(node, " ", () => "- "),
9757
9945
  orderedList: (state, node) => {
@@ -9759,17 +9947,82 @@ var NODES = {
9759
9947
  state.renderList(node, " ", (index) => `${start + index}. `);
9760
9948
  },
9761
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
+ },
9762
9955
  hardBreak: defaults.hard_break,
9763
9956
  codeBlock: (state, node) => {
9764
9957
  const fence = loomaCodeFence(node.textContent);
9765
- state.write(`${fence}${node.attrs.language ?? ""}\n`);
9958
+ state.write(`${fence}${node.attrs.language ?? ""}${node.attrs.meta ? ` ${node.attrs.meta}` : ""}\n`);
9766
9959
  state.text(node.textContent, false);
9767
9960
  state.ensureNewLine();
9768
9961
  state.write(fence);
9769
9962
  state.closeBlock(node);
9770
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
+ },
9771
9975
  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}" : ""}`)
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
+ }
9773
10026
  };
9774
10027
  var MARKS = {
9775
10028
  bold: defaultMarkdownSerializer.marks.strong,
@@ -9793,31 +10046,96 @@ var MARKS = {
9793
10046
  mixable: true
9794
10047
  }
9795
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
+ }
9796
10068
  /**
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.
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.
9802
10073
  */
9803
10074
  function loomaMarkdownFromDoc(doc) {
9804
- const dom = DOMSerializer.fromSchema(doc.type.schema);
10075
+ const schema = doc.type.schema;
10076
+ const dom = DOMSerializer.fromSchema(schema);
9805
10077
  const html = (node) => {
9806
10078
  const holder = document.createElement("div");
9807
10079
  holder.append(dom.serializeNode(node));
9808
10080
  return holder.innerHTML;
9809
10081
  };
9810
- const nodes = {};
9811
- 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) => {
9812
10101
  state.write(html(node));
9813
10102
  if (node.isBlock) state.closeBlock(node);
9814
10103
  });
9815
10104
  const marks = {};
9816
- 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] ?? {
9817
10106
  open: "",
9818
10107
  close: ""
9819
10108
  };
9820
- 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 });
9821
10139
  }
9822
10140
  //#endregion
9823
10141
  //#region src/editor/extensions/smart-paste.ts
@@ -9893,8 +10211,12 @@ function parseHtmlSlice(schema, value, fromHtml = false) {
9893
10211
  "template"
9894
10212
  ],
9895
10213
  FORBID_ATTR: ["style"],
9896
- ADD_TAGS: ["ui-callout"],
9897
- ADD_ATTR: ["tone", "heading"],
10214
+ ADD_TAGS: ["ui-callout", "ui-tabs"],
10215
+ ADD_ATTR: [
10216
+ "tone",
10217
+ "heading",
10218
+ "group"
10219
+ ],
9898
10220
  RETURN_DOM_FRAGMENT: true
9899
10221
  }));
9900
10222
  if (!container.textContent?.trim() && !container.querySelector("img, hr")) return Slice.empty;
@@ -10438,6 +10760,16 @@ function setActiveTableCellBackground(editor, backgroundColor) {
10438
10760
  * Uses the Vanilla JS Tiptap API; apps provide @tiptap/core and Looma ships the preset extensions.
10439
10761
  */
10440
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
+ },
10441
10773
  renderHTML({ node, HTMLAttributes }) {
10442
10774
  return [
10443
10775
  "pre",
@@ -10446,6 +10778,7 @@ var LoomaCodeBlock = CodeBlockLowlight.extend({
10446
10778
  "code",
10447
10779
  {
10448
10780
  class: node.attrs.language ? `${this.options.languageClassPrefix}${node.attrs.language}` : null,
10781
+ "data-meta": node.attrs.meta || null,
10449
10782
  spellcheck: "false"
10450
10783
  },
10451
10784
  0
@@ -10521,7 +10854,10 @@ function getDefaultEditorExtensions(options = {}) {
10521
10854
  Heading.configure({ levels: [
10522
10855
  1,
10523
10856
  2,
10524
- 3
10857
+ 3,
10858
+ 4,
10859
+ 5,
10860
+ 6
10525
10861
  ] }),
10526
10862
  BulletList,
10527
10863
  OrderedList,
@@ -10531,6 +10867,9 @@ function getDefaultEditorExtensions(options = {}) {
10531
10867
  Blockquote,
10532
10868
  calloutNodeView ? LoomaCallout.extend({ addNodeView: () => calloutNodeView }) : LoomaCallout,
10533
10869
  LoomaChip,
10870
+ LoomaTabs,
10871
+ LoomaTab,
10872
+ ...imageInline ? [] : [LoomaFigure, LoomaFigcaption],
10534
10873
  LoomaExpand.configure({ nodeView: expandNodeView ?? null }),
10535
10874
  LoomaTableOfContents.configure({ nodeView: tableOfContentsNodeView ?? null }),
10536
10875
  dividerNodeView ? HorizontalRule.extend({ addNodeView: () => dividerNodeView }) : HorizontalRule,
@@ -11522,4 +11861,4 @@ function createLoomaSlashCommandExtension(options = {}) {
11522
11861
  });
11523
11862
  }
11524
11863
  //#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 };
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 };
@@ -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 };
@@ -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",
@@ -25,6 +27,8 @@ var files = /* #__PURE__ */ Object.assign({
25
27
  "../../markdown-fixtures/code-meta/notation.md": "```ts\nconst port = 8080; // [!code highlight]\n```\n",
26
28
  "../../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
29
  "../../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",
30
+ "../../markdown-fixtures/containers/escaped-marker.html": "<p>::: tip is text here</p>\n",
31
+ "../../markdown-fixtures/containers/escaped-marker.md": "\\::: tip is text here\n",
28
32
  "../../markdown-fixtures/containers/fence-inside.html": "<ui-callout tone=\"tip\">\n<pre><code class=\"language-md\">:::</code></pre>\n</ui-callout>\n",
29
33
  "../../markdown-fixtures/containers/fence-inside.md": "::: tip\n```md\n:::\n```\n:::\n",
30
34
  "../../markdown-fixtures/containers/nesting.canonical.md": ":::: details More\n::: warning\nNested.\n:::\n::::\n",
@@ -43,6 +47,7 @@ var files = /* #__PURE__ */ Object.assign({
43
47
  "../../markdown-fixtures/details/open.md": "::: details Show the full log {open}\nThe build finished in 42 seconds.\n:::\n",
44
48
  "../../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
49
  "../../markdown-fixtures/figures/caption.md": "::: figure The build, from *source* to release\n![The build pipeline](./pipeline.svg)\n:::\n",
50
+ "../../markdown-fixtures/front-matter/metadata.canonical.md": "# Notes\n",
46
51
  "../../markdown-fixtures/front-matter/metadata.html": "<h1 id=\"notes\">Notes</h1>\n",
47
52
  "../../markdown-fixtures/front-matter/metadata.md": "---\ntitle: Release notes\n---\n\n# Notes\n",
48
53
  "../../markdown-fixtures/heading-ids/accented.html": "<h2 id=\"über-uns\">Über uns</h2>\n<h2 id=\"café-crème\">Café crème</h2>\n",
@@ -57,6 +62,8 @@ var files = /* #__PURE__ */ Object.assign({
57
62
  "../../markdown-fixtures/heading-ids/emoji-only.md": "## 🚀\n\n## 🎉 🎉\n",
58
63
  "../../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
64
  "../../markdown-fixtures/heading-ids/generated.md": "## Getting started\n\n## Getting started\n\n## Über `code` & more\n",
65
+ "../../markdown-fixtures/heading-ids/literal-braces.html": "<h2 id=\"price-usd\">Price {#usd}</h2>\n",
66
+ "../../markdown-fixtures/heading-ids/literal-braces.md": "## Price \\{#usd}\n",
60
67
  "../../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
68
  "../../markdown-fixtures/headings/levels.md": "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six\n",
62
69
  "../../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,6 +76,9 @@ var files = /* #__PURE__ */ Object.assign({
69
76
  "../../markdown-fixtures/mentions/person.md": "Thanks, :mention[Ada Lovelace]{id=u1}.\n",
70
77
  "../../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
78
  "../../markdown-fixtures/tables/gfm.md": "| Package | Version | Notes |\n| --- | ---: | :---: |\n| looma | 0.28 | Components |\n",
79
+ "../../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",
80
+ "../../markdown-fixtures/tables/left-aligned.md": "| Name | Notes |\n| :--- | --- |\n| a \\| b | `x \\| y` |\n",
81
+ "../../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
82
  "../../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
83
  "../../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
84
  "../../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",
@@ -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.
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);
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.16",
3
+ "version": "0.28.17",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -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) {