#! /usr/bin/env node /** * This scripts auto-generates tutorial documentation from the * `welcome-to-xod` multi-file project. * * Also it glues a content of `before-1st-h2.md` from the patch directory * to the tutorial contents before first H2 or in the end of the article. * * It generates tutorial with steps: * 1. Extracts comments' content to the RAM * 2. Remove all comments * 3. Save project without comments as `welcome-to-xod.xodball` * 4. Generate `README.md` for every patch in the project * 5. Generate a script for the screenshotter * 6. Run the screenshotter * * Run: * `./tools/generate-tutorial-docs.js 2.0.1 /path/to/welcome-to-xod /path/to/docs/tutorial/` * * Options: * `--no-screenshots` - to run all steps without running screenshotter (last step) */ /* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable no-console */ const os = require('os'); const path = require('path'); const fs = require('fs').promises; const fse = require('fs-extra'); const exec = require('child_process').exec; const R = require('ramda'); const { loadProject, saveProjectAsXodball } = require('xod-fs'); // ============================================================================= const PROJECT_NAME = 'welcome-to-xod'; const XODBALL_FILE = `${PROJECT_NAME}.xodball`; const NO_SCREENSHOTS_FLAG = '--no-screenshots'; const GENERATED_FILE_COMMENT = ` `; // Map that will hold a map of comments sorted by X,Y // to autogenerate docs // :: Map PatchName String const comments = {}; // List of PatchPath that not empty. // Used in generating of // - chapter documentation to avoid pointing on nonexisting image // - screenshotter script to avoid attempt of screenshotting empty patch let nonEmptyPatchPaths = []; // ============================================================================= // Arguments parsing // ============================================================================= const argsWithoutOptional = R.reject( R.equals(NO_SCREENSHOTS_FLAG), process.argv ); if (argsWithoutOptional.length < 5) { console.log('Usage: '); console.log( './tools/generate-tutorial-docs.js 2.0.1 /path/to/project/welcome-to-xod/ /output/path/to/docs/tutorial-dir/' ); console.log('Optional flags: '); console.log( '--no-screenshots — to generate screenshotter script but do not execute it' ); process.exit(1); } const options = { noScreenshots: process.argv.indexOf(NO_SCREENSHOTS_FLAG) > -1, version: argsWithoutOptional[2], input: argsWithoutOptional[3], output: argsWithoutOptional[4], }; // ============================================================================= // Utilities // ============================================================================= // Promised `exec` const execP = (cmd, opts = {}) => new Promise((resolve, reject) => { exec(cmd, opts, (err, stdout, stderr) => { if (err) reject(err); resolve({ stdout, stderr }); }); }); // Returns H1 without `#` and trim spaces // :: String -> String const getH1 = R.compose( R.trim, R.replace(/^#\s+/g, ''), R.defaultTo(''), R.head, R.match(/^# (.+)$/gm) ); // :: PatchName -> String const getExerciseNumber = R.compose(R.head, R.match(/^(\d{3})/g)); // Lists patch directories const getProjectPatchDirs = projectPath => fs .readdir(projectPath) .then(R.reject(R.anyPass([R.equals('project.xod'), R.equals('.DS_Store')]))) .then(R.map(p => path.join(projectPath, p))); // Saves a multifile project as Xodball const saveAsXodball = source => { const output = path.resolve(options.output, XODBALL_FILE); const bundledWs = path.resolve(__dirname, '..', 'workspace'); return loadProject([bundledWs], source).then(saveProjectAsXodball(output)); }; // :: PatchPath -> Boolean const isIntroPart = R.test(/^\d00-/); // ============================================================================= // Project converters & comment extractors // ============================================================================= const extractCommentsFromPatch = xodpPath => fs .readFile(xodpPath) .then(str => JSON.parse(str)) .then( R.tap(content => { const patchName = path.basename(path.dirname(xodpPath)); comments[patchName] = R.compose( R.join('\n\n'), R.pluck('content'), R.sortWith([ R.ascend(R.path(['position', 'x'])), R.ascend(R.path(['position', 'y'])), ]), R.propOr([], 'comments') )(content); }) ) .then(R.omit(['comments'])) .then(content => JSON.stringify(content, null, 2)) .then(content => fs.writeFile(xodpPath, content)); const extractCommentsFromProject = projectPath => getProjectPatchDirs(projectPath) .then(R.map(p => path.join(p, 'patch.xodp'))) .then(xodpFiles => Promise.all(R.map(extractCommentsFromPatch, xodpFiles))); // ============================================================================= // Store non-empty patch paths into `nonEmptyPatchPaths` variable // ============================================================================= // :: [PatchName] -> Promise.Resolve [PatchName] const storeNonEmptyPatchPaths = () => fse.readJSON(path.join(options.output, XODBALL_FILE)).then( R.compose( _nonEmptyPatchPaths => { nonEmptyPatchPaths = _nonEmptyPatchPaths; }, arr => arr.sort(), R.map(R.replace('@/', '')), R.keys, R.reject( R.either( R.complement(R.has('nodes')), R.propSatisfies(R.isEmpty, 'nodes') ) ), R.prop('patches') ), err => { process.stderr.write(err); process.exit(1); } ); // ============================================================================= // Screenshotter command generator // ============================================================================= const formatScreenshotCommand = patchName => `"$SHOT" "$SRC" ${patchName} ./${patchName}/${patchName}.patch.png`; const generateScreenshotScript = () => fs.writeFile( path.join(options.output, 'update-screenshots.sh'), [ '#!/bin/sh', '', `SRC=${XODBALL_FILE}`, '', R.compose( R.join('\n\n'), R.map(formatScreenshotCommand), R.reject(isIntroPart) )(nonEmptyPatchPaths), ].join('\n') ); // ============================================================================= // Tutorial content generators // ============================================================================= // :: Nullable String -> Nullable String -> String const generatePaginator = (prev, next) => `
`; const generateTutorials = projectPath => getProjectPatchDirs(projectPath).then(patchPaths => Promise.all( R.map(async patchPath => { const patchName = path.basename(patchPath); const patchContent = comments[patchName]; if (!patchContent) return Promise.resolve(); // For paginator const lessonsOrdered = R.compose( R.pluck(0), R.sortBy(R.head), R.toPairs )(comments); const curLessonIndex = R.indexOf(patchName, lessonsOrdered); const prevLesson = curLessonIndex > 0 ? lessonsOrdered[curLessonIndex - 1] : null; const nextLesson = curLessonIndex < lessonsOrdered.length - 1 ? lessonsOrdered[curLessonIndex + 1] : null; // Directory for the patch documentation const patchDocDir = path.join(options.output, patchName); // Image const h1 = getH1(patchContent); const img = isIntroPart(patchName) || nonEmptyPatchPaths.indexOf(patchName) === -1 ? '' : ``; const imgPos = patchContent.indexOf(h1) + h1.length; // H2 const h2 = R.match(/^##\s.+$/gm, patchContent); const h2PosI = patchContent.indexOf(h2[0]); const h2Pos = h2PosI > -1 ? h2PosI : patchContent.length; // Content parts const beforeImg = patchContent.slice(0, imgPos); const introduction = patchContent.slice(imgPos, h2Pos); const restOfContent = patchContent.slice(h2Pos); const before1stH2 = await fs .readFile(path.join(patchDocDir, 'before-1st-h2.md')) .catch(() => ''); // Write! await fse.ensureDir(patchDocDir); await fs.writeFile( path.join(patchDocDir, 'README.md'), `--- title: ${h1} version: ${options.version} --- ${GENERATED_FILE_COMMENT}