@remigius42/morg 0.4.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 (67) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +267 -0
  3. package/dist/cli/args.d.ts +15 -0
  4. package/dist/cli/args.js +105 -0
  5. package/dist/cli/configFile.d.ts +2 -0
  6. package/dist/cli/configFile.js +18 -0
  7. package/dist/cli/conversion.d.ts +11 -0
  8. package/dist/cli/conversion.js +50 -0
  9. package/dist/cli/error.d.ts +2 -0
  10. package/dist/cli/error.js +4 -0
  11. package/dist/cli/formats.d.ts +3 -0
  12. package/dist/cli/formats.js +46 -0
  13. package/dist/cli/presets.d.ts +2 -0
  14. package/dist/cli/presets.js +10 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +54 -0
  17. package/dist/config.d.ts +20 -0
  18. package/dist/config.js +22 -0
  19. package/dist/conversionOptions.d.ts +32 -0
  20. package/dist/conversionOptions.js +34 -0
  21. package/dist/core/markdownStyle.d.ts +27 -0
  22. package/dist/core/markdownStyle.js +108 -0
  23. package/dist/core/mdastToUniorg/blocks.d.ts +20 -0
  24. package/dist/core/mdastToUniorg/blocks.js +211 -0
  25. package/dist/core/mdastToUniorg/context.d.ts +15 -0
  26. package/dist/core/mdastToUniorg/context.js +7 -0
  27. package/dist/core/mdastToUniorg/index.d.ts +13 -0
  28. package/dist/core/mdastToUniorg/index.js +95 -0
  29. package/dist/core/mdastToUniorg/lists.d.ts +4 -0
  30. package/dist/core/mdastToUniorg/lists.js +45 -0
  31. package/dist/core/mdastToUniorg/phrasing.d.ts +4 -0
  32. package/dist/core/mdastToUniorg/phrasing.js +176 -0
  33. package/dist/core/uniorgToMdast/elements.d.ts +4 -0
  34. package/dist/core/uniorgToMdast/elements.js +278 -0
  35. package/dist/core/uniorgToMdast/footnotes.d.ts +10 -0
  36. package/dist/core/uniorgToMdast/footnotes.js +54 -0
  37. package/dist/core/uniorgToMdast/index.d.ts +11 -0
  38. package/dist/core/uniorgToMdast/index.js +84 -0
  39. package/dist/core/uniorgToMdast/lists.d.ts +4 -0
  40. package/dist/core/uniorgToMdast/lists.js +94 -0
  41. package/dist/core/uniorgToMdast/objects.d.ts +4 -0
  42. package/dist/core/uniorgToMdast/objects.js +152 -0
  43. package/dist/core/uniorgToMdast/shared.d.ts +25 -0
  44. package/dist/core/uniorgToMdast/shared.js +55 -0
  45. package/dist/core/uniorgToMdast/tables.d.ts +6 -0
  46. package/dist/core/uniorgToMdast/tables.js +66 -0
  47. package/dist/fileNames.d.ts +16 -0
  48. package/dist/fileNames.js +34 -0
  49. package/dist/index.d.ts +13 -0
  50. package/dist/index.js +8 -0
  51. package/dist/markdownToOrg.d.ts +8 -0
  52. package/dist/markdownToOrg.js +194 -0
  53. package/dist/normalize.d.ts +26 -0
  54. package/dist/normalize.js +24 -0
  55. package/dist/options.d.ts +102 -0
  56. package/dist/options.js +9 -0
  57. package/dist/orgToMarkdown.d.ts +8 -0
  58. package/dist/orgToMarkdown.js +61 -0
  59. package/dist/presets/logseq.d.ts +25 -0
  60. package/dist/presets/logseq.js +298 -0
  61. package/dist/presets/obsidian.d.ts +6 -0
  62. package/dist/presets/obsidian.js +40 -0
  63. package/dist/presets/registry.d.ts +8 -0
  64. package/dist/presets/registry.js +25 -0
  65. package/dist/presets/types.d.ts +12 -0
  66. package/dist/presets/types.js +1 -0
  67. package/package.json +112 -0
