├── index.js ├── .prettierrc ├── .gitignore ├── .bin └── cli.js ├── src ├── free-port.js ├── docsify-server.js ├── logger.js ├── process-images-paths.js ├── contents-builder.js ├── process-inner-links.js ├── markdown-combine.js ├── index.js ├── render.js ├── utils.js └── run-sandbox-script.js ├── package.json └── readme.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/index.js"); 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { "trailingComma": "all", "printWidth": 100 } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | .vscode/ 4 | docs/ 5 | pdf/ 6 | static/ 7 | -------------------------------------------------------------------------------- /.bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const rcfile = require("rcfile"); 4 | 5 | const config = rcfile("docsifytopdf"); 6 | 7 | require("../src/index.js")(config); 8 | -------------------------------------------------------------------------------- /src/free-port.js: -------------------------------------------------------------------------------- 1 | const fp = require("find-free-port"); 2 | 3 | const getFreePort = () => { 4 | return fp(3000, 3100, "127.0.0.1", 2).catch(err => { 5 | console.error(err); 6 | }); 7 | }; 8 | 9 | module.exports = getFreePort; 10 | -------------------------------------------------------------------------------- /src/docsify-server.js: -------------------------------------------------------------------------------- 1 | const docsifyCli = require("docsify-cli/lib/commands/serve.js"); 2 | 3 | const runDocsifyRenderer = ({ 4 | docsifyRendererPort, 5 | docsifyLiveReloadPort, 6 | pathToDocsifyEntryPoint, 7 | }) => () => { 8 | docsifyCli(pathToDocsifyEntryPoint, false, docsifyRendererPort, docsifyLiveReloadPort); 9 | }; 10 | 11 | module.exports = ({ docsifyRendererPort, docsifyLiveReloadPort, pathToDocsifyEntryPoint }) => ({ 12 | runDocsifyRenderer: runDocsifyRenderer({ 13 | docsifyRendererPort, 14 | docsifyLiveReloadPort, 15 | pathToDocsifyEntryPoint, 16 | }), 17 | }); 18 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | require("colors"); 2 | 3 | class Logger { 4 | static success(text) { 5 | console.log(`\nSUCCESS:\n${text}\n`.green); 6 | } 7 | static info(text) { 8 | console.log(`\nINFO:\n${text}\n`.blue); 9 | } 10 | static warn(text, error) { 11 | console.warn(`\nWARNING:\n${text}\n`.yellow); 12 | 13 | if (error) { 14 | console.error(JSON.stringify(error, null, 2).yellow); 15 | console.error("\n"); 16 | } 17 | } 18 | static err(text, error) { 19 | console.error(`\nERROR:\n${text}\n`.red); 20 | 21 | if (error) { 22 | console.error(error.toString().red); 23 | console.error("\n"); 24 | } 25 | } 26 | } 27 | 28 | module.exports = Logger; 29 | -------------------------------------------------------------------------------- /src/process-images-paths.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const markdownLinkExtractor = require("markdown-link-extractor"); 3 | const isUrl = require("is-url"); 4 | 5 | const isImg = filePath => { 6 | const extName = path.parse(filePath).ext; 7 | 8 | return extName === ".jpg" || extName === ".png" || extName === ".gif"; 9 | }; 10 | 11 | module.exports = ({ pathToStatic }) => ({ content, name }) => { 12 | let markdown = content; 13 | const dir = path.dirname(name); 14 | const dirWithStatic = path.resolve(process.cwd(), pathToStatic); 15 | 16 | markdownLinkExtractor(content) 17 | .filter(link => !isUrl(link)) 18 | .filter(isImg) 19 | .map(link => ({ origin: link, processed: path.resolve(dir, link) })) 20 | .map(({ origin, processed }) => ({ 21 | origin, 22 | processed: path.relative(dirWithStatic, processed), 23 | })) 24 | .forEach(({ origin, processed }) => { 25 | markdown = markdown.replace(origin, processed); 26 | }); 27 | 28 | return markdown; 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docsify-pdf-converter", 3 | "version": "2.1.0-beta.0", 4 | "description": "A magical pdf generator for docsify projects.", 5 | "main": "index.js", 6 | "author": { 7 | "name": "Ivan Gorbunov", 8 | "email": "meff34@gmail.com", 9 | "url": "https://github.com/meff34" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/meff34/docsify-to-pdf-converter.git" 14 | }, 15 | "bin": { 16 | "converter": ".bin/cli.js", 17 | "docsify-pdf-converter": ".bin/cli.js" 18 | }, 19 | "scripts": { 20 | "patch-release": "npm version patch && npm publish && git push --follow-tags" 21 | }, 22 | "keywords": [ 23 | "docsify", 24 | "pdf", 25 | "converter", 26 | "cli" 27 | ], 28 | "license": "ISC", 29 | "dependencies": { 30 | "colors": "^1.3.3", 31 | "docsify-cli": "^4.3.0", 32 | "find-free-port": "^2.0.0", 33 | "is-url": "^1.2.4", 34 | "lodash": "^4.17.11", 35 | "markdown-link-extractor": "^1.2.0", 36 | "puppeteer": "^1.12.2", 37 | "rcfile": "^1.0.3", 38 | "remark-parse": "^6.0.3", 39 | "rimraf": "^2.6.3", 40 | "unified": "^7.1.0", 41 | "yesno": "^0.2.0" 42 | }, 43 | "devDependencies": { 44 | "prettier": "^1.15.3" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/contents-builder.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const util = require("util"); 3 | const path = require("path"); 4 | const markdownLinkExtractor = require("markdown-link-extractor"); 5 | const isUrl = require("is-url"); 6 | const { flatten } = require("lodash"); 7 | 8 | const [readFile] = [fs.readFile].map(fn => util.promisify(fn)); 9 | 10 | const createRoadMap = ({ contents }) => async () => { 11 | let contentsPaths = Array.isArray(contents) ? contents : [contents]; 12 | 13 | const sidebarFilePaths = contentsPaths.map(sidebarFileName => { 14 | const a = path.dirname(path.resolve(sidebarFileName)); 15 | const b = path.resolve(sidebarFileName); 16 | return { dir: a, filePath: b }; 17 | }); 18 | 19 | const sidebarFileContents = await Promise.all( 20 | sidebarFilePaths.map(async ({ dir, filePath }) => ({ 21 | dir, 22 | file: await readFile(filePath, { encoding: "utf8" }), 23 | })), 24 | ); 25 | 26 | const contentsArray = sidebarFileContents.map(({ file, dir }) => 27 | markdownLinkExtractor(file) 28 | .filter(link => !isUrl(link)) 29 | .map(link => path.resolve(dir, link)), 30 | ); 31 | 32 | return await flatten(contentsArray); 33 | }; 34 | 35 | module.exports = config => ({ 36 | createRoadMap: createRoadMap(config), 37 | }); 38 | -------------------------------------------------------------------------------- /src/process-inner-links.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const markdownLinkExtractor = require("markdown-link-extractor"); 3 | const unified = require("unified"); 4 | const parser = require("remark-parse"); 5 | 6 | module.exports = ({ content, name }, _, arr) => { 7 | let newContent = content; 8 | const b = markdownLinkExtractor(content) 9 | .filter(link => path.parse(link).ext === ".md") 10 | .map(link => ({ file: arr.find(({ name }) => name.includes(link)), link })) 11 | .filter(({ file }) => file) 12 | .map(({ file: { content }, link }) => ({ 13 | ast: unified() 14 | .use(parser) 15 | .parse(content), 16 | link, 17 | })) 18 | .map(({ ast, link }) => { 19 | const [a] = ast.children.filter(({ type }) => type === "heading"); 20 | const { value } = a.children.find(obj => obj.type === "text"); 21 | 22 | return { link, unsafeTag: value }; 23 | }) 24 | .map(({ unsafeTag, link }) => ({ 25 | link, 26 | tagWord: unsafeTag.replace(/[&\/\\#,+()$~%.'":*?<>{}]/g, "").replace(/\s/g, "-"), 27 | })) 28 | .map(({ link, tagWord }) => ({ 29 | link, 30 | tag: `#${tagWord}`, 31 | })); 32 | 33 | b.forEach(({ tag, link }) => (newContent = newContent.replace(link, tag))); 34 | 35 | return { content: newContent, name }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/markdown-combine.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const util = require("util"); 3 | const path = require("path"); 4 | const logger = require("./logger.js"); 5 | const processImagesPaths = require("./process-images-paths.js"); 6 | const processInnerLinks = require("./process-inner-links.js"); 7 | 8 | const [readFile, writeFile, exists] = [fs.readFile, fs.writeFile, fs.exists].map(fn => 9 | util.promisify(fn), 10 | ); 11 | 12 | const combineMarkdowns = ({ contents, pathToStatic, mainMdFilename }) => async links => { 13 | try { 14 | const files = await Promise.all( 15 | await links.map(async filename => { 16 | const fileExist = await exists(filename); 17 | 18 | if (fileExist) { 19 | const content = await readFile(filename, { 20 | encoding: "utf8", 21 | }); 22 | 23 | return { 24 | content, 25 | name: filename, 26 | }; 27 | } 28 | 29 | throw new Error(`file ${filename} is not exist, but listed in ${contents}`); 30 | }), 31 | ); 32 | 33 | const resultFilePath = path.resolve(pathToStatic, mainMdFilename); 34 | 35 | try { 36 | const content = files 37 | .map(processInnerLinks) 38 | .map(processImagesPaths({ pathToStatic })) 39 | .join("\n\n\n\n"); 40 | await writeFile(resultFilePath, content); 41 | } catch (e) { 42 | logger.err("markdown combining error", e); 43 | throw e; 44 | } 45 | 46 | return resultFilePath; 47 | } catch (err) { 48 | logger.err("combineMarkdowns", err); 49 | throw err; 50 | } 51 | }; 52 | 53 | module.exports = config => ({ 54 | combineMarkdowns: combineMarkdowns(config), 55 | }); 56 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { merge } = require("lodash"); 3 | const logger = require("./logger.js"); 4 | const getFreePorts = require("./free-port.js"); 5 | 6 | const defaultConfig = { 7 | pathToStatic: "static", 8 | mainMdFilename: "main.md", 9 | removeTemp: true, 10 | contents: "docs/_sidebar.md", 11 | pathToPublic: "./pdf/readme.pdf", 12 | pdfOptions: { format: "A4" }, 13 | emulateMedia: "print", 14 | pathToDocsifyEntryPoint: ".", 15 | }; 16 | 17 | const run = async incomingConfig => { 18 | const [docsifyRendererPort, docsifyLiveReloadPort] = await getFreePorts(); 19 | const preBuildedConfig = merge(defaultConfig, incomingConfig); 20 | 21 | logger.info("Build with settings:"); 22 | console.log(JSON.stringify(preBuildedConfig, null, 2)); 23 | console.log("\n"); 24 | 25 | const config = merge(preBuildedConfig, { 26 | docsifyRendererPort, 27 | docsifyLiveReloadPort, 28 | }); 29 | 30 | const { combineMarkdowns } = require("./markdown-combine.js")(config); 31 | const { closeProcess, prepareEnv, cleanUp } = require("./utils.js")(config); 32 | const { createRoadMap } = require("./contents-builder.js")(config); 33 | const { runDocsifyRenderer } = require("./docsify-server.js")(config); 34 | const { htmlToPdf } = require("./render.js")(config); 35 | 36 | try { 37 | await cleanUp(); 38 | await prepareEnv(); 39 | const roadMap = await createRoadMap(); 40 | await combineMarkdowns(roadMap); 41 | 42 | runDocsifyRenderer(); 43 | await htmlToPdf(); 44 | 45 | logger.success(path.resolve(config.pathToPublic)); 46 | } catch (error) { 47 | logger.err("run error", error); 48 | } finally { 49 | closeProcess(0); 50 | } 51 | }; 52 | 53 | module.exports = run; 54 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const puppeteer = require("puppeteer"); 3 | const logger = require("./logger.js"); 4 | const runSandboxScript = require("./run-sandbox-script.js"); 5 | 6 | const renderPdf = async ({ 7 | mainMdFilename, 8 | pathToStatic, 9 | pathToPublic, 10 | pdfOptions, 11 | docsifyRendererPort, 12 | emulateMedia, 13 | }) => { 14 | const browser = await puppeteer.launch({ 15 | defaultViewport: { 16 | width: 1200, 17 | height: 1000, 18 | }, 19 | }); 20 | try { 21 | const mainMdFilenameWithoutExt = path.parse(mainMdFilename).name; 22 | const docsifyUrl = `http://localhost:${docsifyRendererPort}/#/${pathToStatic}/${mainMdFilenameWithoutExt}`; 23 | 24 | const page = await browser.newPage(); 25 | await page.goto(docsifyUrl, { waitUntil: "networkidle0" }); 26 | 27 | const renderProcessingErrors = await runSandboxScript(page, { 28 | mainMdFilenameWithoutExt, 29 | pathToStatic, 30 | }); 31 | 32 | if (renderProcessingErrors.length) 33 | logger.warn("anchors processing errors", renderProcessingErrors); 34 | 35 | await page.emulateMedia(emulateMedia); 36 | await page.pdf({ 37 | ...pdfOptions, 38 | path: path.resolve(pathToPublic), 39 | }); 40 | 41 | return await browser.close(); 42 | } catch (e) { 43 | await browser.close(); 44 | throw e; 45 | } 46 | }; 47 | 48 | const htmlToPdf = ({ 49 | mainMdFilename, 50 | pathToStatic, 51 | pathToPublic, 52 | pdfOptions, 53 | removeTemp, 54 | docsifyRendererPort, 55 | emulateMedia, 56 | }) => async () => { 57 | const { closeProcess } = require("./utils.js")({ pathToStatic, removeTemp }); 58 | try { 59 | return await renderPdf({ 60 | mainMdFilename, 61 | pathToStatic, 62 | pathToPublic, 63 | pdfOptions, 64 | docsifyRendererPort, 65 | emulateMedia, 66 | }); 67 | } catch (err) { 68 | logger.err("puppeteer renderer error:", err); 69 | await closeProcess(1); 70 | } 71 | }; 72 | 73 | module.exports = config => ({ 74 | htmlToPdf: htmlToPdf(config), 75 | }); 76 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # docsify-pdf-converter 2 | 3 | ## Install 4 | 5 | ```sh 6 | npm install --save-dev docsify-pdf-converter 7 | ``` 8 | 9 | ## Usage as CLI: 10 | 11 | Create: 12 | 13 | * config file `.docsifytopdfrc.` 14 | * or `"docsifytopdf"` field in `package.json` (like [rcfile][rcfile] can receive) with this setup object: 15 | 16 | Example `.docsifytopdfrc.js` content: 17 | 18 | ```js 19 | module.exports = { 20 | contents: [ "docs/_sidebar.md" ], // array of "table of contents" files path 21 | pathToPublic: "pdf/readme.pdf", // path where pdf will stored 22 | pdfOptions: "", // reference: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions 23 | removeTemp: true, // remove generated .md and .html or not 24 | emulateMedia: "screen", // mediaType, emulating by puppeteer for rendering pdf, 'print' by default (reference: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pageemulatemediamediatype) 25 | } 26 | ``` 27 | 28 | Add script into `package.json`: 29 | 30 | ```json 31 | { 32 | "scripts": { 33 | "convert": "node_modules/.bin/docsify-pdf-converter" 34 | } 35 | } 36 | ``` 37 | 38 | Run converter: 39 | 40 | ```sh 41 | npm run convert 42 | ``` 43 | 44 | ## Usage as npm-package: 45 | 🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧 46 | This part of module is not safe for work - it will stop process after generation pdf. Use it for your own risk. 47 | You can just import and use main function like this: 48 | 49 | ```js 50 | const converter = require('docsify-pdf-converter'); 51 | const config = require('./.docsifytopdfrc.js'); 52 | 53 | converter(config) // right after resolve or reject inner promise your process will be terminated :C 54 | ``` 55 | 🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧 56 | 57 | ## Contributing 58 | 59 | - Fork it! 60 | - Create your feature branch: `git checkout -b my-new-feature` 61 | - Commit your changes: `git commit -am 'Add some feature'` 62 | - Push to the branch: `git push origin my-new-feature` 63 | - Submit a pull request 64 | 65 | Your pull requests and issues are welcome! 66 | 67 | [rcfile]: https://www.npmjs.com/package/rcfile 68 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const util = require("util"); 3 | const path = require("path"); 4 | const rimraf = require("rimraf"); 5 | const yesno = require("yesno"); 6 | const findFreePort = require('find-free-port'); 7 | require("colors"); 8 | 9 | const logger = require("./logger.js"); 10 | 11 | const [mkdir, exists] = [fs.mkdir, fs.exists].map(fn => util.promisify(fn)); 12 | 13 | const safetyMkdir = async rawPath => { 14 | const resolvedPath = path.resolve(rawPath); 15 | 16 | const isExist = await exists(resolvedPath); 17 | 18 | if (!isExist) { 19 | return await mkdir(resolvedPath); 20 | } 21 | 22 | return Promise.resolve(); 23 | }; 24 | 25 | const removeArtifacts = async paths => 26 | Promise.all(paths.map(path => new Promise(resolve => rimraf(path, resolve)))); 27 | 28 | const prepareEnv = ({ pathToStatic, pathToPublic }) => () => { 29 | const pathToStaticDir = path.resolve(pathToStatic); 30 | const pathToPublicDir = path.dirname(path.resolve(pathToPublic)); 31 | 32 | return Promise.all([safetyMkdir(pathToStaticDir), safetyMkdir(pathToPublicDir)]).catch(err => { 33 | logger.err("prepareEnv", err); 34 | }); 35 | }; 36 | 37 | const cleanUp = ({ pathToStatic, pathToPublic, removeTemp }) => async () => { 38 | const isExist = await exists(path.resolve(pathToStatic)); 39 | 40 | if (!isExist) { 41 | return Promise.resolve(); 42 | } 43 | 44 | const questionStatic = `Path "${path.resolve( 45 | pathToStatic, 46 | )}" reserved for statics is already exists.${ 47 | removeTemp ? " It will be deleted." : "" 48 | } Continue evaluating? (y/n)`.yellow; 49 | 50 | const answer = await yesno.askAsync(questionStatic); 51 | 52 | if (answer) { 53 | return removeArtifacts([path.resolve(pathToPublic)]); 54 | } else { 55 | return Promise.reject("User stops evaluating"); 56 | } 57 | }; 58 | 59 | const closeProcess = ({ pathToStatic, removeTemp }) => async code => { 60 | if (removeTemp) { 61 | await removeArtifacts([path.resolve(pathToStatic)]); 62 | } 63 | 64 | return process.exit(code); 65 | }; 66 | 67 | module.exports = config => ({ 68 | prepareEnv: prepareEnv(config), 69 | cleanUp: cleanUp(config), 70 | closeProcess: closeProcess(config), 71 | }); 72 | -------------------------------------------------------------------------------- /src/run-sandbox-script.js: -------------------------------------------------------------------------------- 1 | module.exports = async (page, { mainMdFilenameWithoutExt, pathToStatic }) => { 2 | await page.addScriptTag({ 3 | url: "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js", 4 | }); 5 | 6 | return page.evaluate( 7 | ({ mainMdFilenameWithoutExt, pathToStatic }) => { 8 | const errors = []; 9 | 10 | const makeDocsifyPrettyPrintable = () => { 11 | const nav = document.querySelector("nav"); 12 | if (nav) nav.remove(); 13 | 14 | const aside = document.querySelector("aside.sidebar"); 15 | if (aside) aside.remove(); 16 | 17 | const button = document.querySelector("button.sidebar-toggle"); 18 | if (button) button.remove(); 19 | 20 | document.querySelector("section.content").style = ` 21 | position: static; 22 | padding-top: 0; 23 | `; 24 | }; 25 | 26 | const isSafeTag = tag => tag === window.decodeURIComponent(tag); 27 | 28 | function randomString(length) { 29 | let text = ""; 30 | const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 31 | 32 | for (var i = 0; i < length; i++) 33 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 34 | 35 | return text; 36 | } 37 | 38 | const setSafeTagToHref = (anchorNodes, unsafeTag) => { 39 | const safeId = randomString(10); 40 | 41 | anchorNodes.forEach(node => { 42 | node.href = `#${safeId}`; 43 | }); 44 | 45 | try { 46 | const headerId = decodeURIComponent(unsafeTag).replace(/[&\/\\#,+()$~%.'":*?<>{}]/g, ""); 47 | const anchorTarget = document.querySelector(`#${headerId}`); 48 | 49 | anchorTarget.id = safeId; 50 | } catch (e) { 51 | errors.push({ 52 | processingAnchor: decodeURIComponent(unsafeTag), 53 | error: e.message, 54 | stack: e.stack, 55 | }); 56 | } 57 | }; 58 | 59 | const processSafeInternalLinks = links => { 60 | links.forEach(({ node, id }) => (node.href = `#${id}`)); 61 | }; 62 | 63 | const processUnSafeInternalLinks = unsafeInternalLinks => { 64 | _.chain(unsafeInternalLinks) 65 | .groupBy("id") 66 | .transform((result, value, key) => { 67 | result[key] = value.map(({ node }) => node); 68 | }, {}) 69 | .forOwn(setSafeTagToHref) 70 | .value(); 71 | }; 72 | 73 | const extractInternalLinks = () => { 74 | const allInternalLinks = [ 75 | ...document.querySelectorAll( 76 | `[href*="#/${pathToStatic}/${mainMdFilenameWithoutExt}?id="]`, 77 | ), 78 | ].map(node => { 79 | const [, id] = node.href.split("id="); 80 | return { node, id }; 81 | }); 82 | 83 | const [safeInternalLinks, unsafeInternalLinks] = allInternalLinks.reduce( 84 | ([safe, unsafe], elem) => 85 | isSafeTag(elem.id) ? [[...safe, elem], unsafe] : [safe, [...unsafe, elem]], 86 | [[], []], 87 | ); 88 | 89 | return [safeInternalLinks, unsafeInternalLinks]; 90 | }; 91 | 92 | const processAnchors = () => { 93 | const [safeInternalLinks, unsafeInternalLinks] = extractInternalLinks(); 94 | 95 | processSafeInternalLinks(safeInternalLinks); 96 | processUnSafeInternalLinks(unsafeInternalLinks); 97 | }; 98 | 99 | const main = () => { 100 | makeDocsifyPrettyPrintable(); 101 | processAnchors(); 102 | }; 103 | 104 | main(); 105 | 106 | return errors; 107 | }, 108 | { mainMdFilenameWithoutExt, pathToStatic }, 109 | ); 110 | }; 111 | --------------------------------------------------------------------------------