-
Notifications
You must be signed in to change notification settings - Fork 242
/
literate.ts
235 lines (215 loc) · 6.02 KB
/
literate.ts
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
/**
* A tiny module to include annotated (working!) code snippets into the documentation
*
* Not using 'literate-programming' or 'erasumus' projects because they work
* the other way around: take code from MarkDown, save it as a file, then
* execute that.
*
* We do the opposite: start from source code annotated with MarkDown and
* extract it into (larger) MarkDown files.
*
* Including into README
* ---------------------
*
* To include the examples directly into the README, make a link to the
* annotated TypeScript file on a line by itself, and make sure the
* extension of the file ends in `.lit.ts`.
*
* For example:
*
* [example](test/integ.bucket.lit.ts)
*
* Annotating source
* -----------------
*
* We use the triple-slash comment for our directives, since it's valid TypeScript
* and are treated as regular comments if not the very first thing in the file.
*
* By default, the whole file is included, unless the source contains the statement
* "/// !show". For example:
*
* a
* /// !show
* b
* /// !hide
* c
*
* In this example, only 'b' would be included in the output. A single file may
* switching including and excluding on and off multiple times in the same file.
*
* Other lines starting with triple slashes will be rendered as Markdown in between
* the source lines. For example:
*
* const x = 1;
* /// Now we're going to print x:
* console.log(x);
*
* Will be rendered as:
*
* ```ts
* const x = 1;
* ```
*
* Now we're going to print x:
*
* ```ts
* console.log(x);
* ```
*/
import * as fs from 'fs-extra';
import * as path from 'path';
/**
* Convert an annotated TypeScript source file to MarkDown
*/
export function typescriptSourceToMarkdown(
lines: string[],
codeBlockAnnotations: string[],
): string[] {
const relevantLines = findRelevantLines(lines);
const markdownLines = markdownify(relevantLines, codeBlockAnnotations);
return markdownLines;
}
export interface LoadedFile {
readonly fullPath: string;
readonly lines: string[];
}
export type FileLoader = (
relativePath: string,
) => LoadedFile | Promise<LoadedFile>;
/**
* Given MarkDown source, find source files to include and render
*
* We recognize links on a line by themselves if the link text starts
* with the string "example" (case insensitive). For example:
*
* [example](test/integ.bucket.ts)
*/
export async function includeAndRenderExamples(
lines: string[],
loader: FileLoader,
projectRoot: string,
): Promise<string[]> {
const ret: string[] = [];
const regex = /^\[([^\]]*)\]\(([^)]+\.lit\.ts)\)/i;
for (const line of lines) {
const m = regex.exec(line);
if (m) {
// Found an include
const filename = m[2];
// eslint-disable-next-line no-await-in-loop
const { lines: source, fullPath } = await loader(filename);
// 'lit' source attribute will make snippet compiler know to extract the same source
// Needs to be relative to the project root.
const imported = typescriptSourceToMarkdown(source, [
`lit=${toUnixPath(path.relative(projectRoot, fullPath))}`,
]);
ret.push(...imported);
} else {
ret.push(line);
}
}
return ret;
}
/**
* Load a file into a string array
*/
export async function loadFromFile(fileName: string): Promise<string[]> {
const content = await fs.readFile(fileName, { encoding: 'utf-8' });
return contentToLines(content);
}
/**
* Turn file content string into an array of lines ready for processing using the other functions
*/
export function contentToLines(content: string): string[] {
return content.split('\n').map((x) => x.trimRight());
}
/**
* Return a file system loader given a base directory
*/
export function fileSystemLoader(directory: string): FileLoader {
return async (fileName) => {
const fullPath = path.resolve(directory, fileName);
return { fullPath, lines: await loadFromFile(fullPath) };
};
}
const RELEVANT_TAG = '/// !show';
const DETAIL_TAG = '/// !hide';
const INLINE_MD_REGEX = /^\s*\/\/\/ (.*)$/;
/**
* Find the relevant lines of the input source
*
* Respects switching tags, returns everything if no switching found.
*
* Strips common indentation from the blocks it finds.
*/
function findRelevantLines(lines: string[]): string[] {
let inRelevant = false;
let didFindRelevant = false;
const ret: string[] = [];
for (const line of lines) {
if (line.trim() === RELEVANT_TAG) {
inRelevant = true;
didFindRelevant = true;
} else if (line.trim() === DETAIL_TAG) {
inRelevant = false;
} else {
if (inRelevant) {
ret.push(line);
}
}
}
// Return full lines list if no switching found
return stripCommonIndent(didFindRelevant ? ret : lines);
}
/**
* Remove common leading whitespace from the given lines
*/
function stripCommonIndent(lines: string[]): string[] {
const leadingWhitespace = /^(\s*)/;
const indents = lines.map((l) => leadingWhitespace.exec(l)![1].length);
const commonIndent = Math.min(...indents);
return lines.map((l) => l.substr(commonIndent));
}
/**
* Turn source lines into Markdown, starting in TypeScript mode
*/
function markdownify(
lines: string[],
codeBlockAnnotations: string[],
): string[] {
const typescriptLines: string[] = [];
const ret: string[] = [];
for (const line of lines) {
const m = INLINE_MD_REGEX.exec(line);
if (m) {
// Literal MarkDown line
flushTS();
ret.push(m[1]);
} else {
typescriptLines.push(line);
}
}
flushTS();
return ret;
/**
* Flush typescript lines with a triple-backtick-ts block around it.
*/
function flushTS() {
if (typescriptLines.length !== 0) {
// eslint-disable-next-line prefer-template
ret.push(
`\`\`\`ts${
codeBlockAnnotations.length > 0
? ` ${codeBlockAnnotations.join(' ')}`
: ''
}`,
...typescriptLines,
'```',
);
typescriptLines.splice(0); // Clear
}
}
}
function toUnixPath(x: string) {
return x.replace(/\\/g, '/');
}