@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 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,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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The key, value pairs of a block of `key:: value` lines.
3
+ * @param text The block's text.
4
+ * @returns The pairs in order, or null if a line is none.
5
+ */
6
+ export declare function keyValueEntries(text: string): [string, string][] | null;