UNPKG

7.97 kBJavaScriptView Raw
1//> This file contains the bulk of the logic for generating
2// litterate pages. This file exports a function that the
3// command-line utility calls with configurations.
4
5const fs = require('fs');
6const path = require('path');
7const mkdirp = require('mkdirp');
8//> Marked is our markdown parser
9const marked = require('marked');
10
11//> This isn't optimal, but for now, we read the three template files into memory at the beginning,
12// synchronously, so we can reuse them later.
13const INDEX_PAGE = fs.readFileSync(path.resolve(__dirname, '../templates/index.html'), 'utf8');
14const STYLES_CSS = fs.readFileSync(path.resolve(__dirname, '../templates/main.css'), 'utf8');
15const SOURCE_PAGE = fs.readFileSync(path.resolve(__dirname, '../templates/source.html'), 'utf8');
16
17//> Helper function to wrap a given line of text into multiple lines,
18// with `limit` characters per line.
19const wrapLine = (line, limit) => {
20 const len = line.length;
21 let result = '';
22 for (let countedChars = 0; countedChars < len; countedChars += limit) {
23 result += line.substr(countedChars, limit) + '\n';
24 }
25 return result;
26}
27
28//> Helper function to scape characters that won't display in HTML correctly, like the very common
29// `>` and `<` and `&` characters in code.
30const encodeHTML = code => {
31 return code.replace(/[\u00A0-\u9999<>\&]/gim, i => {
32 return '&#' + i.codePointAt(0) + ';';
33 });
34}
35
36//> Marked allows us to provide a custom HTML sanitizer, which we use to encode HTML
37// entities correctly.
38const markedOptions = {
39 sanitize: true,
40 sanitizer: encodeHTML,
41}
42const renderMarkdown = str => marked(str, markedOptions);
43
44//> Litterate uses a very, very minimal templating system that just wraps keywords
45// in `{{curlyBraces}}`. We don't need anything complicated, and this allows us to be
46// lightweight and customizable when needed. This function populates a template with
47// given key-value pairs.
48const resolveTemplate = (templateContent, templateValues) => {
49 for (const [key, value] of Object.entries(templateValues)) {
50 templateContent = templateContent.replace(
51 new RegExp(`{{${key}}}`, 'g'),
52 value
53 );
54 }
55 return templateContent;
56}
57
58//> Function that maps a given source file to the path where its annotated version
59// will be saved.
60const getOutputPathForSourcePath = (sourcePath, config) => {
61 return path.join(
62 config.outputDirectory,
63 sourcePath + '.html'
64 );
65}
66
67//> Function to populate the `index.html` page of the generated site with all the source
68// links, name/description, etc.
69const populateIndexPage = (sourceFiles, config) => {
70 const files = sourceFiles.map(sourcePath => {
71 const outputPath = getOutputPathForSourcePath(sourcePath, config);
72 return `<p class="sourceLink"><a href="${config.baseURL}${path.relative(config.outputDirectory, outputPath)}">${sourcePath}</a></p>`;
73 });
74 return resolveTemplate(INDEX_PAGE, {
75 title: config.name,
76 description: renderMarkdown(config.description),
77 sourcesList: files.join('\n'),
78 baseURL: config.baseURL,
79 });
80}
81
82//> Given an array of source code lines, return an array of lines matched with
83// any corresponding annotations and the line number from the source file.
84const linesToLinePairs = (lines, config) => {
85 const linePairs = [];
86 let docLine = '';
87
88 //> Shorthand function to markdown-process and optionally wrap
89 // source code lines.
90 const processCodeLine = codeLine => {
91 if (config.wrap !== 0) {
92 return wrapLine(encodeHTML(codeLine), config.wrap);
93 } else {
94 return encodeHTML(codeLine);
95 }
96 }
97
98 //> `linesToLinePairs` works by having two arrays -- one of the annotation-lineNumber-source line
99 // tuples in order, and another of the annotation lines counted so far for the next source line.
100 // This takes the annotation, line number, and source line from the second array and pushes it
101 // onto the first array, so we can move onto the next lines.
102 let inAnnotationComment = false;
103 const pushPair = (codeLine, lineNumber) => {
104 if (docLine) {
105 const lastLine = linePairs[linePairs.length - 1];
106 if (lastLine && lastLine[0]) {
107 linePairs.push(['', '', '']);
108 }
109 linePairs.push([renderMarkdown(docLine), processCodeLine(codeLine), lineNumber]);
110 } else {
111 linePairs.push(['', processCodeLine(codeLine), lineNumber]);
112 }
113 docLine = '';
114 }
115
116 //> Push the current annotation line onto the array of previous annotation lines,
117 // until we get to the next source code line.
118 const pushComment = line => {
119 if (line.trim().startsWith(config.annotationStartMark)) {
120 docLine = line.replace(config.annotationStartMark, '').trim();
121 } else {
122 docLine += ' ' + line.replace(config.annotationContinueMark, '').trim();
123 }
124 };
125
126 //> The main loop for this function.
127 lines.forEach((line, idx) => {
128 if (line.trim().startsWith(config.annotationStartMark)) {
129 inAnnotationComment = true;
130 pushComment(line);
131 } else if (line.trim().startsWith(config.annotationContinueMark)) {
132 if (inAnnotationComment) {
133 pushComment(line)
134 } else {
135 pushPair(line, idx + 1);
136 }
137 } else {
138 if (inAnnotationComment) inAnnotationComment = false;
139 pushPair(line, idx + 1);
140 }
141 });
142
143 return linePairs;
144}
145
146//> This function is called for each source file, to process and save
147// the Litterate version of the source file in the correct place.
148const createAndSavePage = async (sourcePath, config) => {
149 const logErr = (err) => {
150 if (err) console.error(`Error writing ${sourcePath} annotated page: ${err}`);
151 }
152
153 fs.readFile(sourcePath, 'utf8', (err, content) => {
154 if (err) logErr();
155
156 const sourceLines = linesToLinePairs(content.split('\n'), config).map(([doc, source, lineNumber]) => {
157 return `<div class="line"><div class="doc">${doc}</div><pre class="source javascript"><strong class="lineNumber">${lineNumber}</strong>${source}</pre></div>`;
158 }).join('\n');
159
160 const annotatedPage = resolveTemplate(SOURCE_PAGE, {
161 title: sourcePath,
162 lines: sourceLines,
163 baseURL: config.baseURL,
164 });
165 const outputFilePath = getOutputPathForSourcePath(sourcePath, config);
166 mkdirp(path.parse(outputFilePath).dir, (err) => {
167 if (err) logErr();
168
169 fs.writeFile(outputFilePath, annotatedPage, 'utf8', err => {
170 if (err) logErr();
171 });
172 });
173 });
174}
175
176//> This whole file exports this single function, which is called with a list of files
177// to process, and the configuration options.
178const generateLitteratePages = async (sourceFiles, config) => {
179 const {
180 outputDirectory,
181 } = config;
182
183 //> Write out index and main.css files
184 mkdirp(outputDirectory, err => {
185 if (err) console.error(`Unable to create ${outputDirectory} for documentation`);
186
187 fs.writeFile(
188 path.resolve(outputDirectory, 'index.html'),
189 populateIndexPage(sourceFiles, config),
190 'utf8', err => {
191 if (err) console.error(`Error encountered while writing index.html to disk: ${err}`);
192 }
193 );
194
195 fs.writeFile(path.resolve(outputDirectory, 'main.css'), STYLES_CSS, 'utf8', err => {
196 if (err) console.error(`Error encountered while writing main.css to disk: ${err}`);
197 });
198 });
199
200 //> Process source files that need to be annotated
201 for (const sourceFile of sourceFiles) {
202 await createAndSavePage(sourceFile, config);
203 console.log(`Annotated ${sourceFile}`);
204 }
205}
206
207module.exports = {
208 generateLitteratePages,
209}