@remigius42/morg 0.6.0 → 0.8.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 (37) hide show
  1. package/README.md +23 -11
  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 +7 -0
  7. package/dist/core/bracedScripts.js +10 -0
  8. package/dist/core/frontmatterBlock.d.ts +89 -0
  9. package/dist/core/frontmatterBlock.js +448 -0
  10. package/dist/core/keyValueLines.d.ts +6 -0
  11. package/dist/core/keyValueLines.js +18 -0
  12. package/dist/core/lineSyntax.d.ts +16 -0
  13. package/dist/core/lineSyntax.js +25 -2
  14. package/dist/core/mdastToUniorg/blocks.d.ts +0 -1
  15. package/dist/core/mdastToUniorg/blocks.js +7 -32
  16. package/dist/core/mdastToUniorg/index.d.ts +6 -0
  17. package/dist/core/mdastToUniorg/index.js +72 -7
  18. package/dist/core/passthroughSource.d.ts +21 -0
  19. package/dist/core/passthroughSource.js +94 -0
  20. package/dist/core/tablePipes.d.ts +12 -3
  21. package/dist/core/tablePipes.js +38 -5
  22. package/dist/core/uniorgToMdast/elements.js +6 -1
  23. package/dist/core/uniorgToMdast/index.d.ts +3 -2
  24. package/dist/core/uniorgToMdast/index.js +100 -31
  25. package/dist/core/uniorgToMdast/lists.js +1 -1
  26. package/dist/core/uniorgToMdast/objects.js +6 -0
  27. package/dist/core/uniorgToMdast/shared.d.ts +5 -0
  28. package/dist/core/uniorgToMdast/shared.js +9 -13
  29. package/dist/index.d.ts +1 -2
  30. package/dist/markdownToOrg.js +41 -25
  31. package/dist/orgToMarkdown.js +18 -2
  32. package/dist/presets/logseq.d.ts +3 -22
  33. package/dist/presets/logseq.js +269 -186
  34. package/dist/presets/logseqOutline.d.ts +22 -0
  35. package/dist/presets/logseqOutline.js +251 -0
  36. package/dist/presets/types.d.ts +12 -1
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -17,8 +17,8 @@ Bidirectional **Markdown ↔ Org-mode** converter, built on the
17
17
 
18
18
  morg treats Org as a canonical plain-text format and Markdown (Obsidian,
19
19
  generic) as the interop surface. Dialect conventions, such as
20
- [Logseq](https://docs.logseq.com/)'s `heading::` properties and outline
21
- nesting, are supported via presets.
20
+ [Logseq](https://docs.logseq.com/)'s outline of blocks and page
21
+ properties, are supported via presets.
22
22
 
23
23
  ## Round-trip convergence
24
24
 
@@ -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
 
@@ -191,15 +198,20 @@ preset })`: `preserveOrgisms` default `true`; `useHtml` (default
191
198
  `- [x]`); headings become list items and do not restore on the
192
199
  return trip; anything with priority, tags or content keeps its
193
200
  heading and reports via `onWarning`
194
- - `logseq({ nestUnderHeadings })`: default `true`; content following a
195
- heading nests as child blocks of that heading: paragraphs become child
196
- headlines one level deeper (in Logseq org every outline block is a
197
- headline), other constructs stay in the preceding block's body. The
198
- reverse direction restores headings from `:heading:` properties and
199
- turns plain block headlines back into paragraphs. Hiccup blocks
200
- (`[:div …]`) pass through as plain text and are emitted unescaped in
201
- Markdown. Logseq's own syntax maps both directions: `TODO`/`DONE`
202
- text markers and `[#A]` priorities ↔ org keywords/priorities, page
201
+ - `logseq()`: a page is Logseq's outline of blocks, converted block by
202
+ block: a headline (stars, a space, the block's content, an empty
203
+ block as the bare stars) ↔ a `-` bullet indented one tab per level,
204
+ its lines below the first two spaces further in. A block's content
205
+ is one fragment, so a code block or table that starts on the
206
+ headline line converts as a whole. `:heading: N` ↔ `- ## …`, a
207
+ block's property drawer ↔ `key:: value` lines; planning lines and
208
+ other drawers (`:LOGBOOK:`) stay as written. A heading outside the
209
+ bullets is a top-level block, as Logseq writes a page's first one.
210
+ Page properties map both directions: a first block of
211
+ `key:: value` lines and flat frontmatter entries ↔ leading
212
+ `#+key: value` lines, which Logseq reads as page properties;
213
+ frontmatter keys that act in Emacs (`todo`, `include`, …) stay inert.
214
+ Task markers and `[#A]` priorities stay text, page
203
215
  references `[[page]]` and labeled forms `[label]([[page]])` ↔ org
204
216
  fuzzy links `[[page][label]]`, block refs `[label](((uuid)))` ↔
205
217
  `[[((uuid))][label]]`, and `^^highlight^^` markup survives verbatim
@@ -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
+ }
@@ -4,6 +4,13 @@ import type { OrgData } from "uniorg";
4
4
  * holds a bare underscore or caret org would read as a script.
5
5
  */
6
6
  export declare function requireBracedScripts(uniorgAst: OrgData): void;
7
+ /**
8
+ * Whether org→md would consume a `^:{}` setting at the head of `org`:
9
+ * its text reads a script org would otherwise take for one.
10
+ * @param org The org text, the setting included.
11
+ * @returns Whether the setting is taken as the one md→org adds.
12
+ */
13
+ export declare function consumesBracedScripts(org: string): boolean;
7
14
  /**
8
15
  * org→md: parses org, honoring its `^:` setting, and consuming `^:{}`
9
16
  * where the text needs it: md→org adds it only then, so anywhere else
@@ -135,6 +135,16 @@ function takeBracedScripts(uniorgAst) {
135
135
  return node.value !== "";
136
136
  });
137
137
  }
138
+ /**
139
+ * Whether org→md would consume a `^:{}` setting at the head of `org`:
140
+ * its text reads a script org would otherwise take for one.
141
+ * @param org The org text, the setting included.
142
+ * @returns Whether the setting is taken as the one md→org adds.
143
+ */
144
+ export function consumesBracedScripts(org) {
145
+ // text without a bare script candidate skips the parse
146
+ return (BARE_SCRIPT_RE.test(org) && readsBareScripts(bracedScriptsParser.parse(org)));
147
+ }
138
148
  /**
139
149
  * org→md: parses org, honoring its `^:` setting, and consuming `^:{}`
140
150
  * where the text needs it: md→org adds it only then, so anywhere else
@@ -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 {};