@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
package/README.md CHANGED
@@ -37,6 +37,13 @@ guarantee is to be **semantically faithful and convergent** instead
37
37
  org properties; `org → md` serializes Org-only constructs ("org-isms") as
38
38
  `key:: value` conventions ([ADR
39
39
  0002](docs/adr/0002-mdism-property-namespace.md)).
40
+ - Frontmatter travels verbatim and inert in a
41
+ `#+begin_comment morg_frontmatter` block, not as org keywords, which
42
+ can act in Emacs; an org file's own leading keywords travel as a
43
+ `morg_keywords` frontmatter entry and come back as keywords, a
44
+ file-level drawer (org-roam's `:ID:`) as `morg_properties`. The
45
+ Logseq preset instead maps page properties natively ([ADR
46
+ 0005](docs/adr/0005-frontmatter-as-a-marked-comment-block.md)).
40
47
  - The few constructs that cannot be carried are documented in the
41
48
  [mapping reference](docs/mappings.md) and reported as warnings.
42
49
 
@@ -198,7 +205,11 @@ preset })`: `preserveOrgisms` default `true`; `useHtml` (default
198
205
  reverse direction restores headings from `:heading:` properties and
199
206
  turns plain block headlines back into paragraphs. Hiccup blocks
200
207
  (`[:div …]`) pass through as plain text and are emitted unescaped in
201
- Markdown. Logseq's own syntax maps both directions: `TODO`/`DONE`
208
+ Markdown. Page properties map both directions: a first block of
209
+ `key:: value` lines and flat frontmatter entries ↔ leading
210
+ `#+key: value` lines, which Logseq reads as page properties;
211
+ frontmatter keys that act in Emacs (`todo`, `include`, …) stay inert.
212
+ Logseq's own syntax maps both directions: `TODO`/`DONE`
202
213
  text markers and `[#A]` priorities ↔ org keywords/priorities, page
203
214
  references `[[page]]` and labeled forms `[label]([[page]])` ↔ org
204
215
  fuzzy links `[[page][label]]`, block refs `[label](((uuid)))` ↔
