@remigius42/morg 0.9.2 → 0.10.1

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.
@@ -1,5 +1,7 @@
1
1
  import { visit } from "unist-util-visit";
2
2
  import { toString } from "orgast-util-to-string";
3
+ import { maskCode } from "../core/outsideCode.js";
4
+ import { FUZZY_LINK_RE } from "./links.js";
3
5
  /**
4
6
  * Obsidian dialect preset: `[[Page]]` / `[[Page|alias]]` wikilinks map
5
7
  * to org fuzzy links (`[[Page]]` / `[[Page][alias]]`).
@@ -9,16 +11,29 @@ export function obsidian() {
9
11
  name: "obsidian",
10
12
  markdown: {
11
13
  read: { org: rewriteAliasedWikilinks },
12
- write: fuzzyLinksToWikilinks
13
- }
14
+ write: fuzzyLinksToWikilinks,
15
+ links: {
16
+ read: text => text.replace(ALIASED_PAGE_LINK_RE, "[[$1][$2]]"),
17
+ // a bare `|` would split a table's cell
18
+ write: (text, inTable) => text.replace(FUZZY_LINK_RE, inTable ? "[[$1\\|$2]]" : "[[$1|$2]]")
19
+ }
20
+ },
21
+ // Vanilla Markdown reads Obsidian's own syntax but for these; the
22
+ // way back has nothing to do
23
+ translateMarkdown: (markdown, context) => context.side === "input" ? toVanilla(markdown) : markdown
14
24
  };
15
25
  }
26
+ // not an embed, whose `|300` is a size
27
+ const ALIASED_WIKILINK_RE = /(?<!!)\[\[([^\][|]+)\|([^\][]+)\]\]/g;
28
+ // in Markdown text: not an embed, whose `|300` is a size, and with a
29
+ // table cell's escaped pipe (`[[Page\|alias]]`)
30
+ const ALIASED_PAGE_LINK_RE = /(?<!!)\[\[([^\][|\\]+)\\?\|([^\][]+)\]\]/g;
16
31
  // md→org: a wikilink travels as plain text; org already reads `[[Page]]`
17
32
  // as a fuzzy link, only the `[[Page|alias]]` form needs rewriting to
18
33
  // org's `[[Page][alias]]` description syntax
19
34
  function rewriteAliasedWikilinks(uniorgAst) {
20
35
  visit(uniorgAst, "text", (node) => {
21
- node.value = node.value.replace(/\[\[([^\][|]+)\|([^\][]+)\]\]/g, "[[$1][$2]]");
36
+ node.value = node.value.replace(ALIASED_WIKILINK_RE, "[[$1][$2]]");
22
37
  });
23
38
  return uniorgAst;
24
39
  }
@@ -30,13 +45,69 @@ function fuzzyLinksToWikilinks(uniorgAst) {
30
45
  return undefined;
31
46
  }
32
47
  const description = node.children.length ? toString(node) : "";
48
+ // an embed's `!` goes with it, else Markdown escapes it before `[`
49
+ const previous = parent.children[index - 1];
50
+ const embed = previous?.type === "text" && previous.value.endsWith("!");
51
+ if (embed) {
52
+ previous.value = previous.value.slice(0, -1);
53
+ }
33
54
  parent.children[index] = {
34
55
  type: "verbatim-inline",
35
- value: description
56
+ value: `${embed ? "!" : ""}${description
36
57
  ? `[[${node.rawLink}|${description}]]`
37
- : `[[${node.rawLink}]]`
58
+ : `[[${node.rawLink}]]`}`
38
59
  };
39
60
  return undefined;
40
61
  });
41
62
  return uniorgAst;
42
63
  }
64
+ const COMMENT_RE = /%%([\s\S]*?)%%/g;
65
+ const FOOTNOTE_LABEL_RE = /\[\^(\d+)\]/g;
66
+ // Obsidian Markdown → Vanilla Markdown: a comment is an HTML comment,
67
+ // an inline footnote a footnote, numbered on from the page's own, its
68
+ // definition at the end; their delimiters count outside code only, what
69
+ // they hold may be code
70
+ function toVanilla(markdown) {
71
+ let masked = maskCode(markdown);
72
+ const edits = [];
73
+ for (const { index, 0: comment } of masked.matchAll(COMMENT_RE)) {
74
+ const end = index + comment.length;
75
+ edits.push([index, end, `<!--${markdown.slice(index + 2, end - 2)}-->`]);
76
+ // a footnote in a comment is part of it
77
+ masked =
78
+ masked.slice(0, index) + "\0".repeat(comment.length) + masked.slice(end);
79
+ }
80
+ let next = Math.max(0, ...[...markdown.matchAll(FOOTNOTE_LABEL_RE)].map(([, n]) => Number(n))) + 1;
81
+ const definitions = [];
82
+ for (const [start, end] of inlineFootnotes(masked)) {
83
+ definitions.push(`[^${next}]: ${markdown.slice(start + 2, end)}`);
84
+ edits.push([start, end + 1, `[^${next++}]`]);
85
+ }
86
+ let result = markdown;
87
+ for (const [start, end, text] of edits.sort((a, b) => b[0] - a[0])) {
88
+ result = result.slice(0, start) + text + result.slice(end);
89
+ }
90
+ return definitions.length
91
+ ? `${result.replace(/\n*$/, "")}\n\n${definitions.join("\n")}\n`
92
+ : result;
93
+ }
94
+ // each `^[note]`, its brackets balanced: where it starts, and its `]`
95
+ function inlineFootnotes(text) {
96
+ const notes = [];
97
+ for (let start = text.indexOf("^["); start !== -1;) {
98
+ let depth = 0;
99
+ let end = start + 1;
100
+ for (; end < text.length; end++) {
101
+ depth += text[end] === "[" ? 1 : text[end] === "]" ? -1 : 0;
102
+ if (!depth) {
103
+ break;
104
+ }
105
+ }
106
+ if (end === text.length) {
107
+ break;
108
+ }
109
+ notes.push([start, end]);
110
+ start = text.indexOf("^[", end + 1);
111
+ }
112
+ return notes;
113
+ }
@@ -7,7 +7,9 @@ import type { OrgData } from "uniorg";
7
7
  * `convertMarkdown` take a whole conversion over, for a dialect whose
8
8
  * documents are no single org or Markdown document (an outline of
9
9
  * blocks, each its own fragment); `convert` runs the core on a fragment,
10
- * with the given preset's hooks.
10
+ * with the given preset's hooks. `translateOrg` and `translateMarkdown`
11
+ * translate a page between the preset's dialect of the format and
12
+ * Vanilla.
11
13
  */
