@remigius42/morg 0.7.0 → 0.9.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 (47) hide show
  1. package/README.md +35 -18
  2. package/dist/cli/args.d.ts +2 -0
  3. package/dist/cli/args.js +2 -0
  4. package/dist/cli/conversion.d.ts +3 -3
  5. package/dist/cli/conversion.js +4 -4
  6. package/dist/cli/flags.d.ts +1 -1
  7. package/dist/cli/flags.js +15 -1
  8. package/dist/cli/formats.d.ts +2 -1
  9. package/dist/cli/formats.js +1 -0
  10. package/dist/cli/presets.d.ts +13 -2
  11. package/dist/cli/presets.js +17 -3
  12. package/dist/cli.js +4 -5
  13. package/dist/config.d.ts +9 -4
  14. package/dist/config.js +2 -0
  15. package/dist/conversionOptions.d.ts +19 -3
  16. package/dist/conversionOptions.js +61 -0
  17. package/dist/core/bracedScripts.d.ts +7 -0
  18. package/dist/core/bracedScripts.js +10 -0
  19. package/dist/core/frontmatterBlock.d.ts +7 -0
  20. package/dist/core/frontmatterBlock.js +7 -2
  21. package/dist/core/lineSyntax.d.ts +13 -0
  22. package/dist/core/lineSyntax.js +16 -4
  23. package/dist/core/mdastToUniorg/blocks.js +4 -1
  24. package/dist/core/passthroughSource.d.ts +21 -0
  25. package/dist/core/passthroughSource.js +94 -0
  26. package/dist/core/uniorgToMdast/elements.js +5 -0
  27. package/dist/core/uniorgToMdast/shared.js +6 -1
  28. package/dist/index.d.ts +1 -2
  29. package/dist/markdownToOrg.js +19 -6
  30. package/dist/normalize.js +16 -2
  31. package/dist/options.d.ts +3 -7
  32. package/dist/orgToMarkdown.js +14 -4
  33. package/dist/presets/hooks.d.ts +49 -0
  34. package/dist/presets/hooks.js +72 -0
  35. package/dist/presets/logseq.d.ts +3 -22
  36. package/dist/presets/logseq.js +306 -195
  37. package/dist/presets/logseqOutline.d.ts +47 -0
  38. package/dist/presets/logseqOutline.js +428 -0
  39. package/dist/presets/logseqVanillaMarkdown.d.ts +9 -0
  40. package/dist/presets/logseqVanillaMarkdown.js +295 -0
  41. package/dist/presets/obsidian.js +4 -2
  42. package/dist/presets/registry.d.ts +1 -1
  43. package/dist/presets/registry.js +5 -3
  44. package/dist/presets/sides.d.ts +40 -0
  45. package/dist/presets/sides.js +42 -0
  46. package/dist/presets/types.d.ts +49 -8
  47. package/package.json +1 -1
