Files
xod/tools/generate-tutorial-docs.js

397 lines
12 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! /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 = `
<!--
This file is auto-generated from the 'welcome-to-xod' project.
Do not change this file manually because your changes may be lost after
the tutorial update.
To make changes, change the 'welcome-to-xod' contents or 'before-1st-h2.md'.
If you want to change a Fritzing scheme or comments for it, change the
'before-1st-h2.md' in the documentation directory for the patch.
Then run auto-generator tool (xod/tools/generate-tutorial-docs.js).
-->
`;
// 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) => `
<div class="ui grid">
<div class="five wide column left aligned ">
${prev ? `<a href="../${prev}/">← Previous lesson</a>` : ''}
</div>
<div class="six wide column center aligned ">
<a href="../">Index</a>
</div>
<div class="five wide column right aligned ">
${next ? `<a href="../${next}/">Next lesson →</a>` : ''}
</div>
</div>
`;
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
? ''
: `![Screenshot of ${patchName}](./${patchName}.patch.png)`;
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}
<div class="ui segment note">
<span class="ui ribbon label">Note</span>
This is a web-version of a tutorial chapter embedded right into the XOD IDE.
To get a better learning experience we recommend to install the
<a href="/downloads/">desktop IDE</a> or start the
<a href="/ide/">browser-based IDE</a>, and youll see the same tutorial there.
</div>
${beforeImg}
${img}
${introduction}
${before1stH2}
${restOfContent}
${generatePaginator(prevLesson, nextLesson)}
`
);
return 1;
}, patchPaths)
)
);
const generateRootIndex = () => {
const patchIndex = R.compose(
R.join('\n'),
R.map(
R.compose(
sectionContent => `<ol class="ui list">${sectionContent}</ol>`,
R.join('\n'),
R.map(([patchName, content]) =>
[
`<li value="${getExerciseNumber(patchName)}">`,
` <a href="./${patchName}/">${getH1(content)}</a>`,
'</li>',
].join('\n')
)
)
),
R.values,
R.groupBy(R.pipe(R.head, R.head)), // Group by first symbol in PatchName
R.sortBy(R.head),
R.toPairs
)(comments);
return fs.writeFile(
path.join(options.output, 'README.md'),
`---
title: Tutorial
version: ${options.version}
---
${GENERATED_FILE_COMMENT}
# Tutorial
- [Installing and running XOD](./install/)
- [Required hardware](./required-hardware/)
${patchIndex}
- [Complex projects?](./complex-projects/)
`
);
};
// =============================================================================
// RUN
// =============================================================================
fs.mkdtemp(path.join(os.tmpdir(), 'tutorial-docs-')).then(tmpDir => {
const CONVERTED_PROJECT = path.join(tmpDir, `${PROJECT_NAME}.w`);
return fse
.copy(options.input, CONVERTED_PROJECT)
.then(() => extractCommentsFromProject(CONVERTED_PROJECT))
.then(() => fse.ensureDir(options.output))
.then(() => saveAsXodball(CONVERTED_PROJECT))
.then(() => storeNonEmptyPatchPaths())
.then(() => generateScreenshotScript())
.then(() => generateTutorials(CONVERTED_PROJECT))
.then(() => generateRootIndex())
.then(() =>
execP(`chmod 777 ${path.join(options.output, 'update-screenshots.sh')}`)
)
.then(() => {
if (options.noScreenshots) return 1;
return execP(path.join(__dirname, 'update-docs-screenshots.sh'), {
cwd: options.output,
});
})
.catch(err => {
console.error(err);
})
.then(() => fse.remove(tmpDir));
});