@remigius42/morg 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +12 -1
  2. package/dist/core/affiliated.d.ts +13 -0
  3. package/dist/core/affiliated.js +43 -0
  4. package/dist/core/backslashCommands.d.ts +10 -0
  5. package/dist/core/backslashCommands.js +43 -0
  6. package/dist/core/bracedScripts.d.ts +12 -0
  7. package/dist/core/bracedScripts.js +159 -0
  8. package/dist/core/footnoteReferences.d.ts +10 -0
  9. package/dist/core/footnoteReferences.js +24 -0
  10. package/dist/core/frontmatterBlock.d.ts +89 -0
  11. package/dist/core/frontmatterBlock.js +448 -0
  12. package/dist/core/keyValueLines.d.ts +6 -0
  13. package/dist/core/keyValueLines.js +18 -0
  14. package/dist/core/lineSyntax.d.ts +21 -0
  15. package/dist/core/lineSyntax.js +221 -0
  16. package/dist/core/markupBoundary.d.ts +12 -0
  17. package/dist/core/markupBoundary.js +195 -0
  18. package/dist/core/mdastToUniorg/blocks.d.ts +0 -1
  19. package/dist/core/mdastToUniorg/blocks.js +3 -31
  20. package/dist/core/mdastToUniorg/index.d.ts +6 -0
  21. package/dist/core/mdastToUniorg/index.js +81 -8
  22. package/dist/core/mdastToUniorg/lists.d.ts +1 -1
  23. package/dist/core/mdastToUniorg/lists.js +21 -2
  24. package/dist/core/mdastToUniorg/phrasing.js +145 -12
  25. package/dist/core/orgPath.d.ts +9 -0
  26. package/dist/core/orgPath.js +24 -0
  27. package/dist/core/render.d.ts +33 -0
  28. package/dist/core/render.js +101 -0
  29. package/dist/core/tablePipes.d.ts +17 -0
  30. package/dist/core/tablePipes.js +47 -0
  31. package/dist/core/underscoreBullets.d.ts +10 -0
  32. package/dist/core/underscoreBullets.js +32 -0
  33. package/dist/core/uniorgToMdast/elements.js +1 -1
  34. package/dist/core/uniorgToMdast/index.d.ts +3 -2
  35. package/dist/core/uniorgToMdast/index.js +100 -31
  36. package/dist/core/uniorgToMdast/lists.js +12 -1
  37. package/dist/core/uniorgToMdast/objects.js +44 -6
  38. package/dist/core/uniorgToMdast/shared.d.ts +5 -0
  39. package/dist/core/uniorgToMdast/shared.js +3 -12
  40. package/dist/core/uniorgToMdast/tables.js +12 -6
  41. package/dist/markdownToOrg.js +44 -23
  42. package/dist/orgToMarkdown.js +39 -5
  43. package/dist/presets/logseq.js +130 -0
  44. package/dist/presets/types.d.ts +4 -1
  45. package/package.json +4 -1
@@ -1,13 +1,27 @@
1
1
  import { stringify as stringifyYaml } from "yaml";
2
+ import { warn } from "./shared.js";
3
+ import { isModeLineComment, takeFileHeader, takeRenderedFileHeader } from "../frontmatterBlock.js";
2
4
  import { collectFootnoteLabels } from "./footnotes.js";
3
5
  import { transformNodes } from "./elements.js";
4
6
  /**
5
7
  * Transforms a uniorg AST to a mdast (Markdown AST).
6
8
  * @param uniorgAst The uniorg AST to transform.
7
- * @param options Controls org-ism serialization (`key:: value` lines).
9
+ * @param options Controls org-ism serialization (`key:: value` lines);
10
+ * `org`, the text a tree was parsed from, finds its frontmatter block.
8
11
  * @returns The transformed mdast.
9
12
  */
