@remigius42/morg 0.6.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.
- package/README.md +12 -1
- package/dist/core/affiliated.d.ts +13 -0
- package/dist/core/affiliated.js +43 -0
- package/dist/core/backslashCommands.d.ts +10 -0
- package/dist/core/backslashCommands.js +43 -0
- package/dist/core/frontmatterBlock.d.ts +89 -0
- package/dist/core/frontmatterBlock.js +448 -0
- package/dist/core/keyValueLines.d.ts +6 -0
- package/dist/core/keyValueLines.js +18 -0
- package/dist/core/lineSyntax.d.ts +9 -0
- package/dist/core/lineSyntax.js +15 -0
- package/dist/core/mdastToUniorg/blocks.d.ts +0 -1
- package/dist/core/mdastToUniorg/blocks.js +3 -31
- package/dist/core/mdastToUniorg/index.d.ts +6 -0
- package/dist/core/mdastToUniorg/index.js +72 -7
- package/dist/core/tablePipes.d.ts +12 -3
- package/dist/core/tablePipes.js +38 -5
- package/dist/core/uniorgToMdast/elements.js +1 -1
- package/dist/core/uniorgToMdast/index.d.ts +3 -2
- package/dist/core/uniorgToMdast/index.js +100 -31
- package/dist/core/uniorgToMdast/lists.js +1 -1
- package/dist/core/uniorgToMdast/objects.js +6 -0
- package/dist/core/uniorgToMdast/shared.d.ts +5 -0
- package/dist/core/uniorgToMdast/shared.js +3 -12
- package/dist/markdownToOrg.js +28 -25
- package/dist/orgToMarkdown.js +12 -2
- package/dist/presets/logseq.js +130 -0
- package/dist/presets/types.d.ts +4 -1
- package/package.json +1 -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.
|
|
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,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 {};
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { affiliatedEntries, DUAL_NAMES } from "./affiliated.js";
|
|
2
|
+
import { positionParser } from "./render.js";
|
|
3
|
+
import { CST, isMap, isScalar, isSeq, Lexer, parseDocument, visit } from "yaml";
|
|
4
|
+
// frontmatter travels verbatim in a comment block marked as morg's
|
|
5
|
+
// (ADR 0005); uniorg keeps neither the block's parameter nor its
|
|
6
|
+
// unescaped value, so the block is rendered as raw text
|
|
7
|
+
const FRONTMATTER_BLOCK_BEGIN = "#+begin_comment morg_frontmatter";
|
|
8
|
+
export function isFrontmatterNode(node) {
|
|
9
|
+
return node.type === "morg-frontmatter";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* md→org: puts the file's header in place, past keywords any pass put
|
|
13
|
+
* in front: an Emacs mode line and the file-level drawer lead the file,
|
|
14
|
+
* keywords org would attach to what follows stay apart, and the
|
|
15
|
+
* frontmatter node becomes the marked comment block.
|
|
16
|
+
* @param uniorgAst The document.
|
|
17
|
+
*/
|
|
18
|
+
export function renderFileHeader(uniorgAst) {
|
|
19
|
+
leadWithFileHeader(uniorgAst);
|
|
20
|
+
detachLeadingKeywords(uniorgAst);
|
|
21
|
+
renderFrontmatterBlock(uniorgAst);
|
|
22
|
+
}
|
|
23
|
+
// the frontmatter node as a marked org comment block, raw text
|
|
24
|
+
// uniorg-stringify writes as is
|
|
25
|
+
function renderFrontmatterBlock(uniorgAst) {
|
|
26
|
+
const children = uniorgAst.children;
|
|
27
|
+
const index = children.findIndex(isFrontmatterNode);
|
|
28
|
+
const node = children[index];
|
|
29
|
+
if (node) {
|
|
30
|
+
children[index] = { type: "text", value: frontmatterBlock(node.yaml) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// org reads these directly above an element as its affiliated keywords
|
|
34
|
+
// (uniorg-parse's list, aliases included), not as document keywords;
|
|
35
|
+
// `#+NAME:: x` too, which uniorg reads as the key `NAME:` elsewhere
|
|
36
|
+
const AFFILIATED_RE = new RegExp(String.raw `^(?:(?:${DUAL_NAMES})(?:\[.*\])?|DATA|HEADERS?|LABEL|NAME|PLOT|RESNAME|RESULT|SOURCE|SRCNAME|TBLNAME|ATTR_[-\w]+):*$`, "i");
|
|
37
|
+
function isAffiliatedKey(key) {
|
|
38
|
+
return AFFILIATED_RE.test(key);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* md→org: a blank line after each leading keyword org would otherwise
|
|
42
|
+
* attach to the element below it, a keyword or the block included.
|
|
43
|
+
* @param uniorgAst The document.
|
|
44
|
+
*/
|
|
45
|
+
function detachLeadingKeywords(uniorgAst) {
|
|
46
|
+
const children = uniorgAst.children;
|
|
47
|
+
const first = children.findIndex(node => node.type !== "comment" && node.type !== "property-drawer");
|
|
48
|
+
for (let i = first; children[i]?.type === "keyword"; i++) {
|
|
49
|
+
if (children[i + 1] && isAffiliatedKey(children[i]?.key ?? "")) {
|
|
50
|
+
children.splice(++i, 0, { type: "text", value: "\n" });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function frontmatterBlock(yaml) {
|
|
55
|
+
const body = yaml ? `${escapeBlockLines(yaml)}\n` : "";
|
|
56
|
+
return `${FRONTMATTER_BLOCK_BEGIN}\n${body}#+end_comment\n`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* md→org: takes the top-level entries `take` maps to keywords out of
|
|
60
|
+
* the frontmatter; the rest stays verbatim, comments included.
|
|
61
|
+
* @param yaml The frontmatter's YAML text.
|
|
62
|
+
* @param take Keywords for an entry, or null to leave it in place.
|
|
63
|
+
* @returns The keywords in order, and the remaining YAML text.
|
|
64
|
+
*/
|
|
65
|
+
export function takeFrontmatterEntries(yaml, take) {
|
|
66
|
+
const document = parseDocument(yaml);
|
|
67
|
+
const keywords = [];
|
|
68
|
+
let rest = "";
|
|
69
|
+
let from = 0;
|
|
70
|
+
let comments;
|
|
71
|
+
for (const { key, value } of cuttableEntries(document)) {
|
|
72
|
+
const entries = isScalar(key)
|
|
73
|
+
? take(key, value, scalar => scalarText(scalar, yaml))
|
|
74
|
+
: null;
|
|
75
|
+
const span = entrySpan(key, value, yaml);
|
|
76
|
+
if (!entries || !span) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
keywords.push(...entries);
|
|
80
|
+
comments ??= yamlComments(yaml);
|
|
81
|
+
rest += yaml.slice(from, span[0]) + commentLines(comments, span);
|
|
82
|
+
from = span[1];
|
|
83
|
+
}
|
|
84
|
+
const left = rest + yaml.slice(from);
|
|
85
|
+
return keywords.length
|
|
86
|
+
? { keywords, yaml: from === yaml.length ? dropCutNewline(left) : left }
|
|
87
|
+
: { keywords, yaml };
|
|
88
|
+
}
|
|
89
|
+
// the newline before a cut at the end, unless a keep-chomped (`|+`)
|
|
90
|
+
// scalar above it holds it as a line of its own
|
|
91
|
+
function dropCutNewline(yaml) {
|
|
92
|
+
const dropped = yaml.replace(/\n$/, "");
|
|
93
|
+
const value = (text) => JSON.stringify(parseDocument(text).toJS());
|
|
94
|
+
return value(dropped) === value(yaml) ? dropped : yaml;
|
|
95
|
+
}
|
|
96
|
+
// entries can be cut out of a block mapping, one per line, unless an
|
|
97
|
+
// alias would lose its anchor or the YAML has errors, which make the
|
|
98
|
+
// ranges unreliable
|
|
99
|
+
function cuttableEntries(document) {
|
|
100
|
+
const contents = document.contents;
|
|
101
|
+
let aliased = false;
|
|
102
|
+
visit(document, {
|
|
103
|
+
Alias() {
|
|
104
|
+
aliased = true;
|
|
105
|
+
return visit.BREAK;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return isMap(contents) &&
|
|
109
|
+
!contents.flow &&
|
|
110
|
+
!aliased &&
|
|
111
|
+
!document.errors.length
|
|
112
|
+
? contents.items
|
|
113
|
+
: [];
|
|
114
|
+
}
|
|
115
|
+
// a plain scalar as written: its parsed value would rewrite `1.10` as
|
|
116
|
+
// `1.1`, `01234` as `1234` and an empty value as `null`; a quoted one
|
|
117
|
+
// as its string
|
|
118
|
+
function scalarText(scalar, yaml) {
|
|
119
|
+
const [start, end] = scalar.range ?? [0, 0];
|
|
120
|
+
return scalar.type === "PLAIN" ? yaml.slice(start, end) : String(scalar.value);
|
|
121
|
+
}
|
|
122
|
+
// an entry's source text, from the start of its key's line (an explicit
|
|
123
|
+
// `? `, an anchor or a tag precede the key itself) to the end of its value
|
|
124
|
+
function entrySpan(key, value, yaml) {
|
|
125
|
+
const start = isScalar(key) ? key.range?.[0] : undefined;
|
|
126
|
+
const end = value?.range?.[2];
|
|
127
|
+
return start === undefined || end === undefined
|
|
128
|
+
? null
|
|
129
|
+
: [yaml.lastIndexOf("\n", start - 1) + 1, end];
|
|
130
|
+
}
|
|
131
|
+
const CONTROL_TOKENS = [CST.DOCUMENT, CST.FLOW_END, CST.SCALAR];
|
|
132
|
+
// each comment and its offset
|
|
133
|
+
function yamlComments(yaml) {
|
|
134
|
+
const comments = [];
|
|
135
|
+
let offset = 0;
|
|
136
|
+
for (const token of new Lexer().lex(yaml)) {
|
|
137
|
+
if (token.startsWith("#")) {
|
|
138
|
+
comments.push([offset, token]);
|
|
139
|
+
}
|
|
140
|
+
// control tokens mark structure, but hold no source
|
|
141
|
+
offset += CONTROL_TOKENS.includes(token) ? 0 : token.length;
|
|
142
|
+
}
|
|
143
|
+
return comments;
|
|
144
|
+
}
|
|
145
|
+
// the comments within a span, each on a line of its own
|
|
146
|
+
function commentLines(comments, [start, end]) {
|
|
147
|
+
return comments
|
|
148
|
+
.filter(([offset]) => offset >= start && offset < end)
|
|
149
|
+
.map(([, comment]) => `${comment}\n`)
|
|
150
|
+
.join("");
|
|
151
|
+
}
|
|
152
|
+
// a keyword name as uniorg reads it in `#+NAME: value`: no whitespace
|
|
153
|
+
// outside a dual keyword's brackets (`#+FOO:: bar` as the key `FOO:`)
|
|
154
|
+
export const KEYWORD_NAME = String.raw `(?:\[[^\]\r\n]*\]|\S)+`;
|
|
155
|
+
// what a `#+KEY: value` line can hold, read back as the same key: not a
|
|
156
|
+
// block's begin line, and no line break, which would end it and inject
|
|
157
|
+
// org structure
|
|
158
|
+
const KEYWORD_KEY_RE = new RegExp(String.raw `^(?!begin_)${KEYWORD_NAME}$`, "i");
|
|
159
|
+
export function fitsKeywordLine(key, value) {
|
|
160
|
+
return KEYWORD_KEY_RE.test(key) && !/[\r\n]/.test(value);
|
|
161
|
+
}
|
|
162
|
+
// what a `:KEY: value` drawer line can hold, read back as the same key
|
|
163
|
+
// (`:header-args:python:` included): `:END:` would close the drawer
|
|
164
|
+
export function fitsPropertyLine(key, value) {
|
|
165
|
+
return /^\S+$/.test(key) && !/^end$/i.test(key) && !/[\r\n]/.test(value);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* org→md: whether morg's own entries (`morg_properties`,
|
|
169
|
+
* `morg_keywords`) can join the frontmatter: whether the YAML with one
|
|
170
|
+
* more top-level entry appended still parses as a single block mapping
|
|
171
|
+
* holding none of them yet. Comments alone take them; an indented
|
|
172
|
+
* mapping, a list, a flow mapping or a document ending in `...` do not.
|
|
173
|
+
* @param yaml The frontmatter's YAML text.
|
|
174
|
+
*/
|
|
175
|
+
export function takesMorgEntries(yaml) {
|
|
176
|
+
if (!yaml.trim()) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
const document = parseDocument(`${yaml}\nmorg_probe: x`);
|
|
180
|
+
const contents = document.contents;
|
|
181
|
+
return (!document.errors.length &&
|
|
182
|
+
isMap(contents) &&
|
|
183
|
+
!contents.flow &&
|
|
184
|
+
!contents.has("morg_keywords") &&
|
|
185
|
+
!contents.has("morg_properties"));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* md→org: one of morg's own entries, a sequence of one-entry maps
|
|
189
|
+
* (`morg_keywords`, `morg_properties`, ADR 0005), restored only if
|
|
190
|
+
* every item fits its org line.
|
|
191
|
+
* @param yaml The frontmatter's YAML text.
|
|
192
|
+
* @param name The entry's key.
|
|
193
|
+
* @param fits Whether a key and value fit the org line they go back to.
|
|
194
|
+
* @returns The key, value pairs in order, and the remaining YAML text.
|
|
195
|
+
*/
|
|
196
|
+
export function takeMorgEntry(yaml, name, fits) {
|
|
197
|
+
return takeFrontmatterEntries(yaml, (key, value, text) => key.value === name && isSeq(value)
|
|
198
|
+
? morgEntryItems(value.items, text, fits)
|
|
199
|
+
: null);
|
|
200
|
+
}
|
|
201
|
+
// each item a single `KEY: value` pair, or the entry is not morg's to
|
|
202
|
+
// restore and stays in the frontmatter as written
|
|
203
|
+
function morgEntryItems(items, text, fits) {
|
|
204
|
+
const entries = [];
|
|
205
|
+
for (const item of items) {
|
|
206
|
+
const pair = isMap(item) && item.items.length === 1 ? item.items[0] : null;
|
|
207
|
+
if (!isScalar(pair?.key) ||
|
|
208
|
+
!(pair.value === null || isScalar(pair.value))) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const entry = [
|
|
212
|
+
text(pair.key),
|
|
213
|
+
pair.value ? text(pair.value) : ""
|
|
214
|
+
];
|
|
215
|
+
if (!fits(...entry)) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
entries.push(entry);
|
|
219
|
+
}
|
|
220
|
+
return entries;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* org→md: takes the file's header morg carries as frontmatter: the
|
|
224
|
+
* marked block and, where its YAML can take it, a file-level drawer.
|
|
225
|
+
* @param uniorgAst The parsed document.
|
|
226
|
+
* @param org The text it was parsed from.
|
|
227
|
+
* @param onWarning Reports a marked block morg cannot take.
|
|
228
|
+
* @returns The transform options holding them.
|
|
229
|
+
*/
|
|
230
|
+
export function takeFileHeader(uniorgAst, org, onWarning) {
|
|
231
|
+
// uniorg drops leading blank lines: a -*- comment below one is inert
|
|
232
|
+
const modeLine = startsWithModeLine(uniorgAst) && isModeLine(org);
|
|
233
|
+
detachKeywordsOfKeywords(uniorgAst);
|
|
234
|
+
return headerOptions(uniorgAst, takeFrontmatterBlock(uniorgAst, org, onWarning), modeLine);
|
|
235
|
+
}
|
|
236
|
+
// `#+NAME: n` directly above `#+TITLE: t`: uniorg attaches it to the
|
|
237
|
+
// keyword below, where nothing reads it; it becomes a keyword of its own
|
|
238
|
+
function detachKeywordsOfKeywords(uniorgAst) {
|
|
239
|
+
const children = uniorgAst.children;
|
|
240
|
+
for (let i = zerothSection(children).length - 1; i >= 0; i--) {
|
|
241
|
+
const node = children[i];
|
|
242
|
+
if (node?.type === "keyword") {
|
|
243
|
+
children.splice(i, 0, ...affiliatedKeywords(node.affiliated));
|
|
244
|
+
node.affiliated = {};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// affiliated keywords as keyword nodes, upper-cased as org reads them
|
|
249
|
+
// apart from their element: a short caption too
|
|
250
|
+
function affiliatedKeywords(affiliated) {
|
|
251
|
+
return affiliatedEntries(affiliated, true).map(([key, value]) => ({
|
|
252
|
+
type: "keyword",
|
|
253
|
+
key: key.toUpperCase(),
|
|
254
|
+
value
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* org→md, for a tree `transformMdastToUniorgAst` built rather than one
|
|
259
|
+
* parsed from org text: its file header, the block still raw text.
|
|
260
|
+
* @param uniorgAst The document; its header nodes are removed.
|
|
261
|
+
* @returns The transform options holding the header.
|
|
262
|
+
*/
|
|
263
|
+
export function takeRenderedFileHeader(uniorgAst) {
|
|
264
|
+
const modeLine = startsWithModeLine(uniorgAst);
|
|
265
|
+
const header = zerothSection(uniorgAst.children);
|
|
266
|
+
const block = header.find(node => renderedBlockYaml(node) !== undefined);
|
|
267
|
+
const frontmatter = renderedBlockYaml(block);
|
|
268
|
+
// the block, and the separators renderFileHeader put below keywords
|
|
269
|
+
uniorgAst.children = uniorgAst.children.filter((node, i, children) => node !== block &&
|
|
270
|
+
!(i < header.length &&
|
|
271
|
+
isSeparator(node) &&
|
|
272
|
+
children[i - 1]?.type === "keyword"));
|
|
273
|
+
return headerOptions(uniorgAst, frontmatter, modeLine);
|
|
274
|
+
}
|
|
275
|
+
// the transform options for a header whose block is taken: the
|
|
276
|
+
// file-level drawer joins it where its YAML can take it
|
|
277
|
+
function headerOptions(uniorgAst, frontmatter, modeLine) {
|
|
278
|
+
const takes = takesMorgEntries(frontmatter ?? "");
|
|
279
|
+
const fileProperties = takes ? takeFileDrawer(uniorgAst) : undefined;
|
|
280
|
+
return {
|
|
281
|
+
...(frontmatter !== undefined && { frontmatter }),
|
|
282
|
+
...(fileProperties && { fileProperties }),
|
|
283
|
+
takesMorgEntries: takes,
|
|
284
|
+
startsWithModeLine: modeLine
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// before the header is taken, which may leave a comment first
|
|
288
|
+
function startsWithModeLine(uniorgAst) {
|
|
289
|
+
const first = uniorgAst.children[0];
|
|
290
|
+
return !!first && isModeLineComment(first);
|
|
291
|
+
}
|
|
292
|
+
function zerothSection(children) {
|
|
293
|
+
const headline = children.findIndex(node => node.type === "headline");
|
|
294
|
+
return headline === -1 ? children : children.slice(0, headline);
|
|
295
|
+
}
|
|
296
|
+
function isSeparator(node) {
|
|
297
|
+
return node.type === "text" && node.value === "\n";
|
|
298
|
+
}
|
|
299
|
+
// the YAML of a block `frontmatterBlock` rendered, or undefined
|
|
300
|
+
function renderedBlockYaml(node) {
|
|
301
|
+
const text = node?.type === "text" ? (node.value ?? "") : "";
|
|
302
|
+
const begin = `${FRONTMATTER_BLOCK_BEGIN}\n`;
|
|
303
|
+
const end = "#+end_comment\n";
|
|
304
|
+
if (!text.startsWith(begin) || !text.endsWith(end)) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
return unescapeBlockLines(text.slice(begin.length, -end.length)).replace(/\n$/, "");
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* org→md: removes a file-level property drawer (org-roam's `:ID:`),
|
|
311
|
+
* which org reads as the file's only where nothing but comments
|
|
312
|
+
* precede it.
|
|
313
|
+
* @param uniorgAst The parsed document.
|
|
314
|
+
* @returns Its properties in order, or undefined without one.
|
|
315
|
+
*/
|
|
316
|
+
function takeFileDrawer(uniorgAst) {
|
|
317
|
+
const children = uniorgAst.children;
|
|
318
|
+
const index = children.findIndex(node => node.type !== "comment");
|
|
319
|
+
const drawer = children[index];
|
|
320
|
+
if (drawer?.type !== "property-drawer") {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
children.splice(index, 1);
|
|
324
|
+
return drawer.children.map(property => [property.key, property.value]);
|
|
325
|
+
}
|
|
326
|
+
// Emacs reads file variables from a `-*- … -*-` comment on the first line
|
|
327
|
+
const MODE_LINE_RE = /^[^\n]*-\*-.*-\*-/;
|
|
328
|
+
export function isModeLine(comment) {
|
|
329
|
+
return MODE_LINE_RE.test(comment);
|
|
330
|
+
}
|
|
331
|
+
export function isModeLineComment(node) {
|
|
332
|
+
return (node.type === "comment" &&
|
|
333
|
+
isModeLine(node.value ?? ""));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* md→org: marks the comment made from the Markdown body's first node as
|
|
337
|
+
* the file's mode line, if it is a -*- comment: what passes do to the
|
|
338
|
+
* tree before the header is rendered cannot make another one lead.
|
|
339
|
+
* @param node The uniorg node made from the body's first node.
|
|
340
|
+
*/
|
|
341
|
+
export function markModeLine(node) {
|
|
342
|
+
if (node && isModeLineComment(node)) {
|
|
343
|
+
;
|
|
344
|
+
node.modeLine = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* md→org: the file's header leads it, past keywords any pass put in
|
|
349
|
+
* front: the marked Emacs mode line on the first line, then the
|
|
350
|
+
* file-level property drawer, which org reads as the file's only where
|
|
351
|
+
* nothing but comments precede it.
|
|
352
|
+
* @param uniorgAst The document.
|
|
353
|
+
*/
|
|
354
|
+
function leadWithFileHeader(uniorgAst) {
|
|
355
|
+
const children = uniorgAst.children;
|
|
356
|
+
const body = children.findIndex(node => node.modeLine);
|
|
357
|
+
const modeLine = children[body];
|
|
358
|
+
if (modeLine) {
|
|
359
|
+
children.splice(body, 1);
|
|
360
|
+
children.unshift(modeLine);
|
|
361
|
+
}
|
|
362
|
+
const index = zerothSection(children).findIndex(node => node.type === "property-drawer");
|
|
363
|
+
if (index === -1) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const [drawer] = children.splice(index, 1);
|
|
367
|
+
const at = children.findIndex(node => node.type !== "comment");
|
|
368
|
+
children.splice(at === -1 ? children.length : at, 0, drawer);
|
|
369
|
+
}
|
|
370
|
+
// Org's own block escaping (org-escape-code-in-string): a line org would
|
|
371
|
+
// read as a headline or a keyword, or one already escaped, gets a comma
|
|
372
|
+
function escapeBlockLines(text) {
|
|
373
|
+
return text.replace(/^([ \t]*)(,*(?:\*|#\+))/gm, "$1,$2");
|
|
374
|
+
}
|
|
375
|
+
// org-unescape-code-in-string: drops one comma of an escaped line
|
|
376
|
+
function unescapeBlockLines(text) {
|
|
377
|
+
return text.replace(/^([ \t]*,*),(\*|#\+)/gm, "$1$2");
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* org→md: removes the marked frontmatter block, the first one before
|
|
381
|
+
* the first headline, recognized by its own begin line in the source.
|
|
382
|
+
* Keywords org attached to it stay, as keywords in its place: they
|
|
383
|
+
* come back apart from it, as nothing reads them on a comment block.
|
|
384
|
+
* @param uniorgAst The parsed document.
|
|
385
|
+
* @param org The text it was parsed from.
|
|
386
|
+
* @param onWarning Reports keywords attached to the block, and any
|
|
387
|
+
* other marked block, which stays a comment block: uniorg drops its
|
|
388
|
+
* marker.
|
|
389
|
+
* @returns The frontmatter's YAML text, or undefined without a block.
|
|
390
|
+
*/
|
|
391
|
+
function takeFrontmatterBlock(uniorgAst, org, onWarning) {
|
|
392
|
+
const children = uniorgAst.children;
|
|
393
|
+
const blocks = zerothSectionCommentBlocks(uniorgAst);
|
|
394
|
+
if (!blocks.length) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
const [index, ...others] = markedBlockIndices(org, blocks.length);
|
|
398
|
+
if (others.length) {
|
|
399
|
+
onWarning?.("second frontmatter block stays a comment block, without its marker");
|
|
400
|
+
}
|
|
401
|
+
const node = index === undefined ? undefined : blocks[index];
|
|
402
|
+
if (!node) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
if (Object.keys(node.affiliated).length) {
|
|
406
|
+
onWarning?.("keywords on the frontmatter block come back apart from it");
|
|
407
|
+
}
|
|
408
|
+
children.splice(children.indexOf(node), 1, ...affiliatedKeywords(node.affiliated));
|
|
409
|
+
return unescapeBlockLines(node.value).replace(/\n$/, "");
|
|
410
|
+
}
|
|
411
|
+
function zerothSectionCommentBlocks(uniorgAst) {
|
|
412
|
+
const blocks = [];
|
|
413
|
+
for (const node of uniorgAst.children) {
|
|
414
|
+
if (node.type === "headline" || node.type === "section") {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
if (node.type === "comment-block") {
|
|
418
|
+
blocks.push(node);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return blocks;
|
|
422
|
+
}
|
|
423
|
+
// uniorg drops the parameter after #+begin_comment; a parse that keeps
|
|
424
|
+
// positions finds each block's own begin line (after any keywords org
|
|
425
|
+
// attached to it)
|
|
426
|
+
const BEGIN_COMMENT_RE = /^[ \t]*#\+begin_comment(.*)$/im;
|
|
427
|
+
// org reads a line of stars and a space or tab as a headline anywhere,
|
|
428
|
+
// which is why blocks comma-escape theirs
|
|
429
|
+
const HEADLINE_RE = /^\*+[ \t]/m;
|
|
430
|
+
// `count`: how many comment blocks the full parse found there
|
|
431
|
+
function markedBlockIndices(org, count) {
|
|
432
|
+
// only the text before the first headline: its blocks are all we need,
|
|
433
|
+
// unless a star line inside a block cut it short
|
|
434
|
+
const headline = HEADLINE_RE.exec(org)?.index ?? org.length;
|
|
435
|
+
let zeroth = org.slice(0, headline);
|
|
436
|
+
let blocks = zerothSectionCommentBlocks(positionParser.parse(zeroth));
|
|
437
|
+
if (blocks.length !== count) {
|
|
438
|
+
zeroth = org;
|
|
439
|
+
blocks = zerothSectionCommentBlocks(positionParser.parse(org));
|
|
440
|
+
}
|
|
441
|
+
return blocks.flatMap((block, i) => {
|
|
442
|
+
const start = block.position?.start.offset ?? 0;
|
|
443
|
+
return BEGIN_COMMENT_RE.exec(zeroth.slice(start))?.[1]?.trim() ===
|
|
444
|
+
"morg_frontmatter"
|
|
445
|
+
? [i]
|
|
446
|
+
: [];
|
|
447
|
+
});
|
|
448
|
+
}
|