12
14
  export interface Preset {
13
15
  name: string;
@@ -15,6 +17,8 @@ export interface Preset {
15
17
  org?: OrgDialect;
16
18
  convertOrg?: (org: string, convert: FragmentConverter, context: ConversionContext) => string;
17
19
  convertMarkdown?: (markdown: string, convert: FragmentConverter, context: ConversionContext) => string;
20
+ translateOrg?: (org: string, context: ConversionContext) => string;
21
+ translateMarkdown?: (markdown: string, context: ConversionContext) => string;
18
22
  }
19
23
  /**
20
24
  * What a preset that takes a whole conversion over learns of it: the
@@ -25,6 +29,11 @@ export interface ConversionContext {
25
29
  side: "both" | "input" | "output";
26
30
  onWarning?: (message: string) => void;
27
31
  orgismKeys?: Record<string, string>;
32
+ /**
33
+ * A translation's page links from the input's dialect into the
34
+ * output's, in a table's text or not.
35
+ */
36
+ relink?: (text: string, inTable: boolean) => string;
28
37
  }
29
38
  /**
30
39
  * Converts a fragment with the core, the given preset's AST hooks on
@@ -44,6 +53,17 @@ export interface MarkdownDialect {
44
53
  org?: (uniorgAst: OrgData) => OrgData;
45
54
  };
46
55
  write?: (uniorgAst: OrgData) => OrgData;
56
+ /**
57
+ * Its page links in Markdown text to org's fuzzy link syntax
58
+ * (`[[Page][label]]`) and back, for a translation between two
59
+ * dialects; `write` is told if the text is in a table.
60
+ */
61
+ links?: {
62
+ read: (text: string) => string;
63
+ write: (text: string, inTable: boolean) => string;
64
+ };
65
+ /** The bullet its outline needs, which no style may change. */
66
+ bullet?: "-" | "*" | "+";
47
67
  }