10
13
  export function transformUniorgAstToMdast(uniorgAst, options = {}) {
14
+ // called on its own, not by convertOrgToMarkdown, which takes the
15
+ // header off the org text: the header of the org text the tree was
16
+ // parsed from, or else the one transformMdastToUniorgAst built, from a
17
+ // copy, so the caller's tree stays as it is
18
+ if (options.takesMorgEntries === undefined) {
19
+ uniorgAst = { ...uniorgAst, children: [...uniorgAst.children] };
20
+ const header = options.org === undefined
21
+ ? takeRenderedFileHeader(uniorgAst)
22
+ : takeFileHeader(uniorgAst, options.org, options.onWarning);
23
+ options = { ...options, ...header };
24
+ }
11
25
  const ctx = {
12
26
  options,
13
27
  inlineFootnotes: [],
@@ -15,36 +29,15 @@ export function transformUniorgAstToMdast(uniorgAst, options = {}) {
15
29
  };
16
30
  collectFootnoteLabels(ctx, uniorgAst);
17
31
  const nodes = uniorgAst.children || [];
18
- // leading #+KEY: value keywords map to md frontmatter, a native
19
- // construct; JSON-encoded values restore their structure (ADR 0002)
20
- const collected = new Map();
21
- let first = 0;
22
- while (nodes[first]?.type === "keyword") {
23
- const keyword = nodes[first];
24
- const key = keyword.key.toLowerCase();
25
- // a keyword may legally repeat; collect the values instead of
26
- // letting the last one win
27
- collected.set(key, [
28
- ...(collected.get(key) ?? []),
29
- parseKeywordValue(keyword.value)
30
- ]);
31
- first++;
32
- }
33
- const frontmatter = Object.fromEntries([...collected].map(([key, values]) => [
34
- key,
35
- values.length === 1 ? values[0] : values
36
- ]));
37
- const children = transformNodes(ctx, nodes.slice(first));
32
+ const { yaml, rest } = takeFrontmatter(ctx, nodes);
33
+ const children = transformNodes(ctx, rest);
38
34
  // adjacent single-item task lists (one per converted TODO section)
39
35
  // merge into one list, or the output would not be a fixed point
40
36
  if (options.taskCheckboxes) {
41
37
  mergeAdjacentTaskLists(children);
42
38
  }
43
- if (first > 0) {
44
- children.unshift({
45
- type: "yaml",
46
- value: stringifyYaml(frontmatter).trimEnd()
47
- });
39
+ if (yaml !== undefined) {
40
+ children.unshift({ type: "yaml", value: yaml });
48
41
  }
49
42
  for (const footnote of ctx.inlineFootnotes) {
50
43
  children.push({
@@ -56,12 +49,88 @@ export function transformUniorgAstToMdast(uniorgAst, options = {}) {
56
49
  }
57
50
  return { type: "root", children: children };
58
51
  }
59
- function parseKeywordValue(value) {
60
- try {
61
- return JSON.parse(value);
52
+ // the frontmatter: the block's YAML, and the leading #+KEY: value
53
+ // keywords, which can act in Emacs, in order, repeats included, as
54
+ // morg's own entry (ADR 0005); where they cannot join it, they stay
55
+ // keyword lines. Returns the YAML and the nodes left
56
+ function takeFrontmatter(ctx, nodes) {
57
+ const block = ctx.options.frontmatter;
58
+ // set by the caller or transformUniorgAstToMdast, never undefined
59
+ const joins = ctx.options.takesMorgEntries === true;
60
+ const start = leadingKeywordsStart(nodes);
61
+ warnAboutFrontmatter(ctx, block, joins ? [] : nodes.slice(start));
62
+ const keywords = inertModeLineGuard(ctx, nodes, joins ? leadingKeywords(nodes.slice(start)) : [], start);
63
+ const entries = morgEntries(ctx.options.fileProperties ?? [], keywords);
64
+ const rest = [
65
+ ...nodes.slice(0, start),
66
+ ...nodes.slice(start + keywords.length)
67
+ ];
68
+ // an empty block adds nothing next to the entries
69
+ // on a line of their own: a keep-chomped (`|+`) scalar at the end of
70
+ // the block holds a newline already there
71
+ const yaml = entries && block
72
+ ? `${block.replace(/(?<!\n)$/, "\n")}${entries}`
73
+ : (entries ?? block);
74
+ // leading the Markdown body, with or without frontmatter above it
75
+ if (isInertModeLine(ctx, rest[0])) {
76
+ warn(ctx, "a -*- comment below the first line becomes the mode line in org");
77
+ }
78
+ return { yaml, rest };
79
+ }
80
+ // leading keywords may sit below a -*- comment, which leads the
81
+ // Markdown body and so becomes the mode line (warned if it was none);
82
+ // below another comment, they would come back above it
83
+ function leadingKeywordsStart(nodes) {
84
+ return nodes[0] && isModeLineComment(nodes[0]) ? 1 : 0;
85
+ }
86
+ // a -*- comment below the file's first line, which Emacs ignores
87
+ function isInertModeLine(ctx, node) {
88
+ return !ctx.options.startsWithModeLine && !!node && isModeLineComment(node);
89
+ }
90
+ // md→org makes a -*- comment that leads the Markdown body the file's
91
+ // mode line: leading keywords directly above one stay lines, so it
92
+ // does not lead it
93
+ function inertModeLineGuard(ctx, nodes, keywords, start) {
94
+ if (!keywords.length ||
95
+ start !== 0 ||
96
+ !isInertModeLine(ctx, nodes[keywords.length])) {
97
+ return keywords;
98
+ }
99
+ warn(ctx, "leading keywords stay lines: a -*- comment below them is no mode line");
100
+ return [];
101
+ }
102
+ function leadingKeywords(nodes) {
103
+ const keywords = [];
104
+ while (nodes[keywords.length]?.type === "keyword") {
105
+ const keyword = nodes[keywords.length];
106
+ keywords.push([keyword.key, keyword.value]);
107
+ }
108
+ return keywords;
109
+ }
110
+ // morg's own entries as YAML text, or undefined without any
111
+ function morgEntries(fileProperties, keywords) {
112
+ // an empty value as `KEY:`, as it reads back, not as `KEY: ""`
113
+ const items = (pairs) => pairs.map(([key, value]) => ({ [key]: value || null }));
114
+ const entries = {
115
+ ...(fileProperties.length && { morg_properties: items(fileProperties) }),
116
+ ...(keywords.length && { morg_keywords: items(keywords) })
117
+ };
118
+ // unfolded: a value folded over lines fits no keyword line again
119
+ return Object.keys(entries).length
120
+ ? stringifyYaml(entries, { lineWidth: 0, nullStr: "" }).trimEnd()
121
+ : undefined;
122
+ }
123
+ // `left`: the leading nodes the frontmatter could not take
124
+ function warnAboutFrontmatter(ctx, block, left) {
125
+ if (block && /^---[ \t]*$/m.test(block)) {
126
+ warn(ctx, "frontmatter holds a --- line, which ends it early in Markdown");
127
+ }
128
+ const drawer = left[0]?.type === "property-drawer";
129
+ if (drawer) {
130
+ warn(ctx, "file-level drawer stays text: the frontmatter cannot take it");
62
131
  }
63
- catch {
64
- return value;
132
+ if (left[drawer ? 1 : 0]?.type === "keyword") {
133
+ warn(ctx, "leading keywords stay lines: the frontmatter cannot take them");
65
134
  }
66
135
  }
67
136
  function isTaskList(node) {
@@ -61,17 +61,28 @@ function transformUniorgList(ctx, node) {
61
61
  return {
62
62
  type: "list",
63
63
  ordered,
64
- start: ordered ? parseInt(firstBullet, 10) || 1 : null,
64
+ start: ordered ? parseInt(firstBullet, 10) : null,
65
65
  spread: false,
66
66
  children: run.map(item => transformUniorgListItem(ctx, item))
67
67
  };
68
68
  });
69
69
  }
70
+ // uniorg keeps a list item's indentation in its code blocks' values;
71
+ // md indents them itself. Only that much is dropped, as uniorg-stringify
72
+ // does, so the code keeps its own
73
+ function outdent(value, level) {
74
+ return value.replace(new RegExp(`^ {0,${level}}`, "gm"), "");
75
+ }
70
76
  function transformUniorgListItem(ctx, item) {
71
77
  // md has no descriptive lists, so keep the ` :: ` syntax literally in the
72
78
  // item text; the return trip re-parses it as a descriptive list
73
79
  const tag = listItemTag(item);
74
80
  const children = transformNodes(ctx, (item.children || []).filter(child => child !== tag));
81
+ for (const child of children) {
82
+ if (child.type === "code") {
83
+ child.value = outdent(child.value, item.indent + item.bullet.length);
84
+ }
85
+ }
75
86
  if (tag) {
76
87
  const term = {
77
88
  type: "text",
@@ -1,9 +1,16 @@
1
1
  import { toString as orgastToString } from "orgast-util-to-string";
2
2
  import { htmlEnabled, orgNodeToText, warn } from "./shared.js";
3
3
  import { transformFootnoteReference } from "./footnotes.js";
4
+ import { unescapeOrgPath } from "../orgPath.js";
4
5
  const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i;
5
6
  export function transformUniorgObjects(ctx, children) {
6
7
  return (children || [])
8
+ .map((child, index) =>
9
+ // uniorg keeps the continuation line's indentation after a line
10
+ // break; md would render it as leading whitespace
11
+ child.type === "text" && children?.[index - 1]?.type === "line-break"
12
+ ? { ...child, value: child.value.replace(/^[ \t]+/, "") }
13
+ : child)
7
14
  .flatMap(child => transformUniorgObjectToMdastPhrasingContent(ctx, child))
8
15
  .filter(Boolean);
9
16
  }
@@ -74,12 +81,43 @@ function transformUniorgObjectToMdastPhrasingContent(ctx, node) {
74
81
  return null;
75
82
  }
76
83
  }
84
+ // the inverse of md→org's decoding: % and # would be read as an escape
85
+ // or the anchor, and a markdown url cannot hold a bare space. Org path
86
+ // escapes (`[`, `]`, `:`) are undone first rather than escaped twice
87
+ function encodeUrlPart(part) {
88
+ return unescapeOrgPath(part)
89
+ .replaceAll("%", "%25")
90
+ .replaceAll("#", "%23")
91
+ .replaceAll(" ", "%20");
92
+ }
93
+ // a path starting like `a:` would read as a url scheme in markdown
94
+ function encodeUrlPath(path) {
95
+ const encoded = encodeUrlPart(path);
96
+ return /^[a-z][a-z0-9+.-]*:/i.test(encoded)
97
+ ? encoded.replaceAll(":", "%3A")
98
+ : encoded;
99
+ }
100
+ // an org file: link (or a ./ path) is a relative markdown link, the
101
+ // search option becoming the #anchor
102
+ function markdownUrl(node) {
103
+ if (node.linkType !== "file") {
104
+ return node.rawLink;
105
+ }
106
+ const target = node.rawLink.replace(/^file:/, "");
107
+ const search = target.indexOf("::");
108
+ return search === -1
109
+ ? encodeUrlPath(target)
110
+ : `${encodeUrlPath(target.slice(0, search))}#${encodeUrlPart(target.slice(search + 2))}`;
111
+ }
77
112
  function transformUniorgLink(ctx, node) {
78
113
  const descriptionText = orgastToString(node);
114
+ const url = markdownUrl(node);
79
115
  // org has no dedicated image syntax; the common convention is a
80
- // link to an image file, so map those to markdown images
81
- if (IMAGE_EXTENSION_RE.test(node.rawLink)) {
82
- return { type: "image", url: node.rawLink, alt: descriptionText };
116
+ // link to an image file, so map those to markdown images; a file
117
+ // link's ::search option is no part of the file name
118
+ const path = node.linkType === "file" ? node.rawLink.replace(/::.*$/s, "") : node.rawLink;
119
+ if (IMAGE_EXTENSION_RE.test(path)) {
120
+ return { type: "image", url, alt: descriptionText };
83
121
  }
84
122
  // a description equal to the url (a common Logseq pattern) is no
85
123
  // description: text === url makes remark-stringify emit an
@@ -100,8 +138,8 @@ function transformUniorgLink(ctx, node) {
100
138
  withoutMarkers(serializedDescription) === withoutMarkers(node.rawLink)) {
101
139
  return {
102
140
  type: "link",
103
- url: node.rawLink,
104
- children: [{ type: "text", value: node.rawLink }]
141
+ url,
142
+ children: [{ type: "text", value: url }]
105
143
  };
106
144
  }
107
145
  const children = transformUniorgObjects(ctx, node.children);
@@ -110,7 +148,7 @@ function transformUniorgLink(ctx, node) {
110
148
  const flattened = children.some(child => child.type === "link" || child.type === "image")
111
149
  ? [{ type: "text", value: descriptionText }]
112
150
  : children;
113
- return { type: "link", url: node.rawLink, children: flattened };
151
+ return { type: "link", url, children: flattened };
114
152
  }
115
153
  function transformScriptMarkup(ctx, node) {
116
154
  // markdown has no equivalents; with useHtml render as raw html
@@ -6,6 +6,11 @@ export interface UniorgToMdastOptions {
6
6
  taskCheckboxes?: boolean;
7
7
  orgismKeys?: Record<string, string>;
8
8
  onWarning?: (message: string) => void;
9
+ frontmatter?: string;
10
+ fileProperties?: [string, string][];
11
+ takesMorgEntries?: boolean;
12
+ startsWithModeLine?: boolean;
13
+ org?: string;
9
14
  }
10
15
  export interface TransformContext {
11
16
  options: UniorgToMdastOptions;
@@ -1,4 +1,4 @@
1
- import { toString as orgastToString } from "orgast-util-to-string";
1
+ import { affiliatedEntries } from "../affiliated.js";
2
2
  import { unified } from "unified";
3
3
  import { uniorgStringify } from "uniorg-stringify";
4
4
  import { toggleEnabled } from "../../options.js";
@@ -41,15 +41,6 @@ export function trimTrailingNewline(value) {
41
41
  // affiliated keywords (#+CAPTION:, #+NAME:, #+ATTR_*) precede their
42
42
  // element as verbatim lines so the return trip re-attaches them natively
43
43
  export function affiliatedLines(node) {
44
- const affiliated = node
45
- .affiliated;
46
- return Object.entries(affiliated ?? {}).flatMap(([key, value]) => {
47
- const entries = Array.isArray(value) ? value : [value];
48
- return entries.map(entry => {
49
- const text = Array.isArray(entry)
50
- ? entry.map(child => orgastToString(child)).join("")
51
- : String(entry);
52
- return `#+${key}: ${text}`;
53
- });
54
- });
44
+ const affiliated = node.affiliated;
45
+ return affiliatedEntries(affiliated).map(([key, value]) => `#+${key}: ${value}`);
55
46
  }
@@ -16,12 +16,18 @@ function isAlignmentCookieRow(row) {
16
16
  cells.some(cell => cellText(cell) !== ""));
17
17
  }
18
18
  // org cell content keeps the aligning whitespace padding; markdown
19
- // cells are re-padded by the stringifier
20
- function trimCellPadding(node) {
21
- if (node.type === "text") {
22
- node.value = node.value.trim();
19
+ // cells are re-padded by the stringifier. Only the cell's edges are
20
+ // padding: a space between text and markup is content
21
+ function trimCellPadding(children) {
22
+ const first = children[0];
23
+ if (first?.type === "text") {
24
+ first.value = first.value.trimStart();
23
25
  }
24
- return node;
26
+ const last = children.at(-1);
27
+ if (last?.type === "text") {
28
+ last.value = last.value.trimEnd();
29
+ }
30
+ return children;
25
31
  }
26
32
  export function transformTable(ctx, node) {
27
33
  if (node.tableType === "table.el") {
@@ -59,7 +65,7 @@ export function transformTable(ctx, node) {
59
65
  type: "tableRow",
60
66
  children: (row.children || []).map(cell => ({
61
67
  type: "tableCell",
62
- children: transformUniorgObjects(ctx, cell.children).map(trimCellPadding)
68
+ children: trimCellPadding(transformUniorgObjects(ctx, cell.children))
63
69
  }))
64
70
  }))
65
71
  };
@@ -5,8 +5,16 @@ import remarkFrontmatter from "remark-frontmatter";
5
5
  import remarkMath from "remark-math";
6
6
  import { uniorgStringify } from "uniorg-stringify";
7
7
  import { visit } from "unist-util-visit";
8
- import { transformMdastToUniorgAst } from "./core/mdastToUniorg/index.js";
8
+ import { transformMdastToUniorgDraft } from "./core/mdastToUniorg/index.js";
9
9
  import { detectMarkdownStyle, STYLE_KEYWORD } from "./core/markdownStyle.js";
10
+ import { escapeOrgMarkup } from "./core/markupBoundary.js";
11
+ import { renderFileHeader } from "./core/frontmatterBlock.js";
12
+ import { escapeLineSyntax } from "./core/lineSyntax.js";
13
+ import { escapeFootnoteReferences } from "./core/footnoteReferences.js";
14
+ import { escapeBackslashCommands } from "./core/backslashCommands.js";
15
+ import { escapeTablePipes } from "./core/tablePipes.js";
16
+ import { requireBracedScripts } from "./core/bracedScripts.js";
17
+ import { keyValueEntries } from "./core/keyValueLines.js";
10
18
  /**
11
19
  * Converts a Markdown string to an Org-mode string.
12
20
  * @param markdown The Markdown string to convert.
@@ -15,14 +23,9 @@ import { detectMarkdownStyle, STYLE_KEYWORD } from "./core/markdownStyle.js";
15
23
  */
16
24
  export function convertMarkdownToOrg(markdown, options = {}) {
17
25
  // Phase 1: Parse Markdown to mdast
18
- const mdast = unified()
19
- .use(remarkParse)
20
- .use(remarkGfm)
21
- .use(remarkFrontmatter)
22
- .use(remarkMath)
23
- .parse(markdown);
26
+ const mdast = parseMarkdown(markdown, options.preset);
24
27
  // Phase 2: Generic mdast to uniorg-ast transformation
25
- let uniorgAst = transformMdastToUniorgAst(mdast, {
28
+ let uniorgAst = transformMdastToUniorgDraft(mdast, {
26
29
  ...(options.preserveMdisms !== undefined && {
27
30
  preserveMdisms: options.preserveMdisms
28
31
  }),
@@ -52,12 +55,43 @@ export function convertMarkdownToOrg(markdown, options = {}) {
52
55
  if (options.preset?.applyToUniorg) {
53
56
  uniorgAst = options.preset.applyToUniorg(uniorgAst);
54
57
  }
58
+ // Phase 3b: literal footnote references, paragraph lines org would
59
+ // read as line syntax, literal markers org would read as markup, and
60
+ // markup touching a word character, need a zero-width space escape;
61
+ // a pipe in a table cell an entity; a literal backslash before a
62
+ // letter first, the entity being none
63
+ escapeBackslashCommands(uniorgAst);
64
+ escapeTablePipes(uniorgAst, options.onWarning);
65
+ escapeFootnoteReferences(uniorgAst);
66
+ escapeLineSyntax(uniorgAst);
67
+ escapeOrgMarkup(uniorgAst);
68
+ // Phase 3c: md text has no sub/superscripts; keep org from reading
69
+ // bare underscores and carets as such. Checked on the text as
70
+ // rendered: after the preset, whose text rewrites (wikilink aliases)
71
+ // org parses too, and after the escapes, next to which org reads them
72
+ requireBracedScripts(uniorgAst);
73
+ // Phase 3d: the file's header (ADR 0005): mode line and file-level
74
+ // drawer first, keywords apart from what follows, the frontmatter as
75
+ // a marked comment block; raw text, past every pass that rewrites text
76
+ renderFileHeader(uniorgAst);
55
77
  // Phase 4: Render uniorg-ast to Org-mode string
56
78
  const processor = unified().use(uniorgStringify);
57
79
  const orgContent = processor.stringify(uniorgAst);
58
80
  return orgContent;
59
81
  }
60
- // leads the document so it survives the frontmatter keywords following it
82
+ // a preset's dialect conventions that need the Markdown source apply
83
+ // to the parse, before the generic transform
84
+ function parseMarkdown(markdown, preset) {
85
+ const mdast = unified()
86
+ .use(remarkParse)
87
+ .use(remarkGfm)
88
+ .use(remarkFrontmatter)
89
+ .use(remarkMath)
90
+ .parse(markdown);
91
+ preset?.applyToMdast?.(mdast, markdown);
92
+ return mdast;
93
+ }
94
+ // leads the document, ahead of restored keywords and the frontmatter block
61
95
  function recordStyleKeyword(uniorgAst, style) {
62
96
  if (!Object.keys(style).length) {
63
97
  return;
@@ -68,7 +102,6 @@ function recordStyleKeyword(uniorgAst, style) {
68
102
  value: JSON.stringify(style)
69
103
  });
70
104
  }
71
- const KEY_VALUE_LINE_RE = /^([\w-]+):: (.*)$/;
72
105
  function parseKeyValueParagraph(node) {
73
106
  if (node?.type !== "paragraph" || !node.children?.length) {
74
107
  return null;
@@ -76,19 +109,7 @@ function parseKeyValueParagraph(node) {
76
109
  if (!node.children.every(child => child.type === "text")) {
77
110
  return null;
78
111
  }
79
- const lines = node.children
80
- .map(child => child.value ?? "")
81
- .join("")
82
- .split("\n");
83
- const entries = [];
84
- for (const line of lines) {
85
- const match = KEY_VALUE_LINE_RE.exec(line);
86
- if (!match) {
87
- return null;
88
- }
89
- entries.push([match[1], match[2]]);
90
- }
91
- return entries;
112
+ return keyValueEntries(node.children.map(child => child.value ?? "").join(""));
92
113
  }
93
114
  function makeTimestamp(rawValue) {
94
115
  return { type: "timestamp", rawValue };
@@ -1,11 +1,27 @@
1
1
  import { unified } from "unified";
2
- import uniorgParse from "uniorg-parse";
3
2
  import remarkStringify from "remark-stringify";
4
3
  import remarkGfm from "remark-gfm";
5
4
  import remarkFrontmatter from "remark-frontmatter";
6
5
  import remarkMath from "remark-math";
7
6
  import { transformUniorgAstToMdast } from "./core/uniorgToMdast/index.js";
8
7
  import { takeRecordedStyle } from "./core/markdownStyle.js";
8
+ import { takeFileHeader } from "./core/frontmatterBlock.js";
9
+ import { unescapeOrgMarkup } from "./core/markupBoundary.js";
10
+ import { unescapeLineSyntax } from "./core/lineSyntax.js";
11
+ import { unescapeFootnoteReferences } from "./core/footnoteReferences.js";
12
+ import { unescapeBackslashCommands } from "./core/backslashCommands.js";
13
+ import { unescapeTablePipes } from "./core/tablePipes.js";
14
+ import { parseOrg } from "./core/bracedScripts.js";
15
+ import { dropUnderscoreBulletGuards, guardUnderscoreBullets } from "./core/underscoreBullets.js";
16
+ // a list item's paragraph after its nested list needs a blank line, or
17
+ // md reads it as a lazy continuation of the nested list's last item
18
+ function separateTextAfterNestedList(left, right, parent) {
19
+ return parent.type === "listItem" &&
20
+ left.type === "list" &&
21
+ right.type === "paragraph"
22
+ ? 1
23
+ : undefined;
24
+ }
9
25
  /**
10
26
  * Converts an Org-mode string to a Markdown string.
11
27
  * @param org The Org-mode string to convert.
@@ -14,10 +30,23 @@ import { takeRecordedStyle } from "./core/markdownStyle.js";
14
30
  */
15
31
  export function convertOrgToMarkdown(org, options = {}) {
16
32
  // Phase 1: Parse Org-mode to uniorg-ast
17
- let uniorgAst = unified().use(uniorgParse).parse(org);
33
+ // md text has no scripts, so ^:{} is implied there and consumed here
34
+ // (see markdownToOrg); uniorg misreads `_.` lines (see underscoreBullets)
35
+ const guarded = guardUnderscoreBullets(org);
36
+ let uniorgAst = parseOrg(guarded);
37
+ dropUnderscoreBulletGuards(uniorgAst);
18
38
  // Phase 1b: a recorded style is morg's own (ADR 0004), so consume it so
19
39
  // it does not travel on as frontmatter; explicit options still win
20
40
  const recordedStyle = takeRecordedStyle(uniorgAst);
41
+ // frontmatter travels in a block marked as morg's (ADR 0005)
42
+ // and so does a file-level drawer (org-roam's :ID:)
43
+ const fileHeader = takeFileHeader(uniorgAst, guarded, options.onWarning);
44
+ // Phase 1c: markdown needs no zero-width space escapes (inverse of md→org)
45
+ unescapeOrgMarkup(uniorgAst);
46
+ unescapeLineSyntax(uniorgAst);
47
+ unescapeFootnoteReferences(uniorgAst);
48
+ unescapeBackslashCommands(uniorgAst);
49
+ unescapeTablePipes(uniorgAst);
21
50
  // Phase 2: Extract dialect preset conventions, if any
22
51
  if (options.preset?.extractFromUniorg) {
23
52
  uniorgAst = options.preset.extractFromUniorg(uniorgAst);
@@ -34,7 +63,8 @@ export function convertOrgToMarkdown(org, options = {}) {
34
63
  ...(options.orgismKeys !== undefined && {
35
64
  orgismKeys: options.orgismKeys
36
65
  }),
37
- ...(options.onWarning !== undefined && { onWarning: options.onWarning })
66
+ ...(options.onWarning !== undefined && { onWarning: options.onWarning }),
67
+ ...fileHeader
38
68
  });
39
69
  // Phase 4: Render mdast to Markdown string
40
70
  // bullet and rule "-" (not remark's default "*") are morg's canonical
@@ -46,11 +76,15 @@ export function convertOrgToMarkdown(org, options = {}) {
46
76
  rule: "-",
47
77
  ...recordedStyle,
48
78
  ...options.markdownStyle,
79
+ join: [separateTextAfterNestedList],
49
80
  handlers: {
50
81
  // key:: value blocks and preset inline passthroughs (e.g.
51
- // wikilinks) are emitted verbatim, unescaped
82
+ // wikilinks) are emitted verbatim, unescaped; only a pipe inside
83
+ // a table cell is escaped, or it would split the cell
52
84
  keyValue: (node) => node.value,
53
- verbatimInline: (node) => node.value
85
+ verbatimInline: (node, _parent, state) => state.stack.includes("tableCell")
86
+ ? node.value.replaceAll("|", "\\|")
87
+ : node.value
54
88
  }
55
89
  })
56
90
  .use(remarkGfm)