@@ -0,0 +1,295 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkFrontmatter from "remark-frontmatter";
5
+ import remarkMath from "remark-math";
6
+ import { visit } from "unist-util-visit";
7
+ // Vanilla Markdown read as Logseq's outline (ADR 0006), as Logseq reads
8
+ // a file it did not write: a list item is a block, its nested list its
9
+ // children.
10
+ function parse(markdown) {
11
+ return unified()
12
+ .use(remarkParse)
13
+ .use(remarkGfm)
14
+ .use(remarkFrontmatter)
15
+ .use(remarkMath)
16
+ .parse(markdown);
17
+ }
18
+ // a node's source lines, 0-based start and end line
19
+ function span(node) {
20
+ return [
21
+ (node.position?.start.line ?? 1) - 1,
22
+ (node.position?.end.line ?? 1) - 1
23
+ ];
24
+ }
25
+ const BULLET_RE = /^\s*(?:[-*+]|\d+[.)])(?: |$)/;
26
+ const CHECKBOX_RE = /^\[([ xX])\](?: |$)/;
27
+ // the markers Logseq shows unchecked other than TODO, which a task item
28
+ // writes after its checkbox
29
+ const UNCHECKED_MARKER_RE = /^(?:NOW|LATER|DOING|IN-PROGRESS|WAIT|WAITING) /;
30
+ const PROPERTY_RE = /^([\w.-]+)::(?: (.*))?$/;
31
+ const HEADING_RE = /^(#{1,6})(?: (.*))?$/;
32
+ // a task item's checkbox as Logseq's task marker
33
+ function taskTitle(item, title) {
34
+ const checkbox = CHECKBOX_RE.exec(title);
35
+ if (item.checked === null || item.checked === undefined || !checkbox) {
36
+ return title;
37
+ }
38
+ const text = title.slice(checkbox[0].length);
39
+ if (item.checked) {
40
+ return `DONE ${text}`;
41
+ }
42
+ return UNCHECKED_MARKER_RE.test(text) ? text : `TODO ${text}`;
43
+ }
44
+ // key:: lines: planning under its org-ism names (orgismKeys maps them),
45
+ // one planning line where the first of them was, and properties
46
+ function readMeta(lines, context) {
47
+ const canonical = new Map(Object.entries(context.orgismKeys ?? {}).map(([key, custom]) => [
48
+ custom,
49
+ key
50
+ ]));
51
+ const meta = [];
52
+ let planning;
53
+ for (const line of lines) {
54
+ const [, rawKey = "", value = ""] = PROPERTY_RE.exec(line) ?? [];
55
+ const key = canonical.get(rawKey) ?? rawKey;
56
+ if (key !== "scheduled" && key !== "deadline") {
57
+ meta.push({ key: rawKey, value });
58
+ continue;
59
+ }
60
+ const entry = `${key.toUpperCase()}: ${value}`;
61
+ if (planning) {
62
+ planning.lines[0] += ` ${entry}`;
63
+ }
64
+ else {
65
+ planning = { lines: [entry] };
66
+ meta.push(planning);
67
+ }
68
+ }
69
+ return meta;
70
+ }
71
+ // a lazy continuation line sits anywhere left of the column
72
+ function dedent(line, column) {
73
+ const indent = /^ */.exec(line)?.[0].length ?? 0;
74
+ return line.slice(Math.min(indent, column));
75
+ }
76
+ function leadingProperties(lines) {
77
+ const count = lines.findIndex(line => !PROPERTY_RE.test(line));
78
+ return count === -1 ? lines.length : count;
79
+ }
80
+ // an item's own lines, up to its nested list: the bullet off its first,
81
+ // its checkbox read, continuation lines moved left past the bullet
82
+ function itemSource(item, end, lines) {
83
+ const [first = "", ...rest] = lines.slice(span(item)[0], end + 1);
84
+ const column = BULLET_RE.exec(first)?.[0].length ?? 0;
85
+ return [
86
+ taskTitle(item, first.slice(column)),
87
+ ...rest.map(line => dedent(line, column))
88
+ ];
89
+ }
90
+ // a title as a heading's level and text
91
+ function readHeading(title) {
92
+ const match = HEADING_RE.exec(title);
93
+ return match ? [match[1]?.length ?? 0, match[2] ?? ""] : [0, title];
94
+ }
95
+ // key:: lines below the title, or opening the content, are its meta
96
+ function itemBlock(source, level, reader) {
97
+ const metaFirst = PROPERTY_RE.test(source[0] ?? "");
98
+ const body = metaFirst ? source : source.slice(1);
99
+ const count = leadingProperties(body);
100
+ const meta = readMeta(body.slice(0, count), reader.context);
101
+ if (metaFirst) {
102
+ return { level, heading: 0, metaFirst, meta, content: body.slice(count) };
103
+ }
104
+ const [heading, title] = readHeading(source[0] ?? "");
105
+ return {
106
+ level,
107
+ heading,
108
+ metaFirst,
109
+ meta,
110
+ content: [title, ...body.slice(count)]
111
+ };
112
+ }
113
+ function readItem(item, level, ordered, reader) {
114
+ // a list on the item's first line is its content, not its children
115
+ const nested = item.children.filter((child) => child.type === "list" && span(child)[0] > span(item)[0]);
116
+ const end = nested[0] ? span(nested[0])[0] - 1 : span(item)[1];
117
+ const block = itemBlock(itemSource(item, end, reader.lines), level, reader);
118
+ if (ordered) {
119
+ block.meta.push({ key: "logseq.order-list-type", value: "number" });
120
+ }
121
+ reader.blocks.push(block);
122
+ // what follows the first nested list: more lists, and text, which a
123
+ // block's content cannot hold after its children, as child blocks
124
+ const column = BULLET_RE.exec(reader.lines[span(item)[0]] ?? "")?.[0].length;
125
+ const rest = nested[0]
126
+ ? item.children.slice(item.children.indexOf(nested[0]))
127
+ : [];
128
+ for (const child of rest) {
129
+ if (child.type === "list") {
130
+ readList(child, level + 1, reader);
131
+ continue;
132
+ }
133
+ const [start, end] = span(child);
134
+ reader.blocks.push({
135
+ level: level + 1,
136
+ heading: 0,
137
+ metaFirst: false,
138
+ meta: [],
139
+ content: reader.lines
140
+ .slice(start, end + 1)
141
+ .map(line => dedent(line, column ?? 0))
142
+ });
143
+ }
144
+ }
145
+ function readList(list, level, reader) {
146
+ for (const item of list.children) {
147
+ readItem(item, level, Boolean(list.ordered), reader);
148
+ }
149
+ }
150
+ function headingBlock(node, headings, lines) {
151
+ while ((headings.at(-1)?.depth ?? 0) >= node.depth) {
152
+ headings.pop();
153
+ }
154
+ const level = (headings.at(-1)?.level ?? 0) + 1;
155
+ headings.push({ depth: node.depth, level });
156
+ return {
157
+ level,
158
+ heading: node.depth,
159
+ metaFirst: false,
160
+ meta: [],
161
+ content: [headingTitle(lines[span(node)[0]] ?? "")]
162
+ };
163
+ }
164
+ // a link destination as Markdown writes it inline
165
+ function destination(url) {
166
+ return /[\s()<>]/.test(url) ? `<${url}>` : url;
167
+ }
168
+ function offsets(node) {
169
+ return [node.position?.start.offset ?? 0, node.position?.end.offset ?? 0];
170
+ }
171
+ // what spans a page cannot travel in a block: a reference link becomes
172
+ // inline, a definition goes, and so does a footnote's definition, kept
173
+ // for the block of its first reference (ADR 0006)
174
+ function resolveReferences(markdown) {
175
+ // links first: a footnote's definition may hold one
176
+ const inline = inlineReferences(markdown);
177
+ const footnotes = new Map();
178
+ const edits = [];
179
+ visit(parse(inline), "footnoteDefinition", (node) => {
180
+ const [start, end] = offsets(node);
181
+ footnotes.set(node.label ?? "", inline.slice(start, end));
182
+ edits.push([start, end, ""]);
183
+ });
184
+ return { markdown: applyEdits(inline, edits), footnotes };
185
+ }
186
+ function inlineReferences(markdown) {
187
+ const tree = parse(markdown);
188
+ const definitions = new Map();
189
+ visit(tree, "definition", (node) => {
190
+ definitions.set(node.identifier, node);
191
+ });
192
+ const edits = [];
193
+ visit(tree, (node) => {
194
+ const [start, end] = offsets(node);
195
+ if (node.type === "definition") {
196
+ edits.push([start, end, ""]);
197
+ }
198
+ else if (node.type === "linkReference" ||
199
+ node.type === "imageReference") {
200
+ const reference = node;
201
+ const definition = definitions.get(reference.identifier);
202
+ if (definition) {
203
+ edits.push([start, end, inlineLink(reference, definition, markdown)]);
204
+ }
205
+ }
206
+ });
207
+ return applyEdits(markdown, edits);
208
+ }
209
+ // edits of nodes that do not nest, last first so offsets hold
210
+ function applyEdits(text, edits) {
211
+ let result = text;
212
+ for (const [start, end, replacement] of edits.sort((a, b) => b[0] - a[0])) {
213
+ result = result.slice(0, start) + replacement + result.slice(end);
214
+ }
215
+ return result;
216
+ }
217
+ function inlineLink(reference, definition, markdown) {
218
+ const title = definition.title ? ` "${definition.title}"` : "";
219
+ const target = `(${destination(definition.url)}${title})`;
220
+ if (reference.type === "imageReference") {
221
+ return `![${reference.alt ?? ""}]${target}`;
222
+ }
223
+ const first = reference.children[0];
224
+ const last = reference.children.at(-1);
225
+ const text = first && last ? markdown.slice(offsets(first)[0], offsets(last)[1]) : "";
226
+ return `[${text}]${target}`;
227
+ }
228
+ // a footnote's definition, after the first block that refers to it
229
+ function placeFootnotes(blocks, footnotes) {
230
+ for (const [label, definition] of footnotes) {
231
+ const block = blocks.find(candidate => candidate.content.some(line => line.includes(`[^${label}]`)));
232
+ block?.content.push("", ...definition.split("\n").map(line => line.trim()));
233
+ }
234
+ }
235
+ /**
236
+ * Reads a Vanilla Markdown page into Logseq's outline.
237
+ * @param markdown The Markdown page.
238
+ * @param context The conversion's org-ism key names.
239
+ * @returns The outline, its blocks' content in Markdown.
240
+ */
241
+ export function readVanillaMarkdownOutline(markdown, context) {
242
+ const resolved = resolveReferences(markdown);
243
+ markdown = resolved.markdown;
244
+ const reader = {
245
+ lines: markdown.split(/\r?\n/),
246
+ blocks: [],
247
+ context
248
+ };
249
+ const headings = [];
250
+ // a heading whose body is still running: what follows it, up to a
251
+ // list or the next heading, is its content
252
+ let body;
253
+ const page = [];
254
+ for (const node of parse(markdown).children) {
255
+ const level = (headings.at(-1)?.level ?? 0) + 1;
256
+ if (node.type === "yaml") {
257
+ const [start, end] = span(node);
258
+ page.push(...reader.lines.slice(start, end + 1));
259
+ }
260
+ else if (node.type === "heading") {
261
+ body = headingBlock(node, headings, reader.lines);
262
+ reader.blocks.push(body);
263
+ }
264
+ else if (node.type === "list") {
265
+ body = undefined;
266
+ readList(node, level, reader);
267
+ }
268
+ else {
269
+ readText(node, level, body, reader);
270
+ }
271
+ }
272
+ placeFootnotes(reader.blocks, resolved.footnotes);
273
+ return { page, blocks: reader.blocks };
274
+ }
275
+ // text below a heading is its body; after a list, a block of its own
276
+ function readText(node, level, body, reader) {
277
+ const [start, end] = span(node);
278
+ const source = reader.lines.slice(start, end + 1);
279
+ if (body) {
280
+ // the body's paragraphs keep the blank lines between them
281
+ body.content.push(...(body.content.length > 1 ? [""] : []), ...source);
282
+ return;
283
+ }
284
+ reader.blocks.push({
285
+ level,
286
+ heading: 0,
287
+ metaFirst: false,
288
+ meta: [],
289
+ content: source
290
+ });
291
+ }
292
+ // an ATX heading's text, without its markers
293
+ function headingTitle(line) {
294
+ return line.replace(/^ {0,3}#{1,6}(?:\s+|$)/, "").replace(/\s+#+\s*$/, "");
295
+ }
@@ -7,8 +7,10 @@ import { toString } from "orgast-util-to-string";
7
7
  export function obsidian() {
8
8
  return {
9
9
  name: "obsidian",
10
- applyToUniorg: rewriteAliasedWikilinks,
11
- extractFromUniorg: fuzzyLinksToWikilinks
10
+ markdown: {
11
+ read: { org: rewriteAliasedWikilinks },
12
+ write: fuzzyLinksToWikilinks
13
+ }
12
14
  };
13
15
  }
14
16
  // md→org: a wikilink travels as plain text; org already reads `[[Page]]`
@@ -2,7 +2,7 @@ import type { Preset } from "./types.js";
2
2
  /**
3
3
  * Instantiates a preset by name.
4
4
  * @param presetName Preset name, or `undefined` for no preset.
5
- * @returns The preset, or `undefined` when no name was given.
5
+ * @returns The preset, or `undefined` for none or `vanilla`.
6
6
  * @throws If the name is not a known preset.
7
7
  */
8
8
  export declare function createPreset(presetName: string | undefined): Preset | undefined;
@@ -6,15 +6,17 @@ const PRESETS = {
6
6
  logseq: () => logseq(),
7
7
  obsidian: () => obsidian()
8
8
  };
9
- const PRESET_NAMES = Object.keys(PRESETS);
9
+ // the Vanilla side: no preset, but a name to set a side back to it
10
+ const VANILLA = "vanilla";
11
+ const PRESET_NAMES = [VANILLA, ...Object.keys(PRESETS)];
10
12
  /**
11
13
  * Instantiates a preset by name.
12
14
  * @param presetName Preset name, or `undefined` for no preset.
13
- * @returns The preset, or `undefined` when no name was given.
15
+ * @returns The preset, or `undefined` for none or `vanilla`.
14
16
  * @throws If the name is not a known preset.
15
17
  */
16
18
  export function createPreset(presetName) {
17
- if (!presetName) {
19
+ if (!presetName || presetName === VANILLA) {
18
20
  return undefined;
19
21
  }
20
22
  const factory = PRESETS[presetName];
@@ -0,0 +1,40 @@
1
+ import type { Preset } from "./types.js";
2
+ /** The presets of a conversion, by side (ADR 0006). */
3
+ export interface PresetOptions {
4
+ /** Sets both sides, where a side preset does not. */
5
+ preset?: Preset;
6
+ /** The dialect the input is read in. */
7
+ inputPreset?: Preset;
8
+ /** The dialect the output is written in. */
9
+ outputPreset?: Preset;
10
+ }
11
+ /** The preset each side resolved to; `undefined` is Vanilla. */
12
+ export interface Sides {
13
+ input: Preset | undefined;
14
+ output: Preset | undefined;
15
+ }
16
+ /** A document format. */
17
+ export type Format = "markdown" | "org";
18
+ /**
19
+ * Resolves the preset of each side of a conversion. A side preset must
20
+ * have a dialect for its side's format; `preset` leaves a side it has
21
+ * none for Vanilla (Obsidian writes no org).
22
+ * @param options The conversion's preset options.
23
+ * @param from The input's format.
24
+ * @param to The output's format.
25
+ * @returns The preset per side.
26
+ * @throws If a side preset has no dialect for its side's format, or
27
+ * `preset` names another preset than a side preset.
28
+ */
29
+ export declare function resolveSides(options: PresetOptions, from: Format, to: Format): Sides;
30
+ /**
31
+ * Resolves one side's preset: the side preset, else `preset` where it
32
+ * has a dialect for the side's format.
33
+ * @param sidePreset The side's own preset, if any.
34
+ * @param preset The preset for both sides, if any.
35
+ * @param format The side's format.
36
+ * @param side Which side it is.
37
+ * @returns The side's preset; `undefined` is Vanilla.
38
+ * @throws If the side preset has no dialect for the format.
39
+ */
40
+ export declare function resolveSide(sidePreset: Preset | undefined, preset: Preset | undefined, format: Format, side: "input" | "output"): Preset | undefined;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Resolves the preset of each side of a conversion. A side preset must
3
+ * have a dialect for its side's format; `preset` leaves a side it has
4
+ * none for Vanilla (Obsidian writes no org).
5
+ * @param options The conversion's preset options.
6
+ * @param from The input's format.
7
+ * @param to The output's format.
8
+ * @returns The preset per side.
9
+ * @throws If a side preset has no dialect for its side's format, or
10
+ * `preset` names another preset than a side preset.
11
+ */
12
+ export function resolveSides(options, from, to) {
13
+ for (const key of ["inputPreset", "outputPreset"]) {
14
+ const sidePreset = options[key];
15
+ if (options.preset &&
16
+ sidePreset &&
17
+ sidePreset.name !== options.preset.name) {
18
+ throw new Error(`preset '${options.preset.name}' conflicts with ${key} '${sidePreset.name}'`);
19
+ }
20
+ }
21
+ return {
22
+ input: resolveSide(options.inputPreset, options.preset, from, "input"),
23
+ output: resolveSide(options.outputPreset, options.preset, to, "output")
24
+ };
25
+ }
26
+ /**
27
+ * Resolves one side's preset: the side preset, else `preset` where it
28
+ * has a dialect for the side's format.
29
+ * @param sidePreset The side's own preset, if any.
30
+ * @param preset The preset for both sides, if any.
31
+ * @param format The side's format.
32
+ * @param side Which side it is.
33
+ * @returns The side's preset; `undefined` is Vanilla.
34
+ * @throws If the side preset has no dialect for the format.
35
+ */
36
+ export function resolveSide(sidePreset, preset, format, side) {
37
+ if (sidePreset && !sidePreset[format]) {
38
+ const purpose = side === "input" ? "read the input" : "write the output";
39
+ throw new Error(`Preset '${sidePreset.name}' has no ${format} dialect to ${purpose} in`);
40
+ }
41
+ return sidePreset ?? (preset?.[format] ? preset : undefined);
42
+ }
@@ -1,15 +1,56 @@
1
1
  import type { Root as MdastRoot } from "mdast";
2
2
  import type { OrgData } from "uniorg";
3
3
  /**
4
- * A dialect preset: transforms applied on top of the dialect-agnostic core.
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;
7
- * `extractFromUniorg` runs in the org→md pipeline before the generic
8
- * transform, normalizing dialect conventions back to generic uniorg.
4
+ * A dialect preset: transforms applied on top of the dialect-agnostic
5
+ * core, one dialect per format it knows (ADR 0006), each read in one
6
+ * direction and written in the other. `convertOrg` and
7
+ * `convertMarkdown` take a whole conversion over, for a dialect whose
8
+ * documents are no single org or Markdown document (an outline of
9
+ * blocks, each its own fragment); `convert` runs the core on a fragment,
10
+ * with the given preset's hooks.
9
11
  */
10
12
  export interface Preset {
11
13
  name: string;
12
- applyToMdast?: (mdast: MdastRoot, markdown: string) => void;
13
- applyToUniorg?: (uniorgAst: OrgData) => OrgData;
14
- extractFromUniorg?: (uniorgAst: OrgData) => OrgData;
14
+ markdown?: MarkdownDialect;
15
+ org?: OrgDialect;
16
+ convertOrg?: (org: string, convert: FragmentConverter, context: ConversionContext) => string;
17
+ convertMarkdown?: (markdown: string, convert: FragmentConverter, context: ConversionContext) => string;
18
+ }
19
+ /**
20
+ * What a preset that takes a whole conversion over learns of it: the
21
+ * side or sides it is on (the other one is another preset's or
22
+ * Vanilla), and the conversion's warning callback and org-ism key names.
23
+ */
24
+ export interface ConversionContext {
25
+ side: "both" | "input" | "output";
26
+ onWarning?: (message: string) => void;
27
+ orgismKeys?: Record<string, string>;
28
+ }
29
+ /**
30
+ * Converts a fragment with the core, the given preset's AST hooks on
31
+ * the taking-over preset's side or sides, and the other side's preset;
32
+ * a Vanilla side converts with `carried`, the preset whose syntax it
33
+ * carries.
34
+ */
35
+ export type FragmentConverter = (fragment: string, preset?: Preset, carried?: Preset) => string;
36
+ /**
37
+ * A preset's Markdown dialect. `read` runs in md→org: `mdast` on the
38
+ * parse, before the generic transform, with the source at hand; `org`
39
+ * after it. `write` runs in org→md before the generic transform.
40
+ */
41
+ export interface MarkdownDialect {
42
+ read?: {
43
+ mdast?: (mdast: MdastRoot, markdown: string) => void;
44
+ org?: (uniorgAst: OrgData) => OrgData;
45
+ };
46
+ write?: (uniorgAst: OrgData) => OrgData;
47
+ }
48
+ /**
49
+ * A preset's org dialect. `read` runs in org→md before the generic
50
+ * transform, ahead of the Markdown dialect's `write`; `write` runs in
51
+ * md→org after the Markdown dialect was read.
52
+ */
53
+ export interface OrgDialect {
54
+ read?: (uniorgAst: OrgData) => OrgData;
55
+ write?: (uniorgAst: OrgData) => OrgData;
15
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remigius42/morg",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Bidirectional Markdown ↔ Org-mode converter with round-trip convergence",
5
5
  "keywords": [
6
6
  "org-mode",