rdme
Version:
ReadMe's official CLI and GitHub Action.
149 lines (148 loc) • 6.51 kB
JavaScript
import fs from 'node:fs/promises';
import path from 'node:path';
import chalk from 'chalk';
import toposort from 'toposort';
import { APIv1Error } from './apiError.js';
import readdirRecursive from './readdirRecursive.js';
import { cleanAPIv1Headers, handleAPIv1Res, readmeAPIv1Fetch } from './readmeAPIFetch.js';
import { readPage } from './readPage.js';
/**
* Reads the contents of the specified Markdown or HTML file
* and creates/updates the corresponding doc in ReadMe
*
* @returns A promise-wrapped string with the result
*/
async function pushDoc(
/** the project version */
selectedVersion, fileData) {
const type = this.id;
const { key, dryRun } = this.flags;
const { content, data, filePath, hash, slug } = fileData;
// TODO: ideally we should offer a zero-configuration approach that doesn't
// require YAML frontmatter, but that will have to be a breaking change
if (!Object.keys(data).length) {
this.debug(`No frontmatter attributes found for ${filePath}, not syncing`);
return `⏭️ no frontmatter attributes found for ${filePath}, skipping`;
}
const payload = { body: content, ...data, lastUpdatedHash: hash };
function createDoc() {
if (dryRun) {
return `🎭 dry run! This will create '${slug}' with contents from ${filePath} with the following metadata: ${JSON.stringify(data)}`;
}
return readmeAPIv1Fetch(`/api/v1/${type}`, {
method: 'post',
headers: cleanAPIv1Headers(key, selectedVersion, new Headers({ 'Content-Type': 'application/json' })),
body: JSON.stringify({
slug,
...payload,
}),
}, { filePath, fileType: 'path' })
.then(handleAPIv1Res)
.then(res => `🌱 successfully created '${res.slug}' (ID: ${res._id}) with contents from ${filePath}`);
}
function updateDoc(existingDoc) {
if (hash === existingDoc.lastUpdatedHash) {
return `${dryRun ? '🎭 dry run! ' : ''}\`${slug}\` ${dryRun ? 'will not be' : 'was not'} updated because there were no changes.`;
}
if (dryRun) {
return `🎭 dry run! This will update '${slug}' with contents from ${filePath} with the following metadata: ${JSON.stringify(data)}`;
}
return readmeAPIv1Fetch(`/api/v1/${type}/${slug}`, {
method: 'put',
headers: cleanAPIv1Headers(key, selectedVersion, new Headers({ 'Content-Type': 'application/json' })),
body: JSON.stringify(payload),
}, { filePath, fileType: 'path' })
.then(handleAPIv1Res)
.then(res => `✏️ successfully updated '${res.slug}' with contents from ${filePath}`);
}
return readmeAPIv1Fetch(`/api/v1/${type}/${slug}`, {
method: 'get',
headers: cleanAPIv1Headers(key, selectedVersion, new Headers({ Accept: 'application/json' })),
})
.then(async (res) => {
const body = await handleAPIv1Res(res, false);
if (!res.ok) {
if (res.status !== 404)
return Promise.reject(new APIv1Error(body));
this.debug(`error retrieving data for ${slug}, creating doc`);
return createDoc();
}
this.debug(`data received for ${slug}, updating doc`);
return updateDoc(body);
})
.catch(err => {
err.message = `Error uploading ${chalk.underline(filePath)}:\n\n${err.message}`;
throw err;
});
}
const byParentDoc = (left, right) => {
return (right.data.parentDoc ? 1 : 0) - (left.data.parentDoc ? 1 : 0);
};
/**
* Sorts files based on their parentDoc attribute. If a file has a parentDoc attribute,
* it will be sorted after the file it references.
*
* @see {@link https://github.com/readmeio/rdme/pull/973}
* @returns An array of sorted PageMetadata objects
*/
function sortFiles(filePaths) {
const files = filePaths.map(file => readPage.call(this, file)).sort(byParentDoc);
const filesBySlug = files.reduce((bySlug, obj) => {
bySlug[obj.slug] = obj;
return bySlug;
}, {});
const dependencies = Object.values(filesBySlug).reduce((edges, obj) => {
if (obj.data.parentDocSlug && filesBySlug[obj.data.parentDocSlug]) {
edges.push([filesBySlug[obj.data.parentDocSlug], filesBySlug[obj.slug]]);
}
return edges;
}, []);
return toposort.array(files, dependencies);
}
/**
* Takes a path (either to a directory of files or to a single file)
* and syncs those (either via POST or PUT) to ReadMe.
* @returns A promise-wrapped string with the results
*
* @deprecated This is for APIv1 only. Use `syncDocsPath.ts` instead, if possible.
*/
export default async function syncDocsPath(
/** ReadMe project version */
selectedVersion) {
const { path: pathInput } = this.args;
const allowedFileExtensions = ['.markdown', '.md'];
const stat = await fs.stat(pathInput).catch(err => {
if (err.code === 'ENOENT') {
throw new Error("Oops! We couldn't locate a file or directory at the path you provided.");
}
throw err;
});
let output;
if (stat.isDirectory()) {
// Filter out any files that don't match allowedFileExtensions
const files = readdirRecursive(pathInput).filter(file => allowedFileExtensions.includes(path.extname(file).toLowerCase()));
this.debug(`number of files: ${files.length}`);
if (!files.length) {
return Promise.reject(new Error(`The directory you provided (${pathInput}) doesn't contain any of the following required files: ${allowedFileExtensions.join(', ')}.`));
}
let sortedFiles;
try {
sortedFiles = sortFiles.call(this, files);
}
catch (e) {
return Promise.reject(e);
}
output = (await Promise.all(sortedFiles.map(async (fileData) => {
return pushDoc.call(this, selectedVersion, fileData);
}))).join('\n');
}
else {
const fileExtension = path.extname(pathInput).toLowerCase();
if (!allowedFileExtensions.includes(fileExtension)) {
return Promise.reject(new Error(`Invalid file extension (${fileExtension}). Must be one of the following: ${allowedFileExtensions.join(', ')}`));
}
const fileData = readPage.call(this, pathInput);
output = await pushDoc.call(this, selectedVersion, fileData);
}
return Promise.resolve(chalk.green(output));
}