|
| 1 | +"use strict"; |
| 2 | + |
| 3 | +import { existsSync, readFileSync } from "fs"; |
| 4 | +import { resolve, join, basename, dirname } from "path"; |
| 5 | + |
| 6 | +const COMP_TAG = "SnippetTabs"; |
| 7 | + |
| 8 | +class SnippetAccessError extends Error { |
| 9 | + constructor(message: string) { |
| 10 | + super(message); |
| 11 | + this.name = "SnippetAccessError"; |
| 12 | + } |
| 13 | +} |
| 14 | + |
| 15 | +/** |
| 16 | + * A number in {-1, 0, 1} set. |
| 17 | + * |
| 18 | + * 1 means the tag is opening |
| 19 | + * 0 means the tag is self-closing |
| 20 | + * -1 means the tag is closing |
| 21 | + */ |
| 22 | +type TokenNesting = -1 | 0 | 1; |
| 23 | + |
| 24 | +/** |
| 25 | + * Redefine the Token type from Markdown-it to avoid importing CJS |
| 26 | + * https://markdown-it.github.io/markdown-it/#Token |
| 27 | + * |
| 28 | + * @typedef {object} Token |
| 29 | + */ |
| 30 | +type Token = { |
| 31 | + // Source map info. Format: [ line_begin, line_end ]. |
| 32 | + map: number[]; |
| 33 | + // Used in the renderer to calculate line breaks. |
| 34 | + // True for block-level tokens. |
| 35 | + // False for inline tokens. |
| 36 | + block: boolean; |
| 37 | + // '*' or '_' for emphasis, fence string for fence, etc. |
| 38 | + markup: string; |
| 39 | + // Info string for "fence" tokens |
| 40 | + info: string; |
| 41 | + // Level change marker |
| 42 | + nesting: TokenNesting; |
| 43 | +}; |
| 44 | + |
| 45 | +/** |
| 46 | +// Redefine the StateBlock type from Markdown-it that represents a parser state |
| 47 | +// to avoid importing CJS |
| 48 | + * |
| 49 | + * @typedef {object} StateBlock |
| 50 | + */ |
| 51 | +type StateBlock = { |
| 52 | + line: number; |
| 53 | + push(arg0: string, arg1: string, arg2: number): Token; |
| 54 | + skipSpaces(pos: number): number; |
| 55 | + src: string; |
| 56 | + bMarks: number[]; |
| 57 | + eMarks: number[]; |
| 58 | + tShift: number[]; |
| 59 | + sCount: number[]; |
| 60 | + lineMax: number; |
| 61 | + parentType: string; |
| 62 | + blkIndent: number; |
| 63 | +}; |
| 64 | + |
| 65 | +/** |
| 66 | +// Redefine a type that (vaguely) represents Markdown-it |
| 67 | + * |
| 68 | + * @typedef {object} MarkdownIt |
| 69 | + */ |
| 70 | +type MarkdownIt = { |
| 71 | + block: any; |
| 72 | + renderer: any; |
| 73 | +}; |
| 74 | + |
| 75 | +/** |
| 76 | + * A function that splits string by new lines |
| 77 | + * and trims each of those lines. |
| 78 | + * |
| 79 | + * @param {string} input - an input string |
| 80 | + * @returns {string[]} a list of trimmed lines |
| 81 | + */ |
| 82 | +function splitAndTrimLines(input: string): Array<string> { |
| 83 | + return input.split(/\r?\n/).map((item) => item.trim()); |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * A function that composes a new string out of file paths |
| 88 | + * provided to it. |
| 89 | + * |
| 90 | + * Used by the "snippetLocRender" function to render |
| 91 | + * the contents of a Vue component slot. |
| 92 | + * This component will then display the snippets. |
| 93 | + * |
| 94 | + * @param {string[]} paths - a list of file path strings |
| 95 | + * @returns {string} a string, composed of file contents |
| 96 | + */ |
| 97 | +function pathListToStr(paths: string[]): string { |
| 98 | + let result = ""; |
| 99 | + for (let pathId = 0; pathId < paths.length; pathId++) { |
| 100 | + const linePath = paths[pathId]; |
| 101 | + try { |
| 102 | + result += readFileSync(linePath); |
| 103 | + } catch (err) { |
| 104 | + const msg = |
| 105 | + `Unable to read a file.\n` + |
| 106 | + `Filename: "${basename(linePath)}".\n` + |
| 107 | + `Directory path: "${dirname(linePath)}".\n\n` + |
| 108 | + `Ensure it exists, its location is correct and its access rights allow to read it.\n` + |
| 109 | + `If you did not download the snippets, use the "npm run get_snippets" ` + |
| 110 | + `or "pnpm run get_snippets" command.\n` + |
| 111 | + `Read more in "Documenting Iroha" → "Code snippets" part of the tutorial.` + |
| 112 | + `\n`; |
| 113 | + throw new SnippetAccessError(msg); |
| 114 | + } |
| 115 | + } |
| 116 | + return result; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * A function that initializes a snippet group Markdown-it plugin. |
| 121 | + */ |
| 122 | +export function snippets_plugin(md: MarkdownIt, options: Record<string, any>) { |
| 123 | + /** |
| 124 | + * A function that validates snippet parameters and allows it to be rendered. |
| 125 | + * If a path is incorrect, rendering won't happen. |
| 126 | + * |
| 127 | + * @param {string} params - a parameter string that points out a path to the snippets |
| 128 | + * @returns {bool} - whether the snippet directory exists or not |
| 129 | + */ |
| 130 | + function validateDefault(params: string): boolean { |
| 131 | + const snippetPath = resolve(params.trim()); |
| 132 | + let pathExists = false; |
| 133 | + try { |
| 134 | + pathExists = existsSync(snippetPath); |
| 135 | + } catch (fsLookupError) { |
| 136 | + console.error(`Snippet directory lookup error: ${fsLookupError.message}`); |
| 137 | + } |
| 138 | + return pathExists; |
| 139 | + } |
| 140 | + |
| 141 | + /** |
| 142 | + * Render a section with a pre-defined wrapper tag |
| 143 | + * |
| 144 | + * @param {string} tokens - a list of Markdown-It token instances |
| 145 | + */ |
| 146 | + function snippetRender(tokens: Array<Token>, idx: number): string { |
| 147 | + if (tokens[idx].nesting === 1) { |
| 148 | + // Render an opening tag |
| 149 | + return `<${COMP_TAG}>\n`; |
| 150 | + } else { |
| 151 | + // Render an closing tag |
| 152 | + return `</${COMP_TAG}>\n`; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + /** |
| 157 | + * Render slots inside the SnippetTabs Vue component. |
| 158 | + * |
| 159 | + * Locates the internal path or an updated one, |
| 160 | + * outputs the contents of files inside. |
| 161 | + * |
| 162 | + * @param {Array<Token>} tokens - array of Markdown token instances |
| 163 | + * @param {number} idx |
| 164 | + * @returns {string} - render results |
| 165 | + */ |
| 166 | + function snippetLocRender(tokens: Array<Token>, idx: number): string { |
| 167 | + const pathStr = |
| 168 | + options.snippet_root || |
| 169 | + join(options.snippet_root, tokens[idx - 1].info.trim()); |
| 170 | + const snippetPath: string = resolve(pathStr); |
| 171 | + const paths: string[] = splitAndTrimLines(tokens[idx].info.trim()).map( |
| 172 | + (filename) => { |
| 173 | + return join(snippetPath, filename); |
| 174 | + } |
| 175 | + ); |
| 176 | + return `${pathListToStr(paths)}\n`; |
| 177 | + } |
| 178 | + |
| 179 | + options = options || {}; |
| 180 | + |
| 181 | + let min_markers: number = 3, |
| 182 | + marker_str: string = "+", |
| 183 | + marker_char: number = marker_str.charCodeAt(0), |
| 184 | + marker_len: number = marker_str.length, |
| 185 | + validate: Function = options.validate || validateDefault, |
| 186 | + render: Function = snippetRender; |
| 187 | + |
| 188 | + if ( |
| 189 | + !options.hasOwnProperty("snippet_root") || |
| 190 | + options.snippet_root.constructor.name !== "String" |
| 191 | + ) { |
| 192 | + const errTxt = |
| 193 | + "Incorrect configuration. " + |
| 194 | + "A correct value for snippet_root is required for snippet_tabs plugin."; |
| 195 | + throw new Error(errTxt); |
| 196 | + } |
| 197 | + |
| 198 | + function snippet_container( |
| 199 | + state: StateBlock, |
| 200 | + startLine: number, |
| 201 | + endLine: number, |
| 202 | + silent: boolean |
| 203 | + ) { |
| 204 | + var pos: number, |
| 205 | + nextLine: number, |
| 206 | + marker_count: number, |
| 207 | + markup: string, |
| 208 | + params: string, |
| 209 | + token: Token, |
| 210 | + old_parent: string, |
| 211 | + old_line_max: number, |
| 212 | + auto_closed = false, |
| 213 | + start: number = state.bMarks[startLine] + state.tShift[startLine], |
| 214 | + max: number = state.eMarks[startLine]; |
| 215 | + |
| 216 | + // Check out the first character quickly |
| 217 | + // to filter out most of non-containers |
| 218 | + if (marker_char !== state.src.charCodeAt(start)) { |
| 219 | + return false; |
| 220 | + } |
| 221 | + |
| 222 | + // Check out the rest of the marker string |
| 223 | + for (pos = start + 1; pos <= max; pos++) { |
| 224 | + if (marker_str[(pos - start) % marker_len] !== state.src[pos]) { |
| 225 | + break; |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + marker_count = Math.floor((pos - start) / marker_len); |
| 230 | + if (marker_count < min_markers) { |
| 231 | + return false; |
| 232 | + } |
| 233 | + pos -= (pos - start) % marker_len; |
| 234 | + |
| 235 | + markup = state.src.slice(start, pos); |
| 236 | + params = state.src.slice(pos, max); |
| 237 | + if (!validate(params, markup)) { |
| 238 | + return false; |
| 239 | + } |
| 240 | + |
| 241 | + // Since start is found, we can report success here in validation mode |
| 242 | + if (silent) return true; |
| 243 | + |
| 244 | + // Search for the end of the block |
| 245 | + nextLine = startLine; |
| 246 | + |
| 247 | + for (;;) { |
| 248 | + nextLine++; |
| 249 | + if (nextLine >= endLine) { |
| 250 | + // Non-closed block should be autoclosed by end of document. |
| 251 | + // Also, block seems to be |
| 252 | + // automatically closed by the end of a parent one. |
| 253 | + break; |
| 254 | + } |
| 255 | + |
| 256 | + start = state.bMarks[nextLine] + state.tShift[nextLine]; |
| 257 | + max = state.eMarks[nextLine]; |
| 258 | + |
| 259 | + if (start < max && state.sCount[nextLine] < state.blkIndent) { |
| 260 | + // non-empty line with negative indent should stop the list: |
| 261 | + // - ``` |
| 262 | + // test |
| 263 | + break; |
| 264 | + } |
| 265 | + |
| 266 | + if (marker_char !== state.src.charCodeAt(start)) { |
| 267 | + continue; |
| 268 | + } |
| 269 | + |
| 270 | + if (state.sCount[nextLine] - state.blkIndent >= 4) { |
| 271 | + // closing fence should be indented less than 4 spaces |
| 272 | + continue; |
| 273 | + } |
| 274 | + |
| 275 | + for (pos = start + 1; pos <= max; pos++) { |
| 276 | + if (marker_str[(pos - start) % marker_len] !== state.src[pos]) { |
| 277 | + break; |
| 278 | + } |
| 279 | + } |
| 280 | + |
| 281 | + // closing code fence must be at least as long as the opening one |
| 282 | + if (Math.floor((pos - start) / marker_len) < marker_count) { |
| 283 | + continue; |
| 284 | + } |
| 285 | + |
| 286 | + // make sure tail has spaces only |
| 287 | + pos -= (pos - start) % marker_len; |
| 288 | + pos = state.skipSpaces(pos); |
| 289 | + |
| 290 | + if (pos < max) { |
| 291 | + continue; |
| 292 | + } |
| 293 | + |
| 294 | + // found! |
| 295 | + auto_closed = true; |
| 296 | + break; |
| 297 | + } |
| 298 | + |
| 299 | + old_parent = state.parentType; |
| 300 | + old_line_max = state.lineMax; |
| 301 | + state.parentType = "snippets"; |
| 302 | + |
| 303 | + // Prevent the lazy continuations from ever going past an end marker |
| 304 | + state.lineMax = nextLine; |
| 305 | + |
| 306 | + token = state.push("snippets_open", "div", 1); |
| 307 | + token.markup = markup; |
| 308 | + token.block = true; |
| 309 | + token.info = params; |
| 310 | + token.map = [startLine, nextLine]; |
| 311 | + |
| 312 | + token = state.push("snippet_locations", "div", 1); |
| 313 | + token.markup = markup; |
| 314 | + token.block = true; |
| 315 | + token.info = state.src.slice( |
| 316 | + state.bMarks[startLine + 1], |
| 317 | + state.bMarks[nextLine] |
| 318 | + ); |
| 319 | + token.map = [startLine, nextLine]; |
| 320 | + |
| 321 | + token = state.push("snippets_close", "div", -1); |
| 322 | + token.markup = state.src.slice(start, pos); |
| 323 | + token.block = true; |
| 324 | + |
| 325 | + state.parentType = old_parent; |
| 326 | + state.lineMax = old_line_max; |
| 327 | + state.line = nextLine + (auto_closed ? 1 : 0); |
| 328 | + |
| 329 | + return true; |
| 330 | + } |
| 331 | + |
| 332 | + md.block.ruler.before("fence", "snippets", snippet_container, {}); |
| 333 | + md.renderer.rules["snippets_open"] = render; |
| 334 | + md.renderer.rules["snippets_close"] = render; |
| 335 | + md.renderer.rules["snippet_locations"] = snippetLocRender; |
| 336 | +} |
0 commit comments