@remigius42/morg 0.9.0 → 0.9.2

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,12 @@
1
1
  import type { Parent } from "unist";
2
2
  import { type Node } from "./render.js";
3
+ /**
4
+ * Whether a line, indented or not, may start org line syntax: false
5
+ * where org surely reads it as text.
6
+ * @param line The line, without its line break.
7
+ * @returns Whether it may start an element other than a paragraph.
8
+ */
9
+ export declare function mayBeLineSyntax(line: string): boolean;
3
10
  /**
4
11
  * Whether org reads a line as anything but a plain paragraph.
5
12
  * @param line The line, without its line break.
@@ -9,6 +9,15 @@ import { delimiters, isInline, locate, positionParser, renderInline, tryParse }
9
9
  // `:` (`1.`, `a)`, `CLOCK:`, `_.`); any other line (one starting with a
10
10
  // link, markup or code, say) is text, and skips the parse
11
11
  const MAY_BE_LINE_SYNTAX_RE = /^(?:[-+]|\*+)(?:\s|$)|^(?:-{5}|\+-|#(?:\s|$|\+)|[|:]|\[fn:|\\begin\{|%%\()|^[\p{L}\p{N}_]+[.):]/u;
12
+ /**
13
+ * Whether a line, indented or not, may start org line syntax: false
14
+ * where org surely reads it as text.
15
+ * @param line The line, without its line break.
16
+ * @returns Whether it may start an element other than a paragraph.
17
+ */
18
+ export function mayBeLineSyntax(line) {
19
+ return MAY_BE_LINE_SYNTAX_RE.test(line.trimStart());
20
+ }
12
21
  /**
13
22
  * Whether org reads a line as anything but a plain paragraph.
14
23
  * @param line The line, without its line break.
@@ -43,15 +43,29 @@ function transformMdastTableRow(ctx, row) {
43
43
  }))
44
44
  };
45
45
  }
46
+ // lines without the indentation all non-blank ones share
47
+ function dedentLines(text) {
48
+ const lines = text.split("\n");
49
+ const indent = Math.min(...lines
50
+ .filter(line => line.trim())
51
+ .map(line => /^[ \t]*/.exec(line)?.[0].length ?? 0));
52
+ return Number.isFinite(indent)
53
+ ? lines.map(line => line.slice(indent)).join("\n")
54
+ : text;
55
+ }
46
56
  export function transformMdastHtml(ctx, node) {
47
57
  // html comments are markdown's comment idiom and map natively to
48
58
  // org comments (not an md-ism)
49
59
  const comment = /^<!--([\s\S]*?)-->\s*$/.exec(node.value);
50
60
  if (comment) {
61
+ const body = comment[1] ?? "";
62
+ // a multi-line comment's own line breaks frame its lines, so an
63
+ // empty first or last line is the comment's, not padding
64
+ const lines = /^[ \t]*\n([\s\S]*)\n[ \t]*$/.exec(body);
51
65
  return {
52
66
  type: "comment",
53
67
  // inverse of the escaping applied when the comment was emitted
54
- value: (comment[1] ?? "").trim().replaceAll("--&gt;", "-->")
68
+ value: (lines ? dedentLines(lines[1] ?? "") : body.trim()).replaceAll("--&gt;", "-->")
55
69
  };
56
70
  }
57
71
  if (ctx.options.interpretHtml) {
@@ -87,10 +87,28 @@ function convertMarkdownDocument(markdown, options, sides) {
87
87
  // a marked comment block; raw text, past every pass that rewrites text
88
88
  renderFileHeader(uniorgAst);
89
89
  // Phase 4: Render uniorg-ast to Org-mode string
90
- const processor = unified().use(uniorgStringify);
91
- const orgContent = processor.stringify(uniorgAst);
92
- return orgContent;
90
+ const orgContent = String(stringifier.stringify(uniorgAst));
91
+ return orgContent.replaceAll(SPACE_KEEPER, "");
93
92
  }
93
+ // uniorg-stringify trims a headline's line, and the document's end, but
94
+ // Emacs reads stars without a space after them as text; the space ends
95
+ // in a character no trim takes, removed after. null leaves the rest to
96
+ // uniorg-stringify
97
+ const SPACE_KEEPER = "\u0000";
98
+ function emptyHeadline(node) {
99
+ const empty = !node.children.length &&
100
+ !node.todoKeyword &&
101
+ !node.priority &&
102
+ !node.commented &&
103
+ !node.tags.length;
104
+ return empty ? `${"*".repeat(node.level)} ${SPACE_KEEPER}\n` : null;
105
+ }
106
+ // built once: a preset converts a page block by block
107
+ const stringifier = unified()
108
+ .use(uniorgStringify, {
109
+ handlers: { headline: emptyHeadline }
110
+ })
111
+ .freeze();
94
112
  // a preset's dialect conventions that need the Markdown source apply
95
113
  // to the parse, before the generic transform
96
114
  function parseMarkdown(markdown, preset) {
@@ -1,5 +1,6 @@
1
1
  import { consumesBracedScripts } from "../core/bracedScripts.js";
2
- import { readsAsLineSyntax } from "../core/lineSyntax.js";
2
+ import { mayBeLineSyntax, readsAsLineSyntax } from "../core/lineSyntax.js";
3
+ import { positionParser, tryParse } from "../core/render.js";
3
4
  import { ZERO_WIDTH_SPACE } from "../core/markupBoundary.js";
4
5
  import { isDrawerStart, isOrgBlockStart, orgElementEnd } from "../core/passthroughSource.js";
5
6
  import { readVanillaMarkdownOutline } from "./logseqVanillaMarkdown.js";
@@ -17,6 +18,7 @@ const MD_PROPERTY_RE = /^([\w.-]+)::(?: (.*))?$/;
17
18
  // a repeated task's log line, which Logseq bullets per format
18
19
  const STATE_LINE_RE = /^[-*] (?=State ")/;
19
20
  const MD_HEADING_RE = /^(#{1,6})(?: (.*))?$/;
21
+ const HEADLINE_RE = /^\*+ /;
20
22
  const FENCE_RE = /^\s*(?:```|~~~)/;
21
23
  // md→org adds it for the text's bare `_` and `^`, which a block's
22
24
  // content holds as Logseq writes it
@@ -116,8 +118,10 @@ function withBracedScripts(lines) {
116
118
  return consumesBracedScripts(braced.join("\n")) ? braced : lines;
117
119
  }
118
120
  // Logseq org: a block's properties drawer, as properties; a heading
119
- // level is one of them, but for content that starts with the drawer
120
- function readOrgBlock({ level, lines }) {
121
+ // level is one of them, but for content that starts with the drawer,
122
+ // unless written below an empty title (Vanilla org), which both look
123
+ // alike in
124
+ function readOrgBlock({ level, lines }, joined = false) {
121
125
  const [first = "", ...rest] = lines;
122
126
  const metaFirst = isDrawerStart(first);
123
127
  const { meta: groups, body } = takeMeta(metaFirst ? lines : rest, () => false);
@@ -128,7 +132,9 @@ function readOrgBlock({ level, lines }) {
128
132
  }
129
133
  return group.slice(1, -1).flatMap(line => {
130
134
  const [, key = "", value = ""] = ORG_PROPERTY_RE.exec(line) ?? [];
131
- if (!metaFirst && key === "heading" && /^[1-6]$/.test(value)) {
135
+ if ((!metaFirst || joined) &&
136
+ key === "heading" &&
137
+ /^[1-6]$/.test(value)) {
132
138
  heading = Number(value);
133
139
  return [];
134
140
  }
@@ -143,12 +149,21 @@ function readOrgBlock({ level, lines }) {
143
149
  content: metaFirst ? body : [first, ...body]
144
150
  };
145
151
  }
146
- function readOrgOutline(org) {
152
+ function readOrgOutline(org, vanilla) {
147
153
  const { page, blocks } = splitBlocks(org.replace(/\r?\n$/, "").split(/\r?\n/), line => {
148
154
  const match = ORG_BLOCK_RE.exec(line);
149
155
  return match ? [match[1]?.length ?? 0, match[2] ?? ""] : null;
150
156
  });
151
- return { page, blocks: blocks.map(readOrgBlock) };
157
+ return {
158
+ page,
159
+ blocks: blocks.map(({ level, lines }) => {
160
+ // Vanilla org: a first line written below an empty title, as it
161
+ // starts an element, is the block's first line again
162
+ const [title, ...rest] = lines;
163
+ const joined = vanilla && title === "" && startsElement(rest);
164
+ return readOrgBlock({ level, lines: joined ? rest : lines }, joined);
165
+ })
166
+ };
152
167
  }
153
168
  // a continuation line sits two spaces inside its bullet
154
169
  function dedent(line, level) {
@@ -246,23 +261,51 @@ function orgMetaLines(meta, heading) {
246
261
  const drawer = [":PROPERTIES:", ...properties, ":END:"];
247
262
  return lines.flatMap(line => (line === null ? drawer : [line]));
248
263
  }
249
- function writeOrgBlock(block) {
250
- const [title = "", ...more] = arrange(block.metaFirst, orgMetaLines(block.meta, block.heading), block.content);
264
+ // Logseq org writes a block's first line on its stars' line, where
265
+ // Emacs reads it as the title; whether the line starts an element there
266
+ // that runs on below it (a block, a list, a table, a drawer), which the
267
+ // title would cut off; one line of a list or a table is a fine title,
268
+ // as is a headline's own
269
+ function startsElement(lines) {
270
+ const [first = ""] = lines;
271
+ if (!mayBeLineSyntax(first) || HEADLINE_RE.test(first)) {
272
+ return false;
273
+ }
274
+ const text = `${lines.join("\n")}\n`;
275
+ const [element] = tryParse(text, positionParser)?.children ?? [];
276
+ // its end takes in the blank lines after it
277
+ return (!!element &&
278
+ element.type !== "paragraph" &&
279
+ text
280
+ .slice(0, element.position?.end.offset ?? 0)
281
+ .trimEnd()
282
+ .includes("\n"));
283
+ }
284
+ // Vanilla org: Emacs reads stars without a space after them as text, so
285
+ // an empty title keeps the space Logseq org leaves out, and a first line
286
+ // that starts an element goes below the stars (ADR 0006)
287
+ function writeOrgBlock(block, vanilla = false) {
288
+ const lines = arrange(block.metaFirst, orgMetaLines(block.meta, block.heading), block.content);
289
+ const [title = "", ...more] = vanilla && startsElement(lines) ? ["", ...lines] : lines;
251
290
  return [
252
- `${"*".repeat(block.level)}${title ? ` ${title}` : ""}`,
291
+ `${"*".repeat(block.level)}${title || vanilla ? ` ${title}` : ""}`,
253
292
  ...more
254
293
  ].join("\n");
255
294
  }
256
295
  function writeOutline({ page, blocks }, writeBlock) {
257
296
  return [...page, ...blocks.map(writeBlock)].join("\n").concat("\n");
258
297
  }
259
- // a block's title is a headline's, which Logseq reads as inline text
260
- // only; converted with the lines below it, it must not read as a list,
298
+ // a table or a rule, which Logseq reads on a headline's line
299
+ const TITLE_ELEMENT_RE = /^(\||-{5,}\s*$)/;
300
+ // a block's title is a headline's, inline text but for a table or a
301
+ // rule; converted with the lines below it, it must not read as a list,
261
302
  // a headline or a fixed-width line, so an escape the core drops keeps
262
303
  // it text (ADR 0006)
263
304
  function inlineTitle(block) {
264
305
  const [title = "", ...rest] = block.content;
265
- return !block.metaFirst && readsAsLineSyntax(title)
306
+ return !block.metaFirst &&
307
+ !TITLE_ELEMENT_RE.test(title) &&
308
+ readsAsLineSyntax(title)
266
309
  ? [`${ZERO_WIDTH_SPACE}${title}`, ...rest]
267
310
  : block.content;
268
311
  }
@@ -380,15 +423,17 @@ function writeVanillaMarkdownOutline({ page, blocks }, context) {
380
423
  * @returns The Markdown page.
381
424
  */
382
425
  export function orgOutlineToMarkdown(org, convert, presets, context) {
383
- const { page, blocks } = readOrgOutline(org);
426
+ const { page, blocks } = readOrgOutline(org, context.side === "output");
384
427
  const vanilla = context.side === "input";
428
+ // read as Logseq org, a block's title is inline text (ADR 0006)
429
+ const inline = context.side !== "output";
385
430
  const pageLines = vanilla ? (presets.vanillaPage?.(page) ?? page) : page;
386
431
  const convertCarried = (fragment, preset) => convert(fragment, preset, vanilla ? presets.vanillaInline : undefined);
387
432
  const outline = {
388
433
  page: convertPage(pageLines, convertCarried, presets.page),
389
434
  blocks: blocks.map(block => ({
390
435
  ...block,
391
- content: convertContent(withBracedScripts(vanilla ? inlineTitle(block) : block.content), convertCarried, presets.block)
436
+ content: convertContent(withBracedScripts(inline ? inlineTitle(block) : block.content), convertCarried, presets.block)
392
437
  }))
393
438
  };
394
439
  return vanilla
@@ -422,7 +467,7 @@ export function markdownOutlineToOrg(markdown, convert, presets, context) {
422
467
  ...block,
423
468
  content: convertMarkdownContent(block.content, convertCarried, presets.block).filter(line => line !== BRACED_SCRIPTS_LINE)
424
469
  };
425
- return vanilla ? dropTitleEscape(converted) : converted;
470
+ return context.side === "input" ? converted : dropTitleEscape(converted);
426
471
  })
427
- }, writeOrgBlock);
472
+ }, block => writeOrgBlock(block, context.side === "input"));
428
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remigius42/morg",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Bidirectional Markdown ↔ Org-mode converter with round-trip convergence",
5
5
  "keywords": [
6
6
  "org-mode",
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "bin": {
33
- "morg": "./dist/cli.js"
33
+ "morg": "dist/cli.js"
34
34
  },
35
35
  "files": [
36
36
  "dist"