@@ -0,0 +1,13 @@
1
+ import type { AffiliatedKeywords } from "uniorg";
2
+ export declare const DUAL_NAMES = "CAPTION|RESULTS";
3
+ /**
4
+ * An element's affiliated keywords as key, value pairs, the way they are
5
+ * written above it: several values (#+CAPTION, #+HEADER, #+ATTR_*) as
6
+ * several pairs, parsed ones (#+CAPTION) as org text, a dual value as
7
+ * part of the key (`CAPTION[short]`).
8
+ * @param affiliated The element's affiliated keywords.
9
+ * @param asOrg Parsed values as org text, links and markup kept, not as
10
+ * plain text (a Markdown line above a body element reads markup anew).
11
+ * @returns The pairs in order.
12
+ */
13
+ export declare function affiliatedEntries(affiliated?: AffiliatedKeywords, asOrg?: boolean): [string, string][];
@@ -0,0 +1,43 @@
1
+ import { toString } from "orgast-util-to-string";
2
+ import { renderInline } from "./render.js";
3
+ const MULTIPLE_RE = /^(?:CAPTION|HEADER|ATTR_.*)$/;
4
+ // the keywords org reads with a dual value (`#+CAPTION[short]: long`);
5
+ // aliases (RESULT) are not
6
+ export const DUAL_NAMES = "CAPTION|RESULTS";
7
+ const DUAL_RE = new RegExp(`^(?:${DUAL_NAMES})$`);
8
+ /**
9
+ * An element's affiliated keywords as key, value pairs, the way they are
10
+ * written above it: several values (#+CAPTION, #+HEADER, #+ATTR_*) as
11
+ * several pairs, parsed ones (#+CAPTION) as org text, a dual value as
12
+ * part of the key (`CAPTION[short]`).
13
+ * @param affiliated The element's affiliated keywords.
14
+ * @param asOrg Parsed values as org text, links and markup kept, not as
15
+ * plain text (a Markdown line above a body element reads markup anew).
16
+ * @returns The pairs in order.
17
+ */
18
+ export function affiliatedEntries(affiliated = {}, asOrg = false) {
19
+ return Object.entries(affiliated).flatMap(([key, value]) => (MULTIPLE_RE.test(key) ? value : [value]).map(item => {
20
+ const [main, dual] = DUAL_RE.test(key) && isDual(item) ? item : [item];
21
+ return [
22
+ dual === undefined ? key : `${key}[${dual}]`,
23
+ asOrg ? orgText(main) : plainText(main)
24
+ ];
25
+ }));
26
+ }
27
+ function isDual(value) {
28
+ return (Array.isArray(value) && value.length === 2 && typeof value[1] === "string");
29
+ }
30
+ function plainText(value) {
31
+ if (typeof value === "string") {
32
+ return value;
33
+ }
34
+ return Array.isArray(value)
35
+ ? value.map(plainText).join("")
36
+ : toString(value);
37
+ }
38
+ function orgText(value) {
39
+ const items = Array.isArray(value) ? value : [value];
40
+ return items
41
+ .map(item => (typeof item === "string" ? item : renderInline(item)))
42
+ .join("");
43
+ }
@@ -0,0 +1,10 @@
1
+ import type { Parent } from "unist";
2
+ /**
3
+ * md→org: escapes a literal backslash org would read as a command, but
4
+ * not in a passthrough element's org text (`#+begin_export latex`).
5
+ */
6
+ export declare function escapeBackslashCommands(tree: Parent): void;
7
+ /**
8
+ * org→md: drops the zero-width spaces `escapeBackslashCommands` inserts.
9
+ */
10
+ export declare function unescapeBackslashCommands(tree: Parent): void;
@@ -0,0 +1,43 @@
1
+ import { EXIT, SKIP, visit } from "unist-util-visit";
2
+ import { isPassthroughParagraph } from "./lineSyntax.js";
3
+ import { ZERO_WIDTH_SPACE } from "./markupBoundary.js";
4
+ // org reads a backslash before a letter as an entity (`\alpha`) or a
5
+ // LaTeX fragment (`\Users`, `\foo{x}`), before `(` or `[` as a LaTeX
6
+ // fragment too; a zero-width space after the backslash leaves it text
7
+ const COMMAND_RE = /\\(?=[A-Za-z([])/g;
8
+ const ESCAPED_RE = new RegExp(String.raw `\\${ZERO_WIDTH_SPACE}(?=[A-Za-z([])`, "g");
9
+ /**
10
+ * md→org: escapes a literal backslash org would read as a command, but
11
+ * not in a passthrough element's org text (`#+begin_export latex`).
12
+ */
13
+ export function escapeBackslashCommands(tree) {
14
+ visit(tree, (node, index, parent) => {
15
+ if (holdsCommand(node) &&
16
+ isPassthroughParagraph(node, index ?? 0, parent)) {
17
+ return SKIP;
18
+ }
19
+ if (node.type === "text") {
20
+ const text = node;
21
+ text.value = text.value?.replace(COMMAND_RE, `\\${ZERO_WIDTH_SPACE}`);
22
+ }
23
+ return undefined;
24
+ });
25
+ }
26
+ // whether a text in `node` holds a backslash org would read as a
27
+ // command: the only paragraphs worth a passthrough check
28
+ function holdsCommand(node) {
29
+ let found = false;
30
+ visit(node, "text", (text) => {
31
+ found ||= new RegExp(COMMAND_RE.source).test(text.value ?? "");
32
+ return found ? EXIT : undefined;
33
+ });
34
+ return found;
35
+ }
36
+ /**
37
+ * org→md: drops the zero-width spaces `escapeBackslashCommands` inserts.
38
+ */
39
+ export function unescapeBackslashCommands(tree) {
40
+ visit(tree, "text", (node) => {
41
+ node.value = node.value?.replace(ESCAPED_RE, "\\");
42
+ });
43
+ }
@@ -0,0 +1,12 @@
1
+ import type { OrgData } from "uniorg";
2
+ /**
3
+ * md→org: adds `^:{}` to the document's `#+OPTIONS:` when its text
4
+ * holds a bare underscore or caret org would read as a script.
5
+ */
6
+ export declare function requireBracedScripts(uniorgAst: OrgData): void;
7
+ /**
8
+ * org→md: parses org, honoring its `^:` setting, and consuming `^:{}`
9
+ * where the text needs it: md→org adds it only then, so anywhere else
10
+ * it is the author's own setting.
11
+ */
12
+ export declare function parseOrg(org: string): OrgData;
@@ -0,0 +1,159 @@
1
+ import { unified } from "unified";
2
+ import uniorgParse from "uniorg-parse";
3
+ import { EXIT, visit } from "unist-util-visit";
4
+ import { isInline, orgParser, positionParser, renderChildren, tryParse } from "./render.js";
5
+ // org's `#+OPTIONS: ^:{}` limits sub/superscripts to the braced form
6
+ // (`H_{2}O`), so a bare underscore or caret (`a_b`, `x^y`) stays text.
7
+ // Markdown has no script syntax, so md text needs it; the braced
8
+ // scripts morg itself emits are unaffected
9
+ const BRACED_SCRIPTS = "^:{}";
10
+ // built once: constructing a processor per parse dominates the cost
11
+ const bracedScriptsParser = unified()
12
+ .use(uniorgParse, { useSubSuperscripts: "{}" })
13
+ .freeze();
14
+ function isScript(node) {
15
+ return node.type === "subscript" || node.type === "superscript";
16
+ }
17
+ // a tree uniorg fails to read counts as holding none
18
+ function countScripts(tree) {
19
+ let count = 0;
20
+ if (tree) {
21
+ visit(tree, (node) => {
22
+ if (isScript(node)) {
23
+ count++;
24
+ }
25
+ });
26
+ }
27
+ return count;
28
+ }
29
+ // a block's inline content as org renders it; a block element inside
30
+ // (a list item's nested list) only ends a line
31
+ function renderedContent(node) {
32
+ if (isInline(node) ||
33
+ !("children" in node) ||
34
+ !node.children.some(isInline)) {
35
+ return undefined;
36
+ }
37
+ return renderChildren(node.children).join("");
38
+ }
39
+ // a script org reads without `^:{}` only (uniorg's
40
+ // matchSubstringRegex without the braced form, loosened): a `_` or `^`
41
+ // after a non-blank, then `(`, `*`, or a word ending in a letter or
42
+ // digit. Text without one reads the same either way and skips the parses
43
+ const BARE_SCRIPT_RE = /\S[_^](?:[(*]|[+-]?[\p{L}\p{N}.,\\]*[\p{L}\p{N}])/u;
44
+ // whether `tree` (of `content`) holds a script not in the braced form.
45
+ // uniorg tries the braced form first, so without one both parsers read
46
+ // the same, and the braced parse can be skipped
47
+ function holdsBareScript(tree, content) {
48
+ let found = false;
49
+ visit(tree, (node) => {
50
+ const offset = node.position?.start.offset;
51
+ found =
52
+ isScript(node) && offset !== undefined && content[offset + 1] !== "{";
53
+ return found ? EXIT : undefined;
54
+ });
55
+ return found;
56
+ }
57
+ function readsBareScriptsIn(content) {
58
+ if (!BARE_SCRIPT_RE.test(content)) {
59
+ return false;
60
+ }
61
+ const tree = tryParse(content, positionParser);
62
+ return (tree !== undefined &&
63
+ holdsBareScript(tree, content) &&
64
+ countScripts(tree) > countScripts(tryParse(content, bracedScriptsParser)));
65
+ }
66
+ // whether org reads a script in the text that `^:{}` would keep text.
67
+ // Checked per block as rendered, not per text node: the char before a
68
+ // `_` or `^` may belong to a neighbor (a marker, a link's `]`, an
69
+ // escape); a script never spans blocks
70
+ function readsBareScripts(tree) {
71
+ let found = false;
72
+ visit(tree, (node) => {
73
+ const content = renderedContent(node);
74
+ found = content !== undefined && readsBareScriptsIn(content);
75
+ return found ? EXIT : undefined;
76
+ });
77
+ return found;
78
+ }
79
+ function isOptions(node) {
80
+ return (node.type === "keyword" && node.key.toUpperCase() === "OPTIONS");
81
+ }
82
+ /**
83
+ * md→org: adds `^:{}` to the document's `#+OPTIONS:` when its text
84
+ * holds a bare underscore or caret org would read as a script.
85
+ */
86
+ export function requireBracedScripts(uniorgAst) {
87
+ if (!readsBareScripts(uniorgAst)) {
88
+ return;
89
+ }
90
+ const options = uniorgAst.children.find(isOptions);
91
+ if (!options) {
92
+ uniorgAst.children.unshift({
93
+ type: "keyword",
94
+ key: "OPTIONS",
95
+ value: BRACED_SCRIPTS
96
+ });
97
+ }
98
+ else if (!/(^|\s)\^:/.test(options.value)) {
99
+ // an explicit ^: setting is the author's call
100
+ options.value = `${options.value} ${BRACED_SCRIPTS}`;
101
+ }
102
+ }
103
+ // the `^:` setting in an `#+OPTIONS:` value, if any
104
+ function scriptsSetting(options) {
105
+ const items = options.split(/\s+/).filter(item => item.startsWith("^:"));
106
+ return items.at(-1)?.slice(2);
107
+ }
108
+ // org→md honors every `^:` setting: `{}` limits scripts to the braced
109
+ // form, `nil` turns them off, `t` (org's default) keeps them on
110
+ const scriptsParsers = {
111
+ "{}": bracedScriptsParser,
112
+ nil: unified().use(uniorgParse, { useSubSuperscripts: false }).freeze()
113
+ };
114
+ // the settings the document may use, which the parser has to know up
115
+ // front; only a top-level keyword counts, which takes a parse to tell
116
+ function mayUseScripts(org) {
117
+ const settings = [...org.matchAll(/^[ \t]*#\+options:(.*)$/gim)].map(([, value]) => scriptsSetting(value ?? ""));
118
+ return [...new Set(settings)].filter((setting) => setting !== undefined);
119
+ }
120
+ function usesScripts(uniorgAst, setting) {
121
+ return uniorgAst.children.some(node => isOptions(node) && scriptsSetting(node.value) === setting);
122
+ }
123
+ // drops `^:{}` from the top-level `#+OPTIONS:`, the whole keyword if
124
+ // nothing else is left; md text has no scripts, so the return trip
125
+ // re-adds it wherever it is needed
126
+ function takeBracedScripts(uniorgAst) {
127
+ uniorgAst.children = uniorgAst.children.filter(node => {
128
+ if (!isOptions(node)) {
129
+ return true;
130
+ }
131
+ node.value = node.value
132
+ .split(/\s+/)
133
+ .filter(item => item && item !== BRACED_SCRIPTS)
134
+ .join(" ");
135
+ return node.value !== "";
136
+ });
137
+ }
138
+ /**
139
+ * org→md: parses org, honoring its `^:` setting, and consuming `^:{}`
140
+ * where the text needs it: md→org adds it only then, so anywhere else
141
+ * it is the author's own setting.
142
+ */
143
+ export function parseOrg(org) {
144
+ for (const setting of mayUseScripts(org)) {
145
+ const parser = scriptsParsers[setting];
146
+ if (!parser) {
147
+ continue;
148
+ }
149
+ // keywords parse the same either way
150
+ const uniorgAst = parser.parse(org);
151
+ if (usesScripts(uniorgAst, setting)) {
152
+ if (setting === "{}" && readsBareScripts(uniorgAst)) {
153
+ takeBracedScripts(uniorgAst);
154
+ }
155
+ return uniorgAst;
156
+ }
157
+ }
158
+ return orgParser.parse(org);
159
+ }
@@ -0,0 +1,10 @@
1
+ import type { Parent } from "unist";
2
+ /**
3
+ * md→org: escapes a literal `[fn:` in text.
4
+ */
5
+ export declare function escapeFootnoteReferences(tree: Parent): void;
6
+ /**
7
+ * org→md: drops the zero-width spaces `escapeFootnoteReferences`
8
+ * inserts.
9
+ */
10
+ export declare function unescapeFootnoteReferences(tree: Parent): void;
@@ -0,0 +1,24 @@
1
+ import { visit } from "unist-util-visit";
2
+ import { ZERO_WIDTH_SPACE } from "./markupBoundary.js";
3
+ // org reads `[fn:` in text as a footnote reference (`[fn:1]`, `[fn::x]`,
4
+ // `[fn:a:x]`), at a line start as a definition; a zero-width space after
5
+ // the `[` leaves it text
6
+ const REFERENCE = "[fn:";
7
+ const ESCAPED = `[${ZERO_WIDTH_SPACE}fn:`;
8
+ /**
9
+ * md→org: escapes a literal `[fn:` in text.
10
+ */
11
+ export function escapeFootnoteReferences(tree) {
12
+ visit(tree, "text", (node) => {
13
+ node.value = node.value?.replaceAll(REFERENCE, ESCAPED);
14
+ });
15
+ }
16
+ /**
17
+ * org→md: drops the zero-width spaces `escapeFootnoteReferences`
18
+ * inserts.
19
+ */
20
+ export function unescapeFootnoteReferences(tree) {
21
+ visit(tree, "text", (node) => {
22
+ node.value = node.value?.replaceAll(ESCAPED, REFERENCE);
23
+ });
24
+ }
@@ -0,0 +1,89 @@
1
+ import type { OrgData } from "uniorg";
2
+ import { type Scalar } from "yaml";
3
+ export interface FrontmatterNode {
4
+ type: "morg-frontmatter";
5
+ yaml: string;
6
+ }
7
+ export declare function isFrontmatterNode(node: {
8
+ type: string;
9
+ }): node is FrontmatterNode;
10
+ /**
11
+ * md→org: puts the file's header in place, past keywords any pass put
12
+ * in front: an Emacs mode line and the file-level drawer lead the file,
13
+ * keywords org would attach to what follows stay apart, and the
14
+ * frontmatter node becomes the marked comment block.
15
+ * @param uniorgAst The document.
16
+ */
17
+ export declare function renderFileHeader(uniorgAst: OrgData): void;
18
+ /**
19
+ * md→org: takes the top-level entries `take` maps to keywords out of
20
+ * the frontmatter; the rest stays verbatim, comments included.
21
+ * @param yaml The frontmatter's YAML text.
22
+ * @param take Keywords for an entry, or null to leave it in place.
23
+ * @returns The keywords in order, and the remaining YAML text.
24
+ */
25
+ export declare function takeFrontmatterEntries(yaml: string, take: (key: Scalar, value: unknown, text: (scalar: Scalar) => string) => [string, string][] | null): {
26
+ keywords: [string, string][];
27
+ yaml: string;
28
+ };
29
+ export declare const KEYWORD_NAME: string;
30
+ export declare function fitsKeywordLine(key: string, value: string): boolean;
31
+ export declare function fitsPropertyLine(key: string, value: string): boolean;
32
+ /**
33
+ * org→md: whether morg's own entries (`morg_properties`,
34
+ * `morg_keywords`) can join the frontmatter: whether the YAML with one
35
+ * more top-level entry appended still parses as a single block mapping
36
+ * holding none of them yet. Comments alone take them; an indented
37
+ * mapping, a list, a flow mapping or a document ending in `...` do not.
38
+ * @param yaml The frontmatter's YAML text.
39
+ */
40
+ export declare function takesMorgEntries(yaml: string): boolean;
41
+ /**
42
+ * md→org: one of morg's own entries, a sequence of one-entry maps
43
+ * (`morg_keywords`, `morg_properties`, ADR 0005), restored only if
44
+ * every item fits its org line.
45
+ * @param yaml The frontmatter's YAML text.
46
+ * @param name The entry's key.
47
+ * @param fits Whether a key and value fit the org line they go back to.
48
+ * @returns The key, value pairs in order, and the remaining YAML text.
49
+ */
50
+ export declare function takeMorgEntry(yaml: string, name: string, fits: (key: string, value: string) => boolean): {
51
+ keywords: [string, string][];
52
+ yaml: string;
53
+ };
54
+ interface FileHeader {
55
+ frontmatter?: string;
56
+ fileProperties?: [string, string][];
57
+ takesMorgEntries: boolean;
58
+ startsWithModeLine: boolean;
59
+ }
60
+ /**
61
+ * org→md: takes the file's header morg carries as frontmatter: the
62
+ * marked block and, where its YAML can take it, a file-level drawer.
63
+ * @param uniorgAst The parsed document.
64
+ * @param org The text it was parsed from.
65
+ * @param onWarning Reports a marked block morg cannot take.
66
+ * @returns The transform options holding them.
67
+ */
68
+ export declare function takeFileHeader(uniorgAst: OrgData, org: string, onWarning?: (message: string) => void): FileHeader;
69
+ /**
70
+ * org→md, for a tree `transformMdastToUniorgAst` built rather than one
71
+ * parsed from org text: its file header, the block still raw text.
72
+ * @param uniorgAst The document; its header nodes are removed.
73
+ * @returns The transform options holding the header.
74
+ */
75
+ export declare function takeRenderedFileHeader(uniorgAst: OrgData): FileHeader;
76
+ export declare function isModeLine(comment: string): boolean;
77
+ export declare function isModeLineComment(node: {
78
+ type: string;
79
+ }): boolean;
80
+ /**
81
+ * md→org: marks the comment made from the Markdown body's first node as
82
+ * the file's mode line, if it is a -*- comment: what passes do to the
83
+ * tree before the header is rendered cannot make another one lead.
84
+ * @param node The uniorg node made from the body's first node.
85
+ */
86
+ export declare function markModeLine(node: {
87
+ type: string;
88
+ } | null): void;
89
+ export {};