orz-mdhtml 0.1.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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Builds the self-contained .md.html shell.
3
+ *
4
+ * The file is a *document first*: by default it shows the rendered Markdown in
5
+ * an <iframe> (full theme isolation, true WYSIWYG) with a single small edit
6
+ * affordance. Clicking it reveals a minimal editor (CodeMirror) beside a live,
7
+ * incrementally-updated preview. The Markdown source is the single source of
8
+ * truth, embedded in a <script type="text/markdown">; Save re-serializes the
9
+ * outer document. Editor libraries are lazy-loaded on first edit so reading
10
+ * stays lightweight.
11
+ */
12
+ /** Guard inline <script> content against premature `</script>` termination. */
13
+ function escapeForScript(s) {
14
+ return s.replace(/<\/(script)/gi, '<\\/$1');
15
+ }
16
+ function escapeHtml(s) {
17
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
18
+ }
19
+ export function buildHtml(opts) {
20
+ const rendererTag = opts.renderer.mode === 'inline'
21
+ ? `<script>${escapeForScript(opts.renderer.js)}</script>`
22
+ : `<script src="${opts.renderer.src}"></script>`;
23
+ const defaultScheme = opts.themes.find((t) => t.id === opts.defaultTheme)?.scheme ?? 'light';
24
+ // Everything the app needs to (re)build the iframe + manage themes.
25
+ const config = {
26
+ filename: opts.filename,
27
+ docId: opts.docId,
28
+ rendererVersion: opts.rendererVersion,
29
+ versionManifest: opts.versionManifest,
30
+ defaultTheme: opts.defaultTheme,
31
+ themes: opts.themes,
32
+ frame: opts.frame,
33
+ editorLibs: opts.editorLibs,
34
+ runtime: opts.runtimeScript,
35
+ };
36
+ const themeOptions = opts.themes
37
+ .map((t) => `<option value="${t.id}"${t.id === opts.defaultTheme ? ' selected' : ''}>${escapeHtml(t.name)}</option>`)
38
+ .join('');
39
+ return `<!DOCTYPE html>
40
+ <html lang="en" data-mode="read" data-chrome="${defaultScheme}">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <title>${escapeHtml(opts.title)}</title>
45
+ <meta name="generator" content="orz-mdhtml">
46
+ <style>
47
+ :root {
48
+ --chrome-h: 46px;
49
+ --fab-size: 40px;
50
+ }
51
+ [data-chrome="light"] {
52
+ --c-bg: #ffffff; --c-fg: #1f2328; --c-muted: #6b7280;
53
+ --c-border: #e6e8eb; --c-hover: #f2f4f6; --c-active: #e8eef7;
54
+ --c-accent: #2f6feb; --c-shadow: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.10);
55
+ --c-fab-bg: rgba(255,255,255,.92); --c-fab-fg: #374151;
56
+ }
57
+ [data-chrome="dark"] {
58
+ --c-bg: #1b1d21; --c-fg: #e6e8eb; --c-muted: #9aa1ab;
59
+ --c-border: #2c3036; --c-hover: #24272c; --c-active: #2b3340;
60
+ --c-accent: #5b8cff; --c-shadow: 0 1px 2px rgba(0,0,0,.4), 0 2px 8px rgba(0,0,0,.35);
61
+ --c-fab-bg: rgba(33,36,41,.92); --c-fab-fg: #cbd2da;
62
+ }
63
+ * { box-sizing: border-box; }
64
+ html, body { margin: 0; height: 100%; }
65
+ body {
66
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
67
+ background: var(--c-bg); color: var(--c-fg);
68
+ height: 100vh; overflow: hidden;
69
+ display: flex; flex-direction: column;
70
+ }
71
+
72
+ /* ---- toolbar (hidden while reading) ---- */
73
+ #orz-bar {
74
+ height: var(--chrome-h); flex: 0 0 var(--chrome-h);
75
+ display: none; align-items: center; gap: 8px;
76
+ padding: 0 10px; background: var(--c-bg);
77
+ border-bottom: 1px solid var(--c-border);
78
+ -webkit-user-select: none; user-select: none;
79
+ }
80
+ html:not([data-mode="read"]) #orz-bar { display: flex; }
81
+ .bar-spring { flex: 1; }
82
+ .icon-btn {
83
+ display: inline-flex; align-items: center; justify-content: center;
84
+ width: 30px; height: 30px; border: 0; border-radius: 7px;
85
+ background: transparent; color: var(--c-fg); cursor: pointer;
86
+ transition: background .12s, color .12s;
87
+ }
88
+ .icon-btn:hover { background: var(--c-hover); }
89
+ .icon-btn svg { width: 17px; height: 17px; display: block; }
90
+ .icon-btn[aria-pressed="true"] { color: var(--c-accent); background: var(--c-active); }
91
+ .icon-btn[aria-pressed="false"] { color: var(--c-muted); }
92
+ .text-btn {
93
+ display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px;
94
+ border: 1px solid var(--c-border); border-radius: 8px;
95
+ background: var(--c-bg); color: var(--c-fg); cursor: pointer; font: inherit; font-weight: 500;
96
+ }
97
+ .text-btn:hover { background: var(--c-hover); }
98
+ .text-btn.primary { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
99
+ .text-btn.primary:hover { filter: brightness(1.06); }
100
+ .bar-title {
101
+ font-weight: 600; color: var(--c-fg); max-width: 38vw;
102
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
103
+ }
104
+ .dirty-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c-accent);
105
+ margin-left: 2px; opacity: 0; transition: opacity .15s; }
106
+ html[data-dirty="1"] .dirty-dot { opacity: 1; }
107
+ select.theme-pick {
108
+ height: 30px; padding: 0 26px 0 10px; border: 1px solid var(--c-border); border-radius: 8px;
109
+ background: var(--c-bg) no-repeat right 8px center; color: var(--c-fg); font: inherit; cursor: pointer;
110
+ -webkit-appearance: none; appearance: none;
111
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
112
+ }
113
+
114
+ /* ---- stage: editor pane + preview iframe ---- */
115
+ #orz-stage { flex: 1; display: flex; min-height: 0; position: relative; }
116
+ #orz-editor { display: none; min-width: 0; height: 100%; background: var(--c-bg); }
117
+ #orz-frame { flex: 1; min-width: 0; height: 100%; width: 100%; border: 0; background: #fff; display: block; }
118
+
119
+ html[data-mode="read"] #orz-editor { display: none; }
120
+ html[data-mode="read"] #orz-frame { flex: 1; }
121
+ html[data-mode="split"] #orz-editor { display: block; }
122
+ html[data-mode="split"] #orz-frame { border-left: 1px solid var(--c-border); }
123
+
124
+ /* CodeMirror fills the editor pane */
125
+ #orz-editor .CodeMirror { height: 100%; font-family: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 13.5px; line-height: 1.6; }
126
+ #orz-textarea { width: 100%; height: 100%; box-sizing: border-box; border: 0; padding: 16px;
127
+ resize: none; outline: none; font: 13.5px/1.6 ui-monospace, Menlo, Consolas, monospace;
128
+ background: var(--c-bg); color: var(--c-fg); }
129
+
130
+ /* Split.js gutter */
131
+ .gutter { background: var(--c-border); position: relative; }
132
+ .gutter.gutter-horizontal { cursor: col-resize; width: 8px; }
133
+ .gutter.gutter-horizontal::after { content: ""; position: absolute; inset: 0 3px;
134
+ border-radius: 3px; background: transparent; transition: background .12s; }
135
+ .gutter.gutter-horizontal:hover::after { background: var(--c-accent); }
136
+
137
+ /* ---- floating reader tools (reading mode only) ---- */
138
+ #orz-reader-tools {
139
+ position: fixed; right: 18px; bottom: 18px; z-index: 30;
140
+ display: none; align-items: center; gap: 2px; padding: 4px;
141
+ border-radius: 24px; border: 1px solid var(--c-border);
142
+ background: var(--c-fab-bg); color: var(--c-fab-fg);
143
+ box-shadow: var(--c-shadow); backdrop-filter: blur(8px);
144
+ opacity: .6; transition: opacity .2s;
145
+ }
146
+ #orz-reader-tools:hover { opacity: 1; }
147
+ html[data-mode="read"] #orz-reader-tools { display: inline-flex; }
148
+ .rtool {
149
+ width: 34px; height: 34px; border: 0; border-radius: 50%;
150
+ background: transparent; color: inherit; cursor: pointer;
151
+ display: inline-flex; align-items: center; justify-content: center;
152
+ line-height: 1; transition: background .12s, transform .12s;
153
+ }
154
+ .rtool:hover { background: var(--c-hover); }
155
+ .rtool svg { width: 18px; height: 18px; }
156
+ .rtool.font { font-weight: 700; font-family: Georgia, "Times New Roman", serif; }
157
+ .rtool.edit { position: relative; background: var(--c-accent); color: #fff; }
158
+ .rtool.edit:hover { filter: brightness(1.06); transform: translateY(-1px); }
159
+ .rtool.edit::after {
160
+ content: ""; position: absolute; top: 0; right: 0; width: 11px; height: 11px;
161
+ border-radius: 50%; background: #e5534b; border: 2px solid var(--c-fab-bg);
162
+ opacity: 0; transition: opacity .15s;
163
+ }
164
+ html[data-dirty="1"] #orz-reader-tools { opacity: 1; }
165
+ html[data-dirty="1"] .rtool.edit::after { opacity: 1; }
166
+
167
+ /* toast */
168
+ #orz-toast {
169
+ position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%) translateY(8px);
170
+ background: #111418; color: #fff; padding: 8px 14px; border-radius: 9px; font-size: 13px;
171
+ opacity: 0; pointer-events: none; transition: opacity .18s, transform .18s; z-index: 50;
172
+ }
173
+ #orz-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
174
+
175
+ /* update banner */
176
+ #orz-update {
177
+ position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 40;
178
+ display: none; align-items: center; gap: 12px;
179
+ padding: 8px 12px; border-radius: 10px; font-size: 13px;
180
+ background: var(--c-bg); color: var(--c-fg); border: 1px solid var(--c-border); box-shadow: var(--c-shadow);
181
+ }
182
+ #orz-update.show { display: flex; }
183
+
184
+ /* published-page (read-only) save notice */
185
+ #orz-served-note {
186
+ position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 41;
187
+ display: none; align-items: center; gap: 12px; max-width: min(92vw, 560px);
188
+ padding: 10px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4;
189
+ background: var(--c-bg); color: var(--c-fg); border: 1px solid var(--c-border); box-shadow: var(--c-shadow);
190
+ }
191
+ #orz-served-note.show { display: flex; }
192
+ #orz-served-note .note-text { flex: 1; }
193
+ </style>
194
+ </head>
195
+ <body>
196
+
197
+ <div id="orz-bar" role="toolbar" aria-label="Editor toolbar">
198
+ <button class="icon-btn" id="orz-done" title="Done — back to reading" aria-label="Done">
199
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M5 12l6-6M5 12l6 6"/></svg>
200
+ </button>
201
+ <span class="bar-title" id="orz-title">${escapeHtml(opts.title)}</span>
202
+ <span class="dirty-dot" title="Unsaved changes"></span>
203
+ <span class="bar-spring"></span>
204
+
205
+ <button class="icon-btn" id="orz-sync" title="Sync scrolling" aria-label="Sync scrolling" aria-pressed="true">
206
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
207
+ </button>
208
+
209
+ <select class="theme-pick" id="orz-theme" title="Theme" aria-label="Theme">${themeOptions}</select>
210
+
211
+ <button class="icon-btn" id="orz-export-bar" title="Download a copy" aria-label="Download a copy">
212
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
213
+ </button>
214
+
215
+ <button class="text-btn primary" id="orz-save" title="Save (Cmd/Ctrl+S)">
216
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
217
+ Save
218
+ </button>
219
+ </div>
220
+
221
+ <div id="orz-stage">
222
+ <div id="orz-editor"><textarea id="orz-textarea" spellcheck="false"></textarea></div>
223
+ <iframe id="orz-frame" title="Preview"></iframe>
224
+ </div>
225
+
226
+ <div id="orz-reader-tools">
227
+ <button class="rtool font" id="orz-font-dec" title="Smaller text" aria-label="Decrease text size" style="font-size:13px">A</button>
228
+ <button class="rtool font" id="orz-font-inc" title="Larger text" aria-label="Increase text size" style="font-size:18px">A</button>
229
+ <button class="rtool" id="orz-export" title="Download a copy" aria-label="Download a copy">
230
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
231
+ </button>
232
+ <button class="rtool edit" id="orz-fab" title="Edit this document" aria-label="Edit this document">
233
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
234
+ </button>
235
+ </div>
236
+
237
+ <div id="orz-update">
238
+ <span class="upd-text"></span>
239
+ <button class="text-btn" id="orz-upd-dismiss">Dismiss</button>
240
+ </div>
241
+
242
+ <div id="orz-served-note">
243
+ <span class="note-text">This is a published page — your edits can't be saved back to the server. You can download a local copy to edit and save.</span>
244
+ <button class="text-btn primary" id="orz-served-download">Download copy</button>
245
+ <button class="text-btn" id="orz-served-dismiss">Dismiss</button>
246
+ </div>
247
+ <div id="orz-toast"></div>
248
+
249
+ <!-- Embedded markdown source (single source of truth) -->
250
+ <script type="text/markdown" id="orz-src">
251
+ ${escapeForScript(opts.source)}
252
+ </script>
253
+
254
+ <!-- orz-markdown renderer (parent) -->
255
+ ${rendererTag}
256
+
257
+ <!-- in-file app config + runtime -->
258
+ <script>window.__ORZ_MDHTML__ = ${escapeForScript(JSON.stringify(config))};</script>
259
+ <script>${escapeForScript(opts.appJs)}</script>
260
+ </body>
261
+ </html>
262
+ `;
263
+ }
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: orz-mdhtml
3
+ description: Create and use self-contained, editable .md.html documents from Markdown (orz-mdhtml). Use when a user wants a single shareable HTML file that reads like a webpage but can be edited and saved in the browser — e.g. teaching handouts, notes, or published articles.
4
+ ---
5
+
6
+ # orz-mdhtml — self-contained editable `.md.html`
7
+
8
+ `orz-mdhtml` turns a Markdown file into **one `.md.html` file** that:
9
+
10
+ - reads like a normal themed webpage (rendered in an isolated `<iframe>`),
11
+ - can be **edited in the browser** (CodeMirror source + live incremental preview),
12
+ - lets readers **switch themes**, **resize text**, **export a local copy**, and
13
+ **copy rendered content as Markdown source**,
14
+ - **saves itself**: in place on local files (Chromium File System Access API),
15
+ or as a download elsewhere.
16
+
17
+ The Markdown source is embedded in the file as the single source of truth
18
+ (`<script type="text/markdown" id="orz-src">`). Saving re-serializes the whole
19
+ document, so the file reproduces itself.
20
+
21
+ ## When to use it
22
+
23
+ - A user wants a **single shareable file** (email/USB/served page) that is both
24
+ readable and editable without any app.
25
+ - **Teaching**: a teacher serves a handout as a web page; students download
26
+ their own copy to annotate.
27
+ - Prefer plain `.md` when the target is a repo/pipeline; prefer orz-markdown's
28
+ standalone HTML when no editing is needed; use `.md.html` when in-browser
29
+ editing or reader annotation matters.
30
+
31
+ ## Generate a file
32
+
33
+ Node 18+. From the package (CLI: `orz-mdhtml`, or `npm run gen --` in a clone):
34
+
35
+ ```bash
36
+ orz-mdhtml <input.md> [options]
37
+ -o, --out <file> output path (default: <input>.md.html)
38
+ --theme <name> default theme id (default: light-academic-1)
39
+ --inline embed the renderer bundle (default; recommended)
40
+ --cdn reference the renderer from jsDelivr (needs orz-mdhtml-browser published)
41
+ --title <text> document <title>
42
+ ```
43
+
44
+ Always prefer `--inline` unless `orz-mdhtml-browser` is published to npm.
45
+
46
+ Theme ids: `light-academic-1/2`, `light-neat-1/2`, `light-playful-1/2`,
47
+ `beige-decent-1/2`, `dark-elegant-1/2`.
48
+
49
+ ## Authoring the Markdown
50
+
51
+ The source is **orz-markdown** Markdown — standard Markdown plus KaTeX math,
52
+ `{{name body}}` plugins (mermaid, smiles, qrcode, youtube, toc, …), `::: name`
53
+ containers, and `{{attrs[#id .class]}}`. For the full syntax, read the
54
+ orz-markdown skill, shipped at:
55
+
56
+ ```
57
+ node_modules/orz-markdown/orz-markdown-skills/SKILL.md
58
+ ```
59
+
60
+ You write only the `.md`; do not hand-write the `.md.html`. To change content,
61
+ edit the `.md` and regenerate (or edit in the browser and Save).
62
+
63
+ ## What the generated file needs at view time
64
+
65
+ "Self-contained" means *one file*, **not** *offline*. The renderer is embedded
66
+ (with `--inline`), but these load from CDN when the file is opened, so **viewing
67
+ requires internet**: theme CSS (jsDelivr `orz-markdown/themes/...`), KaTeX CSS,
68
+ highlight.js, Mermaid, and — only on first edit — CodeMirror, Split.js, morphdom.
69
+
70
+ ## Deploying / sharing
71
+
72
+ - **Distribute the file**: send/host the `.md.html`; readers open it in a browser.
73
+ - **Serve it**: any static host works. When served over http(s), readers can't
74
+ save back to the server — the file detects this and tells them to **Download a
75
+ local copy** to edit and save. In-place Save only applies to `file://` copies
76
+ they own.
77
+ - **Editing/Save** needs a Chromium browser (Chrome/Edge); reading, themes,
78
+ font size, export, and copy-as-markdown work in all modern browsers.
79
+
80
+ ## Gotchas for agents
81
+
82
+ - Edit the `.md` and regenerate; never restructure the generated HTML by hand —
83
+ the in-file runtime expects specific element ids (`#orz-src`, `#orz-frame`,
84
+ `#orz-doc`, etc.).
85
+ - If you post-process the rendered HTML, **preserve `data-md` and
86
+ `data-src-line` attributes** — they power copy-as-markdown and scroll-sync.
87
+ - The embedded source guards `</script>` as `<\/script>`; read it back via the
88
+ `#orz-src` element's `textContent` and reverse that escape.
89
+ - Requires `orz-markdown` ≥ 1.2.0 (copy-as-markdown); ≥ 1.2.1 for the
90
+ whole-table/blockquote copy fix.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "orz-mdhtml",
3
+ "version": "0.1.0",
4
+ "description": "Generate self-contained, editable .md.html files from Markdown using orz-markdown — preview + edit modes, source-aware copy, in-place save.",
5
+ "type": "module",
6
+ "bin": {
7
+ "orz-mdhtml": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "assets",
12
+ "orz-mdhtml-skills"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc && npm run bundle",
16
+ "bundle": "tsx build/bundle.ts",
17
+ "gen": "tsx src/cli.ts",
18
+ "clean": "rm -rf dist",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "markdown",
23
+ "orz-markdown",
24
+ "self-contained",
25
+ "html",
26
+ "editor",
27
+ "single-file"
28
+ ],
29
+ "author": "Yu Wang <yuwang@orz.how>",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "orz-markdown": "^1.2.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.11.0",
36
+ "esbuild": "^0.21.5",
37
+ "path-browserify": "^1.0.1",
38
+ "tsx": "^4.7.0",
39
+ "typescript": "^5.4.0"
40
+ }
41
+ }