Repository navigation
Expand file tree
/
Copy pathmarkdown.ts
More file actions
259 lines (216 loc) · 8.54 KB
/
Copy pathmarkdown.ts
File metadata and controls
259 lines (216 loc) · 8.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
// Prism is a browser syntax highlighter; here it tokenises fenced code blocks
// into <span class="token ..."> markup that the theme below colours. Each
// language pack registers its grammar on the global the core creates, so the
// core loads first; CSS and JavaScript ship in it, so only the rest need a
// line here.
import "prismjs";
import "prismjs/components/prism-typescript.js";
import "prismjs/components/prism-json.js";
import "prismjs/components/prism-bash.js";
import "prismjs/components/prism-python.js";
import {readFileSync} from "node:fs";
import {TermDOM} from "@b9g/termdom";
import {marked} from "marked";
import {markedHighlight} from "marked-highlight";
import Prism from "prismjs";
// marked and marked-highlight are standard Node/browser libraries used here
// completely unmodified -- the whole point: a real web toolchain feeding a real
// DOM. marked-highlight routes each code block through Prism.
marked.use(
markedHighlight({
highlight(code: string, lang: string): string {
const grammar = Prism.languages[lang];
if (grammar) {
return Prism.highlight(code, grammar, lang);
}
return code
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
},
}),
);
// The sample document, inline: the example is self-contained -- one file to
// read, one to run, no sidecar to locate at runtime.
const SAMPLE = `# Markdown in the Terminal
A single document that exercises the whole element set, rendered from real
Markdown through the **marked** library into a real DOM.
## Inline formatting
Text can be **bold**, *italic*, ***both***, \`inline code\`, ~~struck through~~,
and a [labelled link](https://example.com). Links are underlined by the user
agent; Tab through the document to see a focus ring land on each one.
### Third-level heading
#### Fourth-level heading
##### Fifth-level heading
###### Sixth-level heading
## Lists
An unordered list, with nesting:
- First item
- Second item
- A nested item
- Another, with \`code\`
- Third item
An ordered list:
1. Parse the Markdown
2. Build the DOM
3. Render to cells
A task list (GitHub-flavoured, rendered as real checkboxes):
- [x] Write the parser adapter
- [x] Style the headings
- [x] Add a fixed status line
## Blockquote
> The best way to predict the future is to invent it.
>
> Simple things should be simple, complex things should be possible.
## Code block
\`\`\`js
function greet(name) {
return \`Hello, \${name}!\`;
}
console.log(greet("world"));
\`\`\`
Syntax highlighting comes from Prism, another unmodified web library, across
languages:
\`\`\`json
{
"name": "termdom",
"renders": ["markdown", "code"],
"highlighted": true
}
\`\`\`
## Table
Element | Display | Notes |
----------- | ---------- | --------------------------- |
\`h1\`–\`h6\` | block | themed bold, coloured |
\`blockquote\`| block | left rule via \`border-left\` |
\`pre\` | block | dark background, monospace |
\`table\` | table | ruled by the UA stylesheet |
## Horizontal rule
Above the rule.
---
Below the rule.
## Wrapping
This final paragraph is deliberately long so that it wraps across several
terminal columns, demonstrating that inline text reflows to the viewport width
just as it would in a browser, breaking on word boundaries rather than spilling
off the right edge of the screen.
`;
const file = process.argv[2];
const source = file ? readFileSync(file, "utf8") : SAMPLE;
const html = marked.parse(source, {async: false}) as string;
const term = new TermDOM();
term.attach();
const {document, window} = term;
// A compact terminal theme. The UA sheet already makes links underlined and
// tables ruled; this adds the heading hierarchy, code and blockquote styling a
// Markdown reader wants. Everything here is ordinary author CSS.
const style = document.createElement("style");
style.textContent = `
article { padding: 0 1ch; }
h1, h2, h3, h4, h5, h6 { font-weight: bold; margin-top: 1px; }
h1 { color: #ff8700; text-decoration: underline; }
h2 { color: #ffaf00; }
h3 { color: #ffd700; }
h4, h5, h6 { color: #afaf00; }
p { margin-top: 1px; }
a { color: #5fafff; }
code { color: #ff5f5f; }
pre { background-color: #1c1c1c; color: #d0d0d0; padding: 0 1ch; margin-top: 1px; }
pre code { color: inherit; background-color: transparent; }
li:has(> input[type="checkbox"]) { list-style: none; }
/* Prism token theme (Monokai-ish), scoped to highlighted code. */
.token.comment, .token.prolog, .token.doctype, .token.cdata { color: #75715e; }
.token.punctuation { color: #808080; }
.token.property, .token.tag, .token.constant, .token.symbol { color: #f92672; }
.token.boolean, .token.number { color: #ae81ff; }
.token.selector, .token.attr-name, .token.string, .token.char, .token.builtin { color: #a6e22e; }
.token.operator, .token.entity, .token.url { color: #d0d0d0; }
.token.atrule, .token.attr-value, .token.keyword { color: #66d9ef; }
.token.function, .token.class-name { color: #e6db74; }
.token.regex, .token.important, .token.variable { color: #fd971f; }
blockquote { border-left: 1px solid #5f5f5f; padding-left: 1ch; color: #8a8a8a; margin-top: 1px; }
ul, ol { margin-top: 1px; }
li > ul, li > ol { margin-top: 0; }
hr { color: #444444; margin-top: 1px; }
th { font-weight: bold; color: #ffd700; }
del { text-decoration: line-through; }
/* The pager's status line: position: fixed pins it to the VIEWPORT, so
the camera scrolls the document underneath it -- the same contract as
a web page's sticky footer. The body pads for it, as a web page pads
for its own fixed footer, so the last line can scroll clear of it. */
body { padding-bottom: 1px; }
.status {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: #262626;
color: #9e9e9e;
padding: 0 1ch;
}
.status .pct { position: absolute; right: 1ch; top: 0; color: #ffd700; }
`;
document.head.appendChild(style);
const article = document.createElement("article");
article.innerHTML = html;
document.body.appendChild(article);
// Wait for the first frame to paint before measuring. The flow-or-pager
// decision below compares the document's height to the window's, and a
// transport that settles its size after attach (the browser playground's
// does) reports the real geometry only after the first frame. A frame
// callback runs before its frame paints, so the second one is after.
await new Promise<void>((r) =>
window.requestAnimationFrame(() => window.requestAnimationFrame(() => r())),
);
// Flow mode: a page that fits (or output that isn't a terminal, e.g. piped to a
// file) just prints and exits -- dispose pays the whole document out to the
// terminal's scrollback on the way.
const fits = document.body.scrollHeight <= window.innerHeight;
if (!process.stdout.isTTY || fits) {
term.window.close();
}
// Pager mode: the document is taller than the screen, so move the camera over
// it. window.scrollBy IS the camera (clamped to the document), so this is the
// same scrolling any web page gets -- no terminal-specific plumbing.
function page(): number {
return Math.max(1, window.innerHeight - 1);
}
function height(): number {
return document.body.scrollHeight;
}
// The status line the sample's own task list asked for.
const status = document.createElement("div");
status.className = "status";
const statusName = document.createElement("span");
statusName.textContent = `${file?.split("/").pop() ?? "sample"} · j/k scroll · q quit`;
const pct = document.createElement("span");
pct.className = "pct";
status.append(statusName, pct);
document.body.appendChild(status);
function updateStatus(): void {
const max = Math.max(1, height() - window.innerHeight);
pct.textContent = `${Math.min(100, Math.round((window.scrollY / max) * 100))}%`;
}
updateStatus();
const bindings: Record<string, () => void> = {
q: () => {
term.window.close();
},
" ": () => window.scrollBy(0, page()),
f: () => window.scrollBy(0, page()),
PageDown: () => window.scrollBy(0, page()),
b: () => window.scrollBy(0, -page()),
PageUp: () => window.scrollBy(0, -page()),
j: () => window.scrollBy(0, 1),
ArrowDown: () => window.scrollBy(0, 1),
k: () => window.scrollBy(0, -1),
ArrowUp: () => window.scrollBy(0, -1),
g: () => window.scrollBy(0, -height()),
G: () => window.scrollBy(0, height()),
};
document.addEventListener("keydown", (event: Event) => {
bindings[(event as KeyboardEvent).key]?.();
});
// The wheel and the keys above both move the document; the scroll event
// is where every move ends up.
document.addEventListener("scroll", updateStatus);