package/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # [morg](https://github.com/remigius42/morg)
2
+
3
+ Copyright 2026 [Andreas Remigius Schmidt](https://github.com/remigius42)
4
+
5
+ [![Version](https://img.shields.io/github/v/tag/remigius42/morg?label=version)](https://github.com/remigius42/morg/blob/main/CHANGELOG.md)
6
+ [![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE)
7
+ [![CI](https://github.com/remigius42/morg/actions/workflows/ci.yml/badge.svg)](https://github.com/remigius42/morg/actions/workflows/ci.yml)
8
+ ![Node](https://img.shields.io/badge/node-%3E%3D20-lightgrey.svg)
9
+ [![Codacy grade](https://app.codacy.com/project/badge/Grade/da438d1b90e74d40b03f9fa5b3eca221)](https://app.codacy.com/gh/remigius42/morg/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
10
+ [![Codacy coverage](https://app.codacy.com/project/badge/Coverage/da438d1b90e74d40b03f9fa5b3eca221)](https://app.codacy.com/gh/remigius42/morg/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
11
+
12
+ Bidirectional **Markdown ↔ Org-mode** converter, built on the
13
+ [unified](https://unifiedjs.com/) ecosystem
14
+ ([remark](https://github.com/remarkjs/remark) for Markdown,
15
+ [uniorg](https://github.com/rasendubi/uniorg) for Org).
16
+
17
+ morg treats Org as a canonical plain-text format and Markdown (Obsidian,
18
+ generic) as the interop surface. Dialect conventions — such as
19
+ [Logseq](https://docs.logseq.com/)'s `heading::` properties and outline nesting
20
+ — are supported via presets.
21
+
22
+ ## Round-trip convergence
23
+
24
+ Strict byte-losslessness between the two formats is impossible. morg's
25
+ guarantee is to be **semantically faithful and convergent** instead
26
+ (see [ADR 0001](docs/adr/0001-convergence-over-losslessness.md)):
27
+
28
+ - One round trip (`md → org → md` or `org → md → org`) may normalize formatting,
29
+ but its output is a fixed point: converting again reproduces it byte-for-byte.
30
+ - Input already in canonical form is a round-trip identity. Opt-in
31
+ `recordStyle` widens that set: a file whose bullet, emphasis, fence
32
+ and rule markers are used consistently has them recorded in the org
33
+ file and restored on the way back, so it is left untouched
34
+ ([ADR 0004](docs/adr/0004-record-source-markdown-style.md)).
35
+ - `md → org` preserves Markdown-only constructs ("md-isms") as `morg_`-prefixed
36
+ org properties; `org → md` serializes Org-only constructs ("org-isms") as
37
+ `key:: value` conventions ([ADR
38
+ 0002](docs/adr/0002-mdism-property-namespace.md)).
39
+ - The few constructs that cannot be carried are documented in the
40
+ [mapping reference](docs/mappings.md) and reported as warnings.
41
+
42
+ Round-trip fixture tests are the backbone of the test suite
43
+ (`tests/roundtrip.spec.ts`). The Web UI is covered by vitest specs under
44
+ happy-dom and by a Playwright suite (`tests/e2e/`) that drives the built
45
+ pages in Chromium and WebKit, including axe accessibility audits in both
46
+ color schemes.
47
+
48
+ ## Usage
49
+
50
+ ### Web UI
51
+
52
+ Try morg without installing anything at
53
+ [morg.binarypoetry.ch](https://morg.binarypoetry.ch) — all conversion
54
+ happens in your browser, nothing is uploaded (see [ADR
55
+ 0003](docs/adr/0003-client-side-web-ui-on-github-pages.md)). The
56
+ chrome-less embed page (`/embed.html`, optionally with
57
+ `?theme=dark|light`) can be iframed into other sites. It posts its
58
+ content height to the host on every change, so the frame can follow it
59
+ rather than scrolling inside a page that already scrolls:
60
+
61
+ ```js
62
+ addEventListener("message", event => {
63
+ if (event.origin !== "https://morg.binarypoetry.ch") return
64
+ if (event.data?.type === "morg:height") {
65
+ frame.style.height = `${event.data.height}px`
66
+ }
67
+ })
68
+ ```
69
+
70
+ Give the frame at least 768px of width if you can — below that the
71
+ input and output stack, which doubles its height. `allow="clipboard-write"`
72
+ lets the Copy button use the clipboard rather than falling back to
73
+ selecting the output.
74
+
75
+ Besides pasting, a file can be opened with the picker or dropped
76
+ anywhere on the page — a `.toml` lands in the config panel, a document
77
+ in the input, and the conversion direction follows the extension. Drop
78
+ both at once and each goes where it belongs; an overlay names what is
79
+ accepted while a drag is in flight, and anything that turns out not to
80
+ be text is named in the warning list rather than loaded. The result can
81
+ be copied or saved with the Copy and Download buttons; a normalized file
82
+ is saved as `notes.normalized.org`, and switching to a direction that no
83
+ longer reads the opened file falls back to a generic name, so neither
84
+ lands on top of its own source. Files are read and written by the
85
+ browser itself; this is not an upload.
86
+
87
+ Typing is converted once you pause, not once per keystroke, and the
88
+ conversion itself runs in a web worker, so the page stays responsive
89
+ even while a large document is being converted. Copy and Download are
90
+ unavailable for as long as a conversion is running, so they can never
91
+ save the previous document's output.
92
+
93
+ ### CLI
94
+
95
+ Run it without installing, or install it globally:
96
+
97
+ ```bash
98
+ npx @remigius42/morg --input notes.md --output notes.org
99
+ npm install --global @remigius42/morg
100
+ ```
101
+
102
+ ```bash
103
+ # Formats inferred from file extensions
104
+ morg --input notes.md --output notes.org
105
+
106
+ # stdin/stdout with explicit format
107
+ echo "# Hello" | morg --from markdown
108
+
109
+ # Apply a dialect preset
110
+ morg --input page.md --output page.org --preset logseq
111
+
112
+ # Dropped constructs are reported on stderr; -s / --silent suppresses.
113
+ # Boolean flags take an optional value, so --silent false overrides a
114
+ # morg.toml that sets it
115
+ morg --input notes.md --output notes.org --silent
116
+
117
+ # Record the source's own markdown style (bullet, emphasis, fence,
118
+ # rule) in the org file, so the return trip restores it instead of
119
+ # canonicalizing it; markers used inconsistently warn and are skipped
120
+ morg --input notes.md --output notes.org --record-style
121
+
122
+ # Normalize to canonical form (same format in and out); this
123
+ # canonicalizes — the one-time reformat a first conversion would
124
+ # apply anyway (ADR 0001) — it is not a style formatter like prettier
125
+ morg normalize --input notes.org --output notes.org
126
+ ```
127
+
128
+ ### Configuration file
129
+
130
+ Options can live in a `morg.toml` (auto-discovered in the working
131
+ directory, or passed via `--config path`). Precedence: CLI flags >
132
+ config file > defaults:
133
+
134
+ ```toml
135
+ preset = "logseq"
136
+
137
+ [orgToMarkdown.markdownStyle]
138
+ emphasis = "_" # align with prettier
139
+ ```
140
+
141
+ The full reference — all sections and compatibility snippets for
142
+ prettier and mdformat — is in
143
+ [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
144
+
145
+ ### Library
146
+
147
+ ```bash
148
+ npm install @remigius42/morg
149
+ ```
150
+
151
+ morg is ESM-only and ships its own type declarations:
152
+
153
+ ```ts
154
+ import {
155
+ convertMarkdownToOrg,
156
+ convertOrgToMarkdown,
157
+ logseq
158
+ } from "@remigius42/morg"
159
+
160
+ const org = convertMarkdownToOrg("# Hello\n\nWorld.")
161
+ const md = convertOrgToMarkdown(org)
162
+
163
+ // Logseq dialect
164
+ const logseqOrg = convertMarkdownToOrg(markdown, { preset: logseq() })
165
+ ```
166
+
167
+ Options (flags accept `boolean` or a per-construct `Record<string, boolean>`):
168
+
169
+ - `convertMarkdownToOrg(md, { preserveMdisms, interpretHtml, recordStyle,
170
+ preset })` —
171
+ `preserveMdisms` default `true`; `interpretHtml` (default `false`,
172
+ CLI `--interpret-html`) interprets the HTML vocabulary morg itself
173
+ emits under `useHtml` (bare `<u>`, `<sup>`, `<sub>`, `<dl>`) as
174
+ native Org constructs — the inverse of `useHtml`: with both enabled
175
+ the round trip is lossless, with `interpretHtml` alone it converges
176
+ away from HTML (cleanup mode); other HTML preserves as usual;
177
+ `recordStyle` (default `false`, CLI `--record-style`) records the
178
+ document-level markdown style as a `#+MORG_MARKDOWN_STYLE:` keyword so the
179
+ round trip restores it (ADR 0004)
180
+ - `convertOrgToMarkdown(org, { preserveOrgisms, useHtml, taskCheckboxes,
181
+ preset })` — `preserveOrgisms` default `true`; `useHtml` (default
182
+ `false`) renders org-only markup as raw HTML (`<u>`, `<sup>`, `<sub>`,
183
+ `<dl>`) instead of keeping it verbatim; `taskCheckboxes` (default
184
+ `false`, CLI `--task-checkboxes`) is a lossy export mode that maps
185
+ bare `TODO`/`DONE` leaf headlines to GFM task items (`- [ ]` /
186
+ `- [x]`) — headings become list items and do not restore on the
187
+ return trip; anything with priority, tags or content keeps its
188
+ heading and reports via `onWarning`
189
+ - `logseq({ nestUnderHeadings })` — default `true`; content following a
190
+ heading nests as child blocks of that heading: paragraphs become child
191
+ headlines one level deeper (in Logseq org every outline block is a
192
+ headline), other constructs stay in the preceding block's body. The
193
+ reverse direction restores headings from `:heading:` properties and
194
+ turns plain block headlines back into paragraphs. Hiccup blocks
195
+ (`[:div …]`) pass through as plain text and are emitted unescaped in
196
+ Markdown. Logseq's own syntax maps both directions: `TODO`/`DONE`
197
+ text markers and `[#A]` priorities ↔ org keywords/priorities, page
198
+ references `[[page]]` and labeled forms `[label]([[page]])` ↔ org
199
+ fuzzy links `[[page][label]]`, block refs `[label](((uuid)))` ↔
200
+ `[[((uuid))][label]]`, and `^^highlight^^` markup survives verbatim
201
+ (it would otherwise re-parse as superscripts).
202
+ - `obsidian()` — wikilinks `[[Page]]` / `[[Page|alias]]` ↔ org fuzzy links
203
+
204
+ - `normalizeMarkdown(md, { preset })` / `normalizeOrg(org, { preset })`
205
+ (CLI: `morg normalize`) — one full round trip to morg's canonical
206
+ form, a fixed point. Canonicalization, not styling: org-isms and
207
+ md-isms are rewritten exactly as a conversion would rewrite them.
208
+ Normalize with the same preset/config you will convert with —
209
+ convergence is per-config (ADR 0002).
210
+
211
+ - `markdownStyle: { bullet, emphasis, strong, fence, rule, ruleRepetition }`
212
+ (on `convertOrgToMarkdown` and `normalizeMarkdown`; CLI `--bullet`,
213
+ `--emphasis`, `--strong`, `--fence`, `--rule`, `--rule-repetition`)
214
+ — Markdown output style knobs. Defaults match prettier except emphasis (`*italic*`);
215
+ `--emphasis _` aligns fully with prettier. Canonical form is
216
+ per-config (ADR 0001): round trips must use the same style. Note
217
+ CommonMark/GFM prescribe no style — these defaults are morg's
218
+ canonical choices, not a standard.
219
+
220
+ Both convert functions also accept `onWarning: message => …`, called for
221
+ each construct dropped without an equivalent (e.g. image titles, LaTeX
222
+ fragments). The CLI wires this to stderr unless `-s` / `--silent` is
223
+ given; the library is silent unless a callback is passed.
224
+
225
+ ## Architecture
226
+
227
+ Two pipelines, each with a two-phase transformation separating the
228
+ dialect-agnostic core from dialect presets:
229
+
230
+ ```text
231
+ md → org: remark-parse → mdast→uniorg (core) → preset transforms → uniorg-stringify
232
+ org → md: uniorg-parse → preset extraction → uniorg→mdast (core) → remark-stringify
233
+ ```
234
+
235
+ Formatting is controlled by shaping the AST (e.g. inserting newline text nodes),
236
+ not by custom stringifier handlers — the default, battle-tested stringifiers do
237
+ the rendering.
238
+
239
+ Project vocabulary lives in [CONTEXT.md](CONTEXT.md); design decisions in
240
+ [docs/adr/](docs/adr/).
241
+
242
+ ## Status
243
+
244
+ The core conversion surface is feature-complete and validated against
245
+ real-world Logseq org vaults (edge cases found there live on as
246
+ anonymized fixtures, e.g. `tests/fixtures/logseq-vault.org`); the
247
+ client-side [Web UI](https://morg.binarypoetry.ch) is deployed from
248
+ `main`. The npm package is `@remigius42/morg` — the bare `morg` name is
249
+ taken — and pushing a `v*` tag publishes it. Most of the code is
250
+ written with an AI coding agent under human direction, test-first and
251
+ CI-gated — see
252
+ the [contributing guide](CONTRIBUTING.md#development-process).
253
+
254
+ How each construct maps — including deliberate normalizations and
255
+ documented drops — is covered in the
256
+ [mapping reference](docs/mappings.md). Notable changes are tracked in
257
+ the [changelog](CHANGELOG.md).
258
+
259
+ ## Contributing
260
+
261
+ See the [contributing guide](CONTRIBUTING.md) for setup, conventions
262
+ and the test-first workflow; participation is governed by the
263
+ [code of conduct](CODE_OF_CONDUCT.md).
264
+
265
+ ## License
266
+
267
+ [GPL-3.0-or-later](LICENSE) (required by the uniorg dependencies).
@@ -0,0 +1,15 @@
1
+ export interface CliArgs {
2
+ normalize: boolean;
3
+ fromFormat: string | undefined;
4
+ toFormat: string | undefined;
5
+ inputFile: string | undefined;
6
+ outputFile: string | undefined;
7
+ presetName: string | undefined;
8
+ silent: boolean | undefined;
9
+ taskCheckboxes: boolean | undefined;
10
+ interpretHtml: boolean | undefined;
11
+ recordStyle: boolean | undefined;
12
+ configPath: string | undefined;
13
+ markdownStyle: Record<string, string>;
14
+ }
15
+ export declare function parseArgs(args: string[]): CliArgs;
@@ -0,0 +1,105 @@
1
+ import { CliError } from "./error.js";
2
+ export function parseArgs(args) {
3
+ // `morg normalize` canonicalizes in place of converting: same format
4
+ // in and out, one full round trip (see ADR 0001)
5
+ const normalize = args[0] === "normalize";
6
+ if (normalize) {
7
+ args.shift();
8
+ }
9
+ const parsed = {
10
+ normalize,
11
+ fromFormat: undefined,
12
+ toFormat: undefined,
13
+ inputFile: undefined,
14
+ outputFile: undefined,
15
+ presetName: undefined,
16
+ silent: undefined,
17
+ taskCheckboxes: undefined,
18
+ interpretHtml: undefined,
19
+ recordStyle: undefined,
20
+ configPath: undefined,
21
+ markdownStyle: {}
22
+ };
23
+ parseFlags(parsed, args);
24
+ return parsed;
25
+ }
26
+ const VALUE_FLAGS = new Set([
27
+ "--config",
28
+ "--bullet",
29
+ "--emphasis",
30
+ "--strong",
31
+ "--fence",
32
+ "--rule",
33
+ "--rule-repetition",
34
+ "--from",
35
+ "--to",
36
+ "--input",
37
+ "--output",
38
+ "--preset"
39
+ ]);
40
+ const BOOLEAN_FLAGS = new Map([
41
+ ["-s", "silent"],
42
+ ["--silent", "silent"],
43
+ ["--task-checkboxes", "taskCheckboxes"],
44
+ ["--interpret-html", "interpretHtml"],
45
+ ["--record-style", "recordStyle"]
46
+ ]);
47
+ // a following flag means the value was forgotten; a lone `-` is a legitimate
48
+ // bullet or rule character, so only recognized flag tokens disqualify
49
+ function takeValue(args, index) {
50
+ const flag = args[index];
51
+ const value = args[index + 1];
52
+ if (value === undefined ||
53
+ VALUE_FLAGS.has(value) ||
54
+ BOOLEAN_FLAGS.has(value)) {
55
+ throw new CliError(`${flag} requires a value`);
56
+ }
57
+ return value;
58
+ }
59
+ function parseFlags(parsed, args) {
60
+ for (let i = 0; i < args.length; i++) {
61
+ const arg = args[i] ?? "";
62
+ // boolean flags take an optional `true`/`false`; bare means true
63
+ const option = BOOLEAN_FLAGS.get(arg);
64
+ if (option) {
65
+ const value = args[i + 1];
66
+ if (value === "true" || value === "false") {
67
+ i++;
68
+ }
69
+ parsed[option] = value !== "false";
70
+ continue;
71
+ }
72
+ switch (arg) {
73
+ case "--config":
74
+ parsed.configPath = takeValue(args, i++);
75
+ break;
76
+ case "--bullet":
77
+ case "--emphasis":
78
+ case "--strong":
79
+ case "--fence":
80
+ case "--rule":
81
+ parsed.markdownStyle[arg.slice(2)] = takeValue(args, i++);
82
+ break;
83
+ case "--rule-repetition":
84
+ parsed.markdownStyle.ruleRepetition = takeValue(args, i++);
85
+ break;
86
+ case "--from":
87
+ parsed.fromFormat = takeValue(args, i++);
88
+ break;
89
+ case "--to":
90
+ parsed.toFormat = takeValue(args, i++);
91
+ break;
92
+ case "--input":
93
+ parsed.inputFile = takeValue(args, i++);
94
+ break;
95
+ case "--output":
96
+ parsed.outputFile = takeValue(args, i++);
97
+ break;
98
+ case "--preset":
99
+ parsed.presetName = takeValue(args, i++);
100
+ break;
101
+ default:
102
+ throw new CliError(`Unknown argument: ${arg}`);
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,2 @@
1
+ import { type MorgConfig } from "../config.js";
2
+ export declare function loadConfig(configPath: string | undefined): MorgConfig;
@@ -0,0 +1,18 @@
1
+ import * as fs from "node:fs";
2
+ import { parseConfig } from "../config.js";
3
+ import { CliError } from "./error.js";
4
+ // morg.toml: precedence is CLI > config > defaults
5
+ export function loadConfig(configPath) {
6
+ const resolvedConfigPath = configPath ?? (fs.existsSync("morg.toml") ? "morg.toml" : undefined);
7
+ if (!resolvedConfigPath) {
8
+ return {};
9
+ }
10
+ try {
11
+ return parseConfig(fs.readFileSync(resolvedConfigPath, "utf8"));
12
+ }
13
+ catch (error) {
14
+ throw new CliError(`Error reading ${resolvedConfigPath}:`, {
15
+ cause: error
16
+ });
17
+ }
18
+ }
@@ -0,0 +1,11 @@
1
+ import type { MorgConfig } from "../config.js";
2
+ import type { MarkdownStyleOptions } from "../options.js";
3
+ import type { Preset } from "../presets/types.js";
4
+ import type { CliArgs } from "./args.js";
5
+ export declare function buildConversionOptions(cli: CliArgs, config: MorgConfig, preset: Preset | undefined): {
6
+ mdToOrgOptions: import("../options.js").MarkdownToOrgOptions;
7
+ orgToMdOptions: import("../options.js").OrgToMarkdownOptions & {
8
+ markdownStyle: MarkdownStyleOptions;
9
+ };
10
+ };
11
+ export declare function convert(inputContent: string, fromFormat: string, normalize: boolean, cli: CliArgs, config: MorgConfig, preset: Preset | undefined): string;
@@ -0,0 +1,50 @@
1
+ import { convertMarkdownToOrg } from "../markdownToOrg.js";
2
+ import { convertOrgToMarkdown } from "../orgToMarkdown.js";
3
+ import { normalizeMarkdown, normalizeOrg } from "../normalize.js";
4
+ import { buildConversionOptions as layerOptions } from "../conversionOptions.js";
5
+ import { CliError } from "./error.js";
6
+ export function buildConversionOptions(cli, config, preset) {
7
+ // an explicit --silent wins over the config; dropped constructs are
8
+ // reported on stderr unless it ends up on
9
+ const silent = cli.silent ?? config.silent ?? false;
10
+ const onWarning = silent
11
+ ? undefined
12
+ : (message) => console.error(`morg: ${message}`);
13
+ // style flags arrive as strings; the numeric one needs converting
14
+ const markdownStyle = {
15
+ ...cli.markdownStyle,
16
+ ...(cli.markdownStyle.ruleRepetition !== undefined && {
17
+ ruleRepetition: Number(cli.markdownStyle.ruleRepetition)
18
+ })
19
+ };
20
+ return layerOptions({
21
+ taskCheckboxes: cli.taskCheckboxes,
22
+ interpretHtml: cli.interpretHtml,
23
+ recordStyle: cli.recordStyle,
24
+ markdownStyle
25
+ }, config, {
26
+ preset,
27
+ onWarning,
28
+ ...(config.orgismKeys && { orgismKeys: config.orgismKeys })
29
+ });
30
+ }
31
+ export function convert(inputContent, fromFormat, normalize, cli, config, preset) {
32
+ try {
33
+ const { mdToOrgOptions, orgToMdOptions } = buildConversionOptions(cli, config, preset);
34
+ if (normalize) {
35
+ return fromFormat === "markdown"
36
+ ? normalizeMarkdown(inputContent, {
37
+ ...mdToOrgOptions,
38
+ ...orgToMdOptions
39
+ })
40
+ : normalizeOrg(inputContent, { ...mdToOrgOptions, ...orgToMdOptions });
41
+ }
42
+ if (fromFormat === "markdown") {
43
+ return convertMarkdownToOrg(inputContent, mdToOrgOptions);
44
+ }
45
+ return convertOrgToMarkdown(inputContent, orgToMdOptions);
46
+ }
47
+ catch (error) {
48
+ throw new CliError("Conversion error:", { cause: error });
49
+ }
50
+ }
@@ -0,0 +1,2 @@
1
+ export declare class CliError extends Error {
2
+ }
@@ -0,0 +1,4 @@
1
+ // user-facing CLI failure; the executable entry maps it to stderr and
2
+ // exit code 1 — helpers throw instead of exiting so they stay testable
3
+ export class CliError extends Error {
4
+ }
@@ -0,0 +1,3 @@
1
+ import type { CliArgs } from "./args.js";
2
+ export declare function inferFormats(cli: CliArgs): [fromFormat: string | undefined, toFormat: string | undefined];
3
+ export declare function validateFormats(fromFormat: string | undefined, toFormat: string | undefined, normalize: boolean): asserts fromFormat is string;
@@ -0,0 +1,46 @@
1
+ import { CliError } from "./error.js";
2
+ import { formatFromFileName } from "../fileNames.js";
3
+ export function inferFormats(cli) {
4
+ let { fromFormat, toFormat } = cli;
5
+ // Infer formats from file extensions first
6
+ if (cli.inputFile && !fromFormat) {
7
+ fromFormat = formatFromFileName(cli.inputFile);
8
+ }
9
+ if (cli.outputFile && !toFormat) {
10
+ toFormat = formatFromFileName(cli.outputFile);
11
+ }
12
+ return inferMissingFormat(cli.normalize, fromFormat, toFormat);
13
+ }
14
+ // Infer missing format based on the other
15
+ function inferMissingFormat(normalize, fromFormat, toFormat) {
16
+ if (normalize) {
17
+ // mirror whichever side is known; a conflict between the two is left
18
+ // intact for validateFormats to reject rather than silently overwritten
19
+ fromFormat = fromFormat ?? toFormat;
20
+ toFormat = toFormat ?? fromFormat;
21
+ }
22
+ else if (fromFormat && !toFormat) {
23
+ toFormat = fromFormat === "markdown" ? "org" : "markdown";
24
+ }
25
+ else if (toFormat && !fromFormat) {
26
+ fromFormat = toFormat === "markdown" ? "org" : "markdown";
27
+ }
28
+ return [fromFormat, toFormat];
29
+ }
30
+ export function validateFormats(fromFormat, toFormat, normalize) {
31
+ if (!fromFormat || !toFormat) {
32
+ throw new CliError("Error: Could not determine conversion formats.\n" +
33
+ "Please specify --from and --to, or provide input/output files with .md or .org extensions.");
34
+ }
35
+ if (!["markdown", "org"].includes(fromFormat) ||
36
+ !["markdown", "org"].includes(toFormat)) {
37
+ throw new CliError(`Error: Unsupported format. Supported formats are 'markdown' and 'org'.`);
38
+ }
39
+ if (!normalize && fromFormat === toFormat) {
40
+ throw new CliError("Error: Source and target formats cannot be the same.");
41
+ }
42
+ if (normalize && fromFormat !== toFormat) {
43
+ throw new CliError("Error: normalize reads and writes the same format; " +
44
+ `got '${fromFormat}' and '${toFormat}'.`);
45
+ }
46
+ }
@@ -0,0 +1,2 @@
1
+ import type { Preset } from "../presets/types.js";
2
+ export declare function resolvePreset(presetName: string | undefined): Preset | undefined;
@@ -0,0 +1,10 @@
1
+ import { createPreset } from "../presets/registry.js";
2
+ import { CliError } from "./error.js";
3
+ export function resolvePreset(presetName) {
4
+ try {
5
+ return createPreset(presetName);
6
+ }
7
+ catch (error) {
8
+ throw new CliError(`Error: ${error.message}`);
9
+ }
10
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import { parseArgs } from "./cli/args.js";
4
+ import { loadConfig } from "./cli/configFile.js";
5
+ import { resolvePreset } from "./cli/presets.js";
6
+ import { inferFormats, validateFormats } from "./cli/formats.js";
7
+ import { convert } from "./cli/conversion.js";
8
+ import { CliError } from "./cli/error.js";
9
+ async function readInput(inputFile) {
10
+ if (inputFile) {
11
+ return fs.readFileSync(inputFile, "utf8");
12
+ }
13
+ // Read from stdin
14
+ return new Promise(resolve => {
15
+ const chunks = [];
16
+ process.stdin.on("data", chunk => {
17
+ chunks.push(chunk);
18
+ });
19
+ process.stdin.on("end", () => {
20
+ // decode once: a multi-byte character may straddle two chunks
21
+ resolve(Buffer.concat(chunks).toString("utf8"));
22
+ });
23
+ });
24
+ }
25
+ async function main() {
26
+ const cli = parseArgs(process.argv.slice(2));
27
+ const config = loadConfig(cli.configPath);
28
+ const preset = resolvePreset(cli.presetName ?? config.preset);
29
+ const [fromFormat, toFormat] = inferFormats(cli);
30
+ validateFormats(fromFormat, toFormat, cli.normalize);
31
+ const inputContent = await readInput(cli.inputFile);
32
+ const outputContent = convert(inputContent, fromFormat, cli.normalize, cli, config, preset);
33
+ if (cli.outputFile) {
34
+ fs.writeFileSync(cli.outputFile, outputContent, "utf8");
35
+ }
36
+ else {
37
+ console.log(outputContent);
38
+ }
39
+ }
40
+ main().catch((error) => {
41
+ if (error instanceof CliError) {
42
+ // helpers throw instead of exiting; this is the only exit point
43
+ if (error.cause !== undefined) {
44
+ console.error(error.message, error.cause);
45
+ }
46
+ else {
47
+ console.error(error.message);
48
+ }
49
+ }
50
+ else {
51
+ console.error("An unexpected error occurred:", error);
52
+ }
53
+ process.exit(1);
54
+ });
@@ -0,0 +1,20 @@
1
+ import type { MarkdownToOrgOptions, OrgToMarkdownOptions } from "./options.js";
2
+ /**
3
+ * Shape of `morg.toml`. Sections mirror the library options objects;
4
+ * `preset` and `silent` mirror their CLI flags; `orgismKeys` is shared
5
+ * by both directions. Precedence: CLI > config > defaults.
6
+ */
7
+ export interface MorgConfig {
8
+ preset?: string;
9
+ silent?: boolean;
10
+ orgismKeys?: Record<string, string>;
11
+ markdownToOrg?: Omit<MarkdownToOrgOptions, "preset" | "onWarning">;
12
+ orgToMarkdown?: Omit<OrgToMarkdownOptions, "preset" | "onWarning" | "orgismKeys">;
13
+ }
14
+ /**
15
+ * Parses a `morg.toml` source string. Unknown top-level keys are
16
+ * rejected so typos fail loudly instead of being silently ignored.
17
+ * @param source The TOML source text.
18
+ * @returns The parsed configuration.
19
+ */
20
+ export declare function parseConfig(source: string): MorgConfig;
package/dist/config.js ADDED
@@ -0,0 +1,22 @@
1
+ import { parse as parseToml } from "smol-toml";
2
+ const KNOWN_KEYS = new Set([
3
+ "preset",
4
+ "silent",
5
+ "orgismKeys",
6
+ "markdownToOrg",
7
+ "orgToMarkdown"
8
+ ]);
9
+ /**
10
+ * Parses a `morg.toml` source string. Unknown top-level keys are
11
+ * rejected so typos fail loudly instead of being silently ignored.
12
+ * @param source The TOML source text.
13
+ * @returns The parsed configuration.
14
+ */
15
+ export function parseConfig(source) {
16
+ const parsed = parseToml(source);
17
+ const unknown = Object.keys(parsed).filter(key => !KNOWN_KEYS.has(key));
18
+ if (unknown.length) {
19
+ throw new Error(`Unknown config key(s): ${unknown.join(", ")}`);
20
+ }
21
+ return parsed;
22
+ }