@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
@@ -0,0 +1,251 @@
1
+ import { consumesBracedScripts } from "../core/bracedScripts.js";
2
+ import { isDrawerStart, isOrgBlockStart, orgElementEnd } from "../core/passthroughSource.js";
3
+ // Logseq stores a page as an outline of blocks, each block a content
4
+ // string it parses on its own: org writes a block as its level's stars,
5
+ // a space and the content (an empty block as the bare stars), Markdown
6
+ // as a `- ` bullet indented one tab per level, continuation lines two
7
+ // spaces further in. Both are converted block by block, so a block's
8
+ // content (a code block, a table, lines that run on) is one fragment.
9
+ const ORG_BLOCK_RE = /^(\*+)(?: (.*))?$/;
10
+ const MD_BLOCK_RE = /^(\t*)-(?: (.*))?$/;
11
+ const PLANNING_RE = /^(?:SCHEDULED|DEADLINE): /;
12
+ const ORG_PROPERTY_RE = /^:([^\s:]+):(?: (.*))?$/;
13
+ const MD_PROPERTY_RE = /^([\w.-]+)::(?: (.*))?$/;
14
+ // a repeated task's log line, which Logseq bullets per format
15
+ const STATE_LINE_RE = /^[-*] (?=State ")/;
16
+ const MD_HEADING_RE = /^(#{1,6})(?: (.*))?$/;
17
+ const FENCE_RE = /^\s*(?:```|~~~)/;
18
+ // md→org adds it for the text's bare `_` and `^`, which a block's
19
+ // content holds as Logseq writes it
20
+ const BRACED_SCRIPTS_LINE = "#+OPTIONS: ^:{}";
21
+ function logbook(lines, bullet) {
22
+ return lines[0] === ":LOGBOOK:"
23
+ ? lines.map(line => line.replace(STATE_LINE_RE, `${bullet} `))
24
+ : lines;
25
+ }
26
+ function splitBlocks(lines, blockLevel) {
27
+ const page = [];
28
+ const blocks = [];
29
+ for (const line of lines) {
30
+ const start = blockLevel(line);
31
+ if (start) {
32
+ blocks.push({ level: start[0], lines: [start[1]] });
33
+ }
34
+ else if (blocks.length) {
35
+ blocks[blocks.length - 1]?.lines.push(line);
36
+ }
37
+ else {
38
+ page.push(line);
39
+ }
40
+ }
41
+ return { page, blocks };
42
+ }
43
+ // a block's leading planning lines and drawers, which Logseq writes
44
+ // below the content's first line in either format
45
+ function takeMeta(lines, isMeta) {
46
+ const meta = [];
47
+ let i = 0;
48
+ while (i < lines.length) {
49
+ const line = lines[i] ?? "";
50
+ if (isDrawerStart(line)) {
51
+ const end = orgElementEnd(lines, i);
52
+ if (end === -1) {
53
+ break;
54
+ }
55
+ meta.push(lines.slice(i, end + 1));
56
+ i = end + 1;
57
+ }
58
+ else if (PLANNING_RE.test(line) || isMeta(line)) {
59
+ meta.push([line]);
60
+ i++;
61
+ }
62
+ else {
63
+ break;
64
+ }
65
+ }
66
+ return { meta, body: lines.slice(i) };
67
+ }
68
+ // the one org block whose content Logseq md writes as Markdown markup
69
+ // (its <quote command), and org→md writes as a md quote
70
+ const QUOTE_BLOCK_RE = /^#\+begin_quote\b/i;
71
+ function convertOrgBlock(block, convert, preset) {
72
+ if (!QUOTE_BLOCK_RE.test(block[0] ?? "")) {
73
+ return block;
74
+ }
75
+ const content = convertContent(block.slice(1, -1), convert, preset);
76
+ return [block[0] ?? "", ...content, block.at(-1) ?? ""];
77
+ }
78
+ // md→org: a block's org blocks stay as written, as org→md writes them,
79
+ // but for a quote's content; the text around them converts
80
+ function convertMarkdownContent(lines, convert, preset) {
81
+ const result = [];
82
+ let text = [];
83
+ let fenced = false;
84
+ for (let i = 0; i < lines.length; i++) {
85
+ fenced = FENCE_RE.test(lines[i] ?? "") ? !fenced : fenced;
86
+ const end = fenced || !isOrgBlockStart(lines[i] ?? "") ? -1 : orgElementEnd(lines, i);
87
+ if (end === -1) {
88
+ text.push(lines[i] ?? "");
89
+ continue;
90
+ }
91
+ result.push(...convertContent(text, convert, preset), ...convertOrgBlock(lines.slice(i, end + 1), convert, preset));
92
+ text = [];
93
+ i = end;
94
+ }
95
+ return [...result, ...convertContent(text, convert, preset)];
96
+ }
97
+ function convertContent(lines, convert, preset) {
98
+ const content = lines.join("\n");
99
+ return content.trim()
100
+ ? convert(content, preset).replace(/\n+$/, "").split("\n")
101
+ : [];
102
+ }
103
+ function convertPage(lines, convert, preset) {
104
+ // Logseq ends the page properties with a blank line
105
+ return lines.some(line => line.trim())
106
+ ? [`${convert(lines.join("\n"), preset).replace(/\n+$/, "")}\n`]
107
+ : [];
108
+ }
109
+ /**
110
+ * Converts a Logseq org page to Logseq Markdown, block by block.
111
+ * @param org The org page.
112
+ * @param convert The core's fragment converter.
113
+ * @param presets The presets for the page properties and for a block.
114
+ * @returns The Markdown page.
115
+ */
116
+ export function orgOutlineToMarkdown(org, convert, presets) {
117
+ const { page, blocks } = splitBlocks(org.replace(/\r?\n$/, "").split(/\r?\n/), line => {
118
+ const match = ORG_BLOCK_RE.exec(line);
119
+ return match ? [match[1]?.length ?? 0, match[2] ?? ""] : null;
120
+ });
121
+ return [
122
+ ...convertPage(page, convert, presets.page),
123
+ ...blocks.map(block => orgBlockToMarkdown(block, convert, presets.block))
124
+ ]
125
+ .join("\n")
126
+ .concat("\n");
127
+ }
128
+ // org→md reads a block's bare `_` and `^` as text, as md→org writes
129
+ // them without the setting that would say so
130
+ function withBracedScripts(lines) {
131
+ const braced = [BRACED_SCRIPTS_LINE, ...lines];
132
+ return consumesBracedScripts(braced.join("\n")) ? braced : lines;
133
+ }
134
+ function orgBlockToMarkdown(block, convert, preset) {
135
+ const [first = "", ...rest] = block.lines;
136
+ // content that starts with the block's properties puts their drawer
137
+ // on the headline line; its heading level is then a property too
138
+ const metaFirst = isDrawerStart(first);
139
+ const { meta, body } = takeMeta(metaFirst ? block.lines : rest, () => false);
140
+ let heading = "";
141
+ const metaLines = meta.flatMap(lines => {
142
+ if (lines[0] !== ":PROPERTIES:") {
143
+ return logbook(lines, "*");
144
+ }
145
+ return lines.slice(1, -1).flatMap(line => {
146
+ const [, key = "", value = ""] = ORG_PROPERTY_RE.exec(line) ?? [];
147
+ if (!metaFirst && key === "heading" && /^[1-6]$/.test(value)) {
148
+ heading = "#".repeat(Number(value));
149
+ return [];
150
+ }
151
+ return [`${key}::${value ? ` ${value}` : ""}`];
152
+ });
153
+ });
154
+ const content = convertContent(withBracedScripts(metaFirst ? body : [first, ...body]), convert, preset);
155
+ const [title = "", ...more] = arrange(metaFirst, metaLines, content);
156
+ const head = [heading, title].filter(Boolean).join(" ");
157
+ const indent = "\t".repeat(block.level - 1);
158
+ return [
159
+ `${indent}-${head ? ` ${head}` : ""}`,
160
+ ...more.map(line => `${indent} ${line}`)
161
+ ].join("\n");
162
+ }
163
+ /**
164
+ * Converts a Logseq Markdown page to Logseq org, block by block.
165
+ * @param markdown The Markdown page.
166
+ * @param convert The core's fragment converter.
167
+ * @param presets The presets for the page properties and for a block.
168
+ * @returns The org page.
169
+ */
170
+ export function markdownOutlineToOrg(markdown, convert, presets) {
171
+ const lines = markdown.replace(/\r?\n$/, "").split(/\r?\n/);
172
+ // a leading frontmatter is page content, its `- ` lines yaml items
173
+ const frontmatter = lines.slice(0, lines[0] === "---" ? lines.indexOf("---", 1) + 1 : 0);
174
+ let fenced = false;
175
+ const { page, blocks } = splitBlocks(lines.slice(frontmatter.length), line => {
176
+ const match = MD_BLOCK_RE.exec(line);
177
+ // a bullet starts a block, which may open a fence of its own
178
+ fenced = (match ? false : fenced) !== FENCE_RE.test(match?.[2] ?? line);
179
+ if (match) {
180
+ return [(match[1]?.length ?? 0) + 1, match[2] ?? ""];
181
+ }
182
+ // a heading outside the bullets is a top-level block, as Logseq
183
+ // writes a page's first block if it is a heading
184
+ return !fenced && MD_HEADING_RE.test(line) ? [1, line] : null;
185
+ });
186
+ page.unshift(...frontmatter);
187
+ return [
188
+ ...convertPage(page, convert, presets.page),
189
+ ...blocks.map(block => mdBlockToOrg(block, convert, presets.block))
190
+ ]
191
+ .join("\n")
192
+ .concat("\n");
193
+ }
194
+ // a continuation line sits two spaces inside its bullet
195
+ function dedent(line, level) {
196
+ const indent = `${"\t".repeat(level - 1)} `;
197
+ return line.startsWith(indent)
198
+ ? line.slice(indent.length)
199
+ : line.trim()
200
+ ? line
201
+ : "";
202
+ }
203
+ // the md meta lines in org: `key::` lines become the property drawer,
204
+ // where the first of them was, or below the planning lines
205
+ function orgMetaLines(meta, heading) {
206
+ const properties = heading ? [`:heading: ${heading}`] : [];
207
+ const lines = [];
208
+ for (const group of meta) {
209
+ const property = MD_PROPERTY_RE.exec(group[0] ?? "");
210
+ if (!property) {
211
+ lines.push(...logbook(group, "-"));
212
+ continue;
213
+ }
214
+ if (!lines.includes(null)) {
215
+ lines.push(null);
216
+ }
217
+ properties.push(`:${property[1]}:${property[2] ? ` ${property[2]}` : ""}`);
218
+ }
219
+ if (properties.length && !lines.includes(null)) {
220
+ lines.splice(lines.filter(line => PLANNING_RE.test(line ?? "")).length, 0, null);
221
+ }
222
+ const drawer = [":PROPERTIES:", ...properties, ":END:"];
223
+ return lines.flatMap(line => (line === null ? drawer : [line]));
224
+ }
225
+ // a block's content's first line, its meta lines, then the rest of the
226
+ // content; content that starts with the meta lines keeps them first
227
+ function arrange(metaFirst, meta, content) {
228
+ return metaFirst
229
+ ? [...meta, ...content]
230
+ : [content[0] ?? "", ...meta, ...content.slice(1)];
231
+ }
232
+ // `## title`: the heading level and the title
233
+ function mdHeading(line) {
234
+ const match = MD_HEADING_RE.exec(line);
235
+ return match ? [match[1]?.length ?? 0, match[2] ?? ""] : [0, line];
236
+ }
237
+ function mdBlockToOrg(block, convert, preset) {
238
+ const [first = "", ...rest] = block.lines;
239
+ const lines = [first, ...rest.map(line => dedent(line, block.level))];
240
+ // content that starts with properties: their drawer opens on the
241
+ // headline line
242
+ const metaFirst = MD_PROPERTY_RE.test(first);
243
+ const [heading, titleLine] = metaFirst ? [0, ""] : mdHeading(first);
244
+ const { meta, body } = takeMeta(metaFirst ? lines : lines.slice(1), line => MD_PROPERTY_RE.test(line));
245
+ const content = convertMarkdownContent(metaFirst ? body : [titleLine, ...body], convert, preset).filter(line => line !== BRACED_SCRIPTS_LINE);
246
+ const [title = "", ...more] = arrange(metaFirst, orgMetaLines(meta, heading), content);
247
+ return [
248
+ `${"*".repeat(block.level)}${title ? ` ${title}` : ""}`,
249
+ ...more
250
+ ].join("\n");
251
+ }
@@ -1,12 +1,23 @@
1
+ import type { Root as MdastRoot } from "mdast";
1
2
  import type { OrgData } from "uniorg";
2
3
  /**
3
4
  * A dialect preset: transforms applied on top of the dialect-agnostic core.
4
- * `applyToUniorg` runs in the md→org pipeline after the generic transform;
5
+ * `applyToMdast` runs in the md→org pipeline before the generic
6
+ * transform, with the Markdown source at hand; `applyToUniorg` runs in the md→org pipeline after the generic transform;
5
7
  * `extractFromUniorg` runs in the org→md pipeline before the generic
6
8
  * transform, normalizing dialect conventions back to generic uniorg.
9
+ * `convertOrg` and `convertMarkdown` take a whole conversion over, for a
10
+ * dialect whose documents are no single org or Markdown document (an
11
+ * outline of blocks, each its own fragment); `convert` runs the core on
12
+ * a fragment, with the given preset's hooks.
7
13
  */
8
14
  export interface Preset {
9
15
  name: string;
16
+ applyToMdast?: (mdast: MdastRoot, markdown: string) => void;
10
17
  applyToUniorg?: (uniorgAst: OrgData) => OrgData;
11
18
  extractFromUniorg?: (uniorgAst: OrgData) => OrgData;
19
+ convertOrg?: (org: string, convert: FragmentConverter) => string;
20
+ convertMarkdown?: (markdown: string, convert: FragmentConverter) => string;
12
21
  }
22
+ /** Converts a fragment with the core and the given preset's AST hooks. */
23
+ export type FragmentConverter = (fragment: string, preset?: Preset) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remigius42/morg",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Bidirectional Markdown ↔ Org-mode converter with round-trip convergence",
5
5
  "keywords": [
6
6
  "org-mode",