@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.
- package/LICENSE +674 -0
- package/README.md +267 -0
- package/dist/cli/args.d.ts +15 -0
- package/dist/cli/args.js +105 -0
- package/dist/cli/configFile.d.ts +2 -0
- package/dist/cli/configFile.js +18 -0
- package/dist/cli/conversion.d.ts +11 -0
- package/dist/cli/conversion.js +50 -0
- package/dist/cli/error.d.ts +2 -0
- package/dist/cli/error.js +4 -0
- package/dist/cli/formats.d.ts +3 -0
- package/dist/cli/formats.js +46 -0
- package/dist/cli/presets.d.ts +2 -0
- package/dist/cli/presets.js +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +54 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +22 -0
- package/dist/conversionOptions.d.ts +32 -0
- package/dist/conversionOptions.js +34 -0
- package/dist/core/markdownStyle.d.ts +27 -0
- package/dist/core/markdownStyle.js +108 -0
- package/dist/core/mdastToUniorg/blocks.d.ts +20 -0
- package/dist/core/mdastToUniorg/blocks.js +211 -0
- package/dist/core/mdastToUniorg/context.d.ts +15 -0
- package/dist/core/mdastToUniorg/context.js +7 -0
- package/dist/core/mdastToUniorg/index.d.ts +13 -0
- package/dist/core/mdastToUniorg/index.js +95 -0
- package/dist/core/mdastToUniorg/lists.d.ts +4 -0
- package/dist/core/mdastToUniorg/lists.js +45 -0
- package/dist/core/mdastToUniorg/phrasing.d.ts +4 -0
- package/dist/core/mdastToUniorg/phrasing.js +176 -0
- package/dist/core/uniorgToMdast/elements.d.ts +4 -0
- package/dist/core/uniorgToMdast/elements.js +278 -0
- package/dist/core/uniorgToMdast/footnotes.d.ts +10 -0
- package/dist/core/uniorgToMdast/footnotes.js +54 -0
- package/dist/core/uniorgToMdast/index.d.ts +11 -0
- package/dist/core/uniorgToMdast/index.js +84 -0
- package/dist/core/uniorgToMdast/lists.d.ts +4 -0
- package/dist/core/uniorgToMdast/lists.js +94 -0
- package/dist/core/uniorgToMdast/objects.d.ts +4 -0
- package/dist/core/uniorgToMdast/objects.js +152 -0
- package/dist/core/uniorgToMdast/shared.d.ts +25 -0
- package/dist/core/uniorgToMdast/shared.js +55 -0
- package/dist/core/uniorgToMdast/tables.d.ts +6 -0
- package/dist/core/uniorgToMdast/tables.js +66 -0
- package/dist/fileNames.d.ts +16 -0
- package/dist/fileNames.js +34 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/markdownToOrg.d.ts +8 -0
- package/dist/markdownToOrg.js +194 -0
- package/dist/normalize.d.ts +26 -0
- package/dist/normalize.js +24 -0
- package/dist/options.d.ts +102 -0
- package/dist/options.js +9 -0
- package/dist/orgToMarkdown.d.ts +8 -0
- package/dist/orgToMarkdown.js +61 -0
- package/dist/presets/logseq.d.ts +25 -0
- package/dist/presets/logseq.js +298 -0
- package/dist/presets/obsidian.d.ts +6 -0
- package/dist/presets/obsidian.js +40 -0
- package/dist/presets/registry.d.ts +8 -0
- package/dist/presets/registry.js +25 -0
- package/dist/presets/types.d.ts +12 -0
- package/dist/presets/types.js +1 -0
- 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
|
+
[](https://github.com/remigius42/morg/blob/main/CHANGELOG.md)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/remigius42/morg/actions/workflows/ci.yml)
|
|
8
|
+

|
|
9
|
+
[](https://app.codacy.com/gh/remigius42/morg/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
|
10
|
+
[](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;
|
package/dist/cli/args.js
ADDED
|
@@ -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,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,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,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
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
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|