48
68
  /**
49
69
  * A preset's org dialect. `read` runs in org→md before the generic
@@ -0,0 +1,34 @@
1
+ import type { MarkdownStyleOptions } from "./options.js";
2
+ import { type PresetOptions } from "./presets/sides.js";
3
+ /**
4
+ * The presets of a translation, its warning callback and the org-ism
5
+ * key names Vanilla Markdown writes planning under.
6
+ */
7
+ export type TranslateOptions = PresetOptions & {
8
+ onWarning?: (message: string) => void;
9
+ orgismKeys?: Record<string, string>;
10
+ /** The Markdown markers to write; others stay as written. */
11
+ markdownStyle?: MarkdownStyleOptions;
12
+ };
13
+ /**
14
+ * Translates an Org string from the Input Preset's dialect into the
15
+ * Output Preset's, changing only what the two dialects write
16
+ * differently (ADR 0006): a block's content is kept as written.
17
+ * @param org The Org string to translate.
18
+ * @param options The preset of each side, which must differ.
19
+ * @returns The Org string in the output's dialect.
20
+ * @throws If both sides name the same preset (that is `normalizeOrg`),
21
+ * or a side preset has no org dialect.
22
+ */
23
+ export declare function translateOrg(org: string, options?: TranslateOptions): string;
24
+ /**
25
+ * Translates a Markdown string from the Input Preset's dialect into the
26
+ * Output Preset's, changing only what the two dialects write
27
+ * differently (ADR 0006): a block's content is kept as written.
28
+ * @param markdown The Markdown string to translate.
29
+ * @param options The preset of each side, which must differ.
30
+ * @returns The Markdown string in the output's dialect.
31
+ * @throws If both sides name the same preset (that is
32
+ * `normalizeMarkdown`).
33
+ */
34
+ export declare function translateMarkdown(markdown: string, options?: TranslateOptions): string;
@@ -0,0 +1,82 @@
1
+ import { restyleMarkdown } from "./core/restyle.js";
2
+ import { resolveSides } from "./presets/sides.js";
3
+ /**
4
+ * Translates an Org string from the Input Preset's dialect into the
5
+ * Output Preset's, changing only what the two dialects write
6
+ * differently (ADR 0006): a block's content is kept as written.
7
+ * @param org The Org string to translate.
8
+ * @param options The preset of each side, which must differ.
9
+ * @returns The Org string in the output's dialect.
10
+ * @throws If both sides name the same preset (that is `normalizeOrg`),
11
+ * or a side preset has no org dialect.
12
+ */
13
+ export function translateOrg(org, options = {}) {
14
+ const { input, output } = twoSides(options, "org", "normalizeOrg");
15
+ // Vanilla has no dialect to translate, so the other side does it
16
+ const side = input?.translateOrg ? "input" : "output";
17
+ const translate = (side === "input" ? input : output)?.translateOrg;
18
+ return translate
19
+ ? translate(org, {
20
+ side,
21
+ ...(options.onWarning && { onWarning: options.onWarning })
22
+ })
23
+ : org;
24
+ }
25
+ /**
26
+ * Translates a Markdown string from the Input Preset's dialect into the
27
+ * Output Preset's, changing only what the two dialects write
28
+ * differently (ADR 0006): a block's content is kept as written.
29
+ * @param markdown The Markdown string to translate.
30
+ * @param options The preset of each side, which must differ.
31
+ * @returns The Markdown string in the output's dialect.
32
+ * @throws If both sides name the same preset (that is
33
+ * `normalizeMarkdown`).
34
+ */
35
+ export function translateMarkdown(markdown, options = {}) {
36
+ const { input, output } = twoSides(options, "markdown", "normalizeMarkdown");
37
+ const context = hookContext(options, pageLinks({ input, output }));
38
+ // into Vanilla from the input's dialect, then from it into the
39
+ // output's; a dialect does what its side needs
40
+ const vanilla = input?.translateMarkdown?.(markdown, { ...context, side: "input" }) ??
41
+ markdown;
42
+ const translated = output?.translateMarkdown?.(vanilla, { ...context, side: "output" }) ??
43
+ vanilla;
44
+ const style = outputStyle(options, output);
45
+ return style ? restyleMarkdown(translated, style) : translated;
46
+ }
47
+ // the style to write, but for a bullet the output's outline needs
48
+ function outputStyle({ markdownStyle, onWarning }, output) {
49
+ const needed = output?.markdown?.bullet;
50
+ if (!markdownStyle?.bullet || !needed || markdownStyle.bullet === needed) {
51
+ return markdownStyle;
52
+ }
53
+ onWarning?.(`${output.name} Markdown writes its blocks with '${needed}'; bullet '${markdownStyle.bullet}' not applied`);
54
+ const style = { ...markdownStyle };
55
+ delete style.bullet;
56
+ return style;
57
+ }
58
+ // what a dialect's translation learns of the options
59
+ function hookContext({ onWarning, orgismKeys }, relink) {
60
+ return {
61
+ ...(onWarning && { onWarning }),
62
+ ...(orgismKeys && { orgismKeys }),
63
+ ...(relink && { relink })
64
+ };
65
+ }
66
+ // page links between two dialects; a Vanilla side carries the other's
67
+ function pageLinks({ input, output }) {
68
+ const read = input?.markdown?.links?.read;
69
+ const write = output?.markdown?.links?.write;
70
+ return read && write
71
+ ? (text, inTable) => write(read(text), inTable)
72
+ : undefined;
73
+ }
74
+ // a translation is between two dialects; one on both sides normalizes
75
+ function twoSides(options, format, normalize) {
76
+ const sides = resolveSides(options, format, format);
77
+ const name = sides.input?.name ?? "vanilla";
78
+ if (name === (sides.output?.name ?? "vanilla")) {
79
+ throw new Error(`translation takes two presets; for '${name}' on both sides, use ${normalize}`);
80
+ }
81
+ return sides;
82
+ }
@@ -0,0 +1,12 @@
1
+ import type { NormalizeOptions } from "./normalize.js";
2
+ import type { Format } from "./presets/sides.js";
3
+ /**
4
+ * Converts within one format: two presets translate between their
5
+ * dialects, one normalizes (ADR 0006). What both adapters do with one
6
+ * format on both sides.
7
+ * @param text The document.
8
+ * @param format Its format, on both sides.
9
+ * @param options The conversion's options, presets included.
10
+ * @returns The translated or normalized document.
11
+ */
12
+ export declare function convertWithinFormat(text: string, format: Format, options?: NormalizeOptions): string;
@@ -0,0 +1,18 @@
1
+ import { normalizeMarkdown, normalizeOrg } from "./normalize.js";
2
+ import { translateMarkdown, translateOrg } from "./translate.js";
3
+ /**
4
+ * Converts within one format: two presets translate between their
5
+ * dialects, one normalizes (ADR 0006). What both adapters do with one
6
+ * format on both sides.
7
+ * @param text The document.
8
+ * @param format Its format, on both sides.
9
+ * @param options The conversion's options, presets included.
10
+ * @returns The translated or normalized document.
11
+ */
12
+ export function convertWithinFormat(text, format, options = {}) {
13
+ const markdown = format === "markdown";
14
+ if (options.inputPreset || options.outputPreset) {
15
+ return (markdown ? translateMarkdown : translateOrg)(text, options);
16
+ }
17
+ return (markdown ? normalizeMarkdown : normalizeOrg)(text, options);
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remigius42/morg",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Bidirectional Markdown ↔ Org-mode converter with round-trip convergence",
5
5
  "keywords": [
6
6
  "org-mode",
@@ -40,7 +40,7 @@
40
40
  "provenance": true
41
41
  },
42
42
  "engines": {
43
- "node": ">=20"
43
+ "node": ">=24"
44
44
  },
45
45
  "scripts": {
46
46
  "prepare": "husky",