@log4brains/core
Version:
Log4brains architecture knowledge base core API
1,963 lines (1,541 loc) • 55.9 kB
JavaScript
import 'core-js/features/array/flat';
import { createContainer, InjectionMode, asValue, asClass, asFunction } from 'awilix';
import moment$1 from 'moment-timezone';
import isEqual from 'lodash/isEqual';
import moment from 'moment';
import slugify from 'slugify';
import path from 'path';
import cheerio from 'cheerio';
import MarkdownIt from 'markdown-it';
import fs, { promises } from 'fs';
import simpleGit from 'simple-git';
import chokidar from 'chokidar';
import open from 'open';
import launchEditor from 'launch-editor';
import yaml from 'yaml';
import Joi from 'joi';
import gitUrlParse from 'git-url-parse';
import parseGitConfig from 'parse-git-config';
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
class Entity {
constructor(props) {
this.props = props;
}
equals(e) {
return e === this; // One instance allowed per entity
}
}
class AggregateRoot extends Entity {}
/**
* Log4brains Error base class.
* Any error thrown by the core API extends this class.
*/
class Log4brainsError extends Error {
constructor(name, details) {
super(`${name}${details ? ` (${details})` : ""}`);
this.name = name;
this.details = details;
}
}
/**
* @desc ValueObjects are objects that we determine their
* equality through their structural property.
*/
class ValueObject {
constructor(props) {
this.props = Object.freeze(props);
}
equals(vo) {
if (vo === null || vo === undefined) {
return false;
}
if (vo.constructor.name !== this.constructor.name) {
return false;
}
if (vo.props === undefined) {
return false;
}
return isEqual(this.props, vo.props);
}
}
class ValueObjectArray {
static inArray(object, array) {
return array.some(o => o.equals(object));
}
}
class AdrSlug extends ValueObject {
constructor(value) {
super({
value
});
if (this.namePart.includes("/")) {
throw new Log4brainsError("The / character is not allowed in the name part of an ADR slug", value);
}
}
get value() {
return this.props.value;
}
get packagePart() {
const s = this.value.split("/", 2);
return s.length >= 2 ? s[0] : undefined;
}
get namePart() {
const s = this.value.split("/", 2);
return s.length >= 2 ? s[1] : s[0];
}
static createFromFile(file, packageRef) {
const localSlug = file.path.basenameWithoutExtension;
return new AdrSlug(packageRef ? `${packageRef.name}/${localSlug}` : localSlug);
}
static createFromTitle(title, packageRef, date) {
const slugifiedTitle = slugify(title, {
lower: true,
strict: true
}).replace(/-*$/, "");
const localSlug = `${moment(date).format("YYYYMMDD")}-${slugifiedTitle}`;
return new AdrSlug(packageRef ? `${packageRef.name}/${localSlug}` : localSlug);
}
}
class AdrStatus extends ValueObject {
constructor(name) {
super({
name
});
}
get name() {
return this.props.name;
}
static createFromName(name) {
if (name.toLowerCase().startsWith("superseded by")) {
return this.SUPERSEDED;
} // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const status = Object.values(AdrStatus).filter(prop => {
return prop instanceof AdrStatus && prop.name === name.toLowerCase();
}).pop();
if (!status) {
throw new Log4brainsError("Unknown ADR status", name);
}
return status;
}
}
AdrStatus.DRAFT = new AdrStatus("draft");
AdrStatus.PROPOSED = new AdrStatus("proposed");
AdrStatus.REJECTED = new AdrStatus("rejected");
AdrStatus.ACCEPTED = new AdrStatus("accepted");
AdrStatus.DEPRECATED = new AdrStatus("deprecated");
AdrStatus.SUPERSEDED = new AdrStatus("superseded");
class MarkdownAdrLink extends ValueObject {
constructor(from, to) {
super({
from,
to
});
}
get from() {
return this.props.from;
}
get to() {
return this.props.to;
}
toMarkdown() {
if (!this.from.file || !this.to.file) {
throw new Log4brainsError("Impossible to create a link between two unsaved ADRs", `${this.from.slug.value} -> ${this.to.slug.value}`);
}
const relativePath = this.from.file.path.relative(this.to.file.path);
return `[${this.to.slug.value}](${relativePath})`;
}
}
class AdrRelation extends ValueObject {
constructor(from, relation, to) {
super({
from,
relation,
to
});
}
get from() {
return this.props.from;
}
get relation() {
return this.props.relation;
}
get to() {
return this.props.to;
}
toMarkdown() {
const link = new MarkdownAdrLink(this.from, this.to);
return `${this.relation} ${link.toMarkdown()}`;
}
}
class Author extends ValueObject {
constructor(name, email) {
super({
name,
email
});
}
get name() {
return this.props.name;
}
get email() {
return this.props.email;
}
static createAnonymous() {
return new Author("Anonymous");
}
}
const dateFormats = ["YYYY-MM-DD", "DD/MM/YYYY"];
class Adr extends AggregateRoot {
constructor(props) {
super(_extends({
creationDate: props.creationDate || new Date(),
lastEditDate: props.lastEditDate || new Date(),
lastEditAuthor: props.lastEditAuthor || Author.createAnonymous()
}, props));
}
/**
* @see Adr.tz
*/
static setTz(tz) {
if (!moment$1.tz.zone(tz)) {
throw new Log4brainsError("Unknown timezone", Adr.tz);
}
Adr.tz = tz;
}
/**
* For test purposes only
*/
static clearTz() {
Adr.tz = undefined;
}
get slug() {
return this.props.slug;
}
get package() {
return this.props.package;
}
get body() {
return this.props.body;
}
get file() {
return this.props.file;
}
get creationDate() {
return this.props.creationDate;
}
get lastEditDate() {
return this.props.lastEditDate;
}
get lastEditAuthor() {
return this.props.lastEditAuthor;
}
get title() {
return this.body.getFirstH1Title(); // TODO: log when no title
}
get status() {
const statusStr = this.body.getHeaderMetadata("Status");
if (!statusStr) {
return AdrStatus.ACCEPTED;
}
try {
return AdrStatus.createFromName(statusStr);
} catch (e) {
return AdrStatus.DRAFT; // TODO: log (DRAFT because usually the help from the template)
}
}
get superseder() {
const statusStr = this.body.getHeaderMetadata("Status");
if (!this.status.equals(AdrStatus.SUPERSEDED) || !statusStr) {
return undefined;
}
const slug = statusStr.replace(/superseded\s*by\s*:?/i, "").trim();
try {
return slug ? new AdrSlug(slug) : undefined;
} catch (e) {
return undefined; // TODO: log
}
}
get publicationDate() {
if (!Adr.tz) {
throw new Log4brainsError("Adr.setTz() must be called at startup!");
}
const dateStr = this.body.getHeaderMetadata("date");
if (!dateStr) {
return undefined;
} // We set hours on 23:59:59 local time for sorting reasons:
// Because an ADR without a publication date is sorted based on its creationDate.
// And usually, ADRs created on the same publicationDate of another ADR are older than this one.
// This enables us to have a consistent behavior in sorting.
const date = moment$1.tz(`${dateStr} 23:59:59`, dateFormats.map(format => `${format} HH:mm:ss`), true, Adr.tz);
if (!date.isValid()) {
return undefined; // TODO: warning
}
return date.toDate();
}
get tags() {
const tags = this.body.getHeaderMetadata("tags");
if (!tags || tags.trim() === "" || tags === "[space and/or comma separated list of tags] <!-- optional -->") {
return [];
}
return tags.split(/\s*[\s,]{1}\s*/).map(tag => tag.trim().toLowerCase());
}
get deciders() {
const deciders = this.body.getHeaderMetadata("deciders");
if (!deciders || deciders.trim() === "" || deciders === "[list everyone involved in the decision] <!-- optional -->") {
return [];
}
return deciders.split(/\s*[,]{1}\s*/).map(decider => decider.trim());
}
setFile(file) {
this.props.file = file;
}
setTitle(title) {
this.body.setFirstH1Title(title);
}
supersedeBy(superseder) {
const relation = new AdrRelation(this, "superseded by", superseder);
this.body.setHeaderMetadata("Status", relation.toMarkdown());
superseder.markAsSuperseder(this);
}
markAsSuperseder(superseded) {
const relation = new AdrRelation(this, "Supersedes", superseded);
this.body.addLinkNoDuplicate(relation.toMarkdown());
}
async getEnhancedMdx() {
const bodyCopy = this.body.clone(); // Remove title
bodyCopy.deleteFirstH1Title(); // Remove header metadata
["status", "deciders", "date", "tags"].forEach(metadata => bodyCopy.deleteHeaderMetadata(metadata)); // Replace links
await bodyCopy.replaceAdrLinks(this);
return bodyCopy.getRawMarkdown();
}
static compare(a, b) {
// PublicationDate always wins on creationDate
const aDate = a.publicationDate || a.creationDate;
const bDate = b.publicationDate || b.creationDate;
const dateDiff = aDate.getTime() - bDate.getTime();
if (dateDiff !== 0) {
return dateDiff;
} // When the dates are equal, we compare the slugs' name parts
const aSlugNamePart = a.slug.namePart.toLowerCase();
const bSlugNamePart = b.slug.namePart.toLowerCase();
if (aSlugNamePart === bSlugNamePart) {
// Special case: when the name parts are equal, we take the package name into account
// This case is very rare but we have to take it into account so that the results are not random
return a.slug.value.toLowerCase() < b.slug.value.toLowerCase() ? -1 : 1;
}
return aSlugNamePart < bSlugNamePart ? -1 : 1;
}
}
const reservedFilenames = ["template.md", "readme.md", "index.md", "backlog.md"];
class AdrFile extends ValueObject {
constructor(path) {
super({
path
});
if (path.extension.toLowerCase() !== ".md") {
throw new Log4brainsError("Only .md files are supported", path.pathRelativeToCwd);
}
if (reservedFilenames.includes(path.basename.toLowerCase())) {
throw new Log4brainsError("Reserved ADR filename", path.basename);
}
}
get path() {
return this.props.path;
}
static isPathValid(path) {
try {
// eslint-disable-next-line no-new
new AdrFile(path);
return true;
} catch (e) {
return false;
}
}
static createFromSlugInFolder(folder, slug) {
return new AdrFile(folder.join(`${slug.namePart}.md`));
}
}
class PackageRef extends ValueObject {
constructor(name) {
super({
name
});
}
get name() {
return this.props.name;
}
}
class AdrTemplate extends AggregateRoot {
get package() {
return this.props.package;
}
get body() {
return this.props.body;
}
createAdrFromMe(slug, title) {
const packageRef = slug.packagePart ? new PackageRef(slug.packagePart) : undefined;
if (!this.package && packageRef || this.package && !this.package.equals(packageRef)) {
var _this$package;
throw new Log4brainsError("The given slug does not match this template package name", `slug: ${slug.value} / template package: ${(_this$package = this.package) == null ? void 0 : _this$package.name}`);
}
const adr = new Adr({
slug,
package: packageRef,
body: this.body.clone()
});
adr.setTitle(title);
return adr;
}
}
function forceUnixPath(p) {
return p.replace(/\\/g, "/");
}
class FilesystemPath extends ValueObject {
constructor(cwdAbsolutePath, pathRelativeToCwd) {
super({
cwdAbsolutePath: forceUnixPath(cwdAbsolutePath),
pathRelativeToCwd: forceUnixPath(pathRelativeToCwd)
});
if (!path.isAbsolute(cwdAbsolutePath)) {
throw new Log4brainsError("CWD path is not absolute", cwdAbsolutePath);
}
}
get cwdAbsolutePath() {
return this.props.cwdAbsolutePath;
}
get pathRelativeToCwd() {
return this.props.pathRelativeToCwd;
}
get absolutePath() {
return forceUnixPath(path.join(this.props.cwdAbsolutePath, this.pathRelativeToCwd));
}
get basename() {
return forceUnixPath(path.basename(this.pathRelativeToCwd));
}
get extension() {
// with the dot (.)
return path.extname(this.pathRelativeToCwd);
}
get basenameWithoutExtension() {
if (!this.extension) {
return this.basename;
}
return this.basename.substring(0, this.basename.length - this.extension.length);
}
join(p) {
return new FilesystemPath(this.cwdAbsolutePath, path.join(this.pathRelativeToCwd, p));
}
relative(to, amIaDirectory = false) {
const from = amIaDirectory ? this.absolutePath : path.dirname(this.absolutePath);
return forceUnixPath(path.relative(from, to.absolutePath));
}
equals(vo) {
// We redefine ValueObject's equals() method to test only the computed absolutePath
// because in some the pathRelativeToCwd can be different but targets the same location
if (vo === null || vo === undefined || !(vo instanceof FilesystemPath)) {
return false;
}
return this.absolutePath === vo.absolutePath;
}
}
// Source: https://github.com/tylingsoft/markdown-it-source-map
// Thanks! ;)
// Had to fork it to add additional information
function markdownItSourceMap(md) {
const defaultRenderToken = md.renderer.renderToken.bind(md.renderer);
md.renderer.renderToken = function (tokens, idx, options) {
const token = tokens[idx];
if (token.type.endsWith("_open")) {
if (token.map) {
token.attrPush(["data-source-line-start", token.map[0].toString()]);
token.attrPush(["data-source-line-end", token.map[1].toString()]);
}
if (token.markup !== undefined) {
token.attrPush(["data-source-markup", token.markup]);
}
if (token.level !== undefined) {
token.attrPush(["data-source-level", token.level.toString()]);
}
}
return defaultRenderToken(tokens, idx, options);
};
}
class CheerioMarkdownElement {
constructor(cheerioElt) {
this.cheerioElt = cheerioElt;
}
get startLine() {
const data = this.cheerioElt.data("sourceLineStart");
return data !== undefined ? parseInt(data, 10) : undefined;
}
get endLine() {
const data = this.cheerioElt.data("sourceLineEnd");
return data !== undefined ? parseInt(data, 10) : undefined;
}
get markup() {
const data = this.cheerioElt.data("sourceMarkup");
return data !== undefined ? data : undefined;
}
get level() {
const data = this.cheerioElt.data("sourceLevel");
return data !== undefined ? parseInt(data, 10) : undefined;
}
}
function cheerioToMarkdown(elt, keepLinks = true) {
const html = elt.html();
if (!html) {
return "";
}
const copy = cheerio.load(html);
if (keepLinks) {
copy("a").each((i, linkElt) => {
copy(linkElt).text(`[${copy(linkElt).text()}](${copy(linkElt).attr("href")})`);
});
}
return copy("body").text();
}
const markdownItInstance = new MarkdownIt();
markdownItInstance.use(markdownItSourceMap);
function isWindowsLine(line) {
return line.endsWith(`\r\n`) || line.endsWith(`\r`);
}
class CheerioMarkdown {
constructor($markdown) {
this.$markdown = $markdown;
this.observers = [];
this.updateMarkdown($markdown);
}
get markdown() {
return this.$markdown;
}
get nbLines() {
return this.markdown.split(`\n`).length;
}
onChange(cb) {
this.observers.push(cb);
}
updateMarkdown(markdown) {
this.$markdown = markdown;
this.$ = cheerio.load(markdownItInstance.render(this.markdown));
this.observers.forEach(observer => observer(this.markdown));
}
getLine(i) {
const lines = this.markdown.split(/\r?\n/);
if (lines[i] === undefined) {
throw new Error(`Unknown line ${i}`);
}
return lines[i];
}
replaceText(elt, newText) {
const mdElt = new CheerioMarkdownElement(elt);
if (mdElt.startLine === undefined || mdElt.endLine === undefined) {
throw new Error("Cannot source-map this element from Markdown");
}
for (let i = mdElt.startLine; i < mdElt.endLine; i += 1) {
const newLine = this.getLine(mdElt.startLine).replace(cheerioToMarkdown(elt), newText);
this.replaceLine(mdElt.startLine, newLine);
}
}
deleteElement(elt) {
const mdElt = new CheerioMarkdownElement(elt);
if (mdElt.startLine === undefined || mdElt.endLine === undefined) {
throw new Error("Cannot source-map this element from Markdown");
}
this.deleteLines(mdElt.startLine, mdElt.endLine - 1);
}
replaceLine(i, newLine) {
const lines = this.markdown.split(`\n`);
if (lines[i] === undefined) {
throw new Error(`Unknown line ${i}`);
}
lines[i] = `${newLine}${isWindowsLine(lines[i]) ? `\r` : ""}`;
this.updateMarkdown(lines.join(`\n`));
}
deleteLines(start, end) {
const lines = this.markdown.split(`\n`);
if (lines[start] === undefined) {
throw new Error(`Unknown line ${start}`);
}
const length = end ? end - start + 1 : 1;
lines.splice(start, length);
this.updateMarkdown(lines.join(`\n`));
}
insertLineAt(i, newLine) {
const lines = this.markdown.split(`\n`);
if (lines.length === 0) {
lines.push(`\n`);
}
if (lines[i] === undefined) {
throw new Error(`Unknown line ${i}`);
}
lines.splice(i, 0, `${newLine}${isWindowsLine(lines[i]) ? `\r` : ""}`);
this.updateMarkdown(lines.join(`\n`));
}
insertLineAfter(elt, newLine) {
const mdElt = new CheerioMarkdownElement(elt);
if (mdElt.endLine === undefined) {
throw new Error("Cannot source-map this element from Markdown");
}
const end = elt.is("ul") ? mdElt.endLine - 1 : mdElt.endLine;
this.insertLineAt(end, newLine);
}
appendLine(newLine) {
const lines = this.markdown.split(`\n`);
const windowsLines = lines.length > 0 ? isWindowsLine(lines[0]) : false;
if (lines[lines.length - 1].trim() === "") {
delete lines[lines.length - 1];
}
lines.push(`${newLine}${windowsLines ? `\r` : ""}`);
lines.push(`${windowsLines ? `\r` : ""}\n`);
this.updateMarkdown(lines.join(`\n`));
}
appendToList(ul, newItem) {
if (!ul.is("ul")) {
throw new TypeError("Given element is not a <ul>");
}
const mdElt = new CheerioMarkdownElement(ul);
if (mdElt.markup === undefined || mdElt.level === undefined) {
throw new Error("Cannot source-map this element from Markdown");
}
if (mdElt.level > 0) {
throw new Error("Sub-lists are not implemented yet");
}
const newLine = `${mdElt.markup} ${newItem}`;
this.insertLineAfter(ul, newLine);
}
}
function htmlentities(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
class MarkdownBody extends Entity {
constructor(value) {
super({
value
});
this.cm = new CheerioMarkdown(value);
this.cm.onChange(newValue => {
this.props.value = newValue;
});
}
setAdrLinkResolver(resolver) {
this.adrLinkResolver = resolver;
return this;
}
getFirstH1TitleElement() {
const elt = this.cm.$("h1").first();
return elt.length > 0 ? elt : undefined;
}
getFirstH1Title() {
var _this$getFirstH1Title;
return (_this$getFirstH1Title = this.getFirstH1TitleElement()) == null ? void 0 : _this$getFirstH1Title.text();
}
setFirstH1Title(title) {
const elt = this.getFirstH1TitleElement();
if (elt) {
this.cm.replaceText(elt, title);
} else {
this.cm.insertLineAt(0, `# ${title}`);
}
}
deleteFirstH1Title() {
const elt = this.getFirstH1TitleElement();
if (elt) {
this.cm.deleteElement(elt);
}
}
getHeaderMetadataUl() {
const elts = this.cm.$("body > *:first-child").nextUntil("h2").addBack();
const ul = elts.filter("ul").first();
return ul.length > 0 ? ul : undefined;
}
getHeaderMetadataElementAndMatch(key) {
var _result$;
const ul = this.getHeaderMetadataUl();
if (!ul) {
return undefined;
}
const regexp = new RegExp(`^(\\s*${key}\\s*:\\s*)(.*)$`, "i");
const result = ul.children().map((i, li) => {
const line = this.cm.$(li);
const match = regexp.exec(line.text());
return match ? {
element: this.cm.$(li),
match
} : undefined;
}).get();
return (_result$ = result[0]) != null ? _result$ : undefined;
}
getHeaderMetadata(key) {
var _this$getHeaderMetada;
return (_this$getHeaderMetada = this.getHeaderMetadataElementAndMatch(key)) == null ? void 0 : _this$getHeaderMetada.match[2].trim();
}
setHeaderMetadata(key, value) {
const res = this.getHeaderMetadataElementAndMatch(key);
if (res) {
this.cm.replaceText(res.element, `${res.match[1]}${value}`);
} else {
const ul = this.getHeaderMetadataUl();
if (ul) {
this.cm.appendToList(ul, `${key}: ${value}`);
} else {
const h1TitleElt = this.getFirstH1TitleElement();
if (h1TitleElt) {
this.cm.insertLineAfter(h1TitleElt, `\n- ${key}: ${value}\n`);
} else {
this.cm.insertLineAt(0, `- ${key}: ${value}`);
}
}
}
}
deleteHeaderMetadata(key) {
// TODO: fix bug: when the last item is deleted, it deletes also the next new line.
// As a result, it is not detected as a list anymore.
const res = this.getHeaderMetadataElementAndMatch(key);
if (res) {
this.cm.deleteElement(res.element);
}
}
getLinksUl() {
const h2Results = this.cm.$("h2").filter((i, elt) => this.cm.$(elt).text().toLowerCase().replace(/<!--.*-->/, "").trim() === "links");
if (h2Results.length === 0) {
return undefined;
}
const h2 = h2Results[0];
const elts = this.cm.$(h2).nextUntil("h2");
const ul = elts.filter("ul").first();
return ul.length > 0 ? ul : undefined;
}
getLinks() {
const ul = this.getLinksUl();
if (!ul) {
return undefined;
}
return ul.children().map((i, li) => cheerioToMarkdown(this.cm.$(li))).get();
}
addLink(link) {
const ul = this.getLinksUl();
if (ul === undefined) {
this.cm.appendLine(`\n## Links\n\n- ${link}`);
} else {
this.cm.appendToList(ul, link);
}
}
addLinkNoDuplicate(link) {
const links = this.getLinks();
if (links && links.map(l => l.toLowerCase().trim()).filter(l => l === link.toLowerCase().trim()).length > 0) {
return;
}
this.addLink(link);
}
getRawMarkdown() {
return this.props.value;
}
clone() {
const copy = new MarkdownBody(this.props.value);
if (this.adrLinkResolver) {
copy.setAdrLinkResolver(this.adrLinkResolver);
}
return copy;
}
async replaceAdrLinks(from) {
const links = this.cm.$("a").map((_, element) => ({
text: this.cm.$(element).text(),
href: this.cm.$(element).attr("href")
})).get();
const isUrlRegexp = new RegExp(/^https?:\/\//i);
const promises = links.filter(link => !isUrlRegexp.exec(link.href)).filter(link => link.href.toLowerCase().endsWith(".md")).map(link => (async () => {
if (!this.adrLinkResolver) {
throw new Log4brainsError("Impossible to call replaceAdrLinks() without an MarkdownAdrLinkResolver");
}
const mdAdrLink = await this.adrLinkResolver.resolve(from, link.href);
if (mdAdrLink) {
const params = [`slug="${htmlentities(mdAdrLink.to.slug.value)}"`, `status="${mdAdrLink.to.status.name}"`];
if (mdAdrLink.to.title) {
params.push(`title="${htmlentities(mdAdrLink.to.title)}"`);
}
if (mdAdrLink.to.package) {
params.push(`package="${htmlentities(mdAdrLink.to.package.name)}"`);
}
if (![mdAdrLink.to.slug.value.toLowerCase(), mdAdrLink.to.slug.namePart.toLowerCase()].includes(link.text.toLowerCase().trim())) {
params.push(`customLabel="${htmlentities(link.text)}"`);
}
this.cm.updateMarkdown(this.cm.markdown.replace(`[${link.text}](${link.href})`, `<AdrLink ${params.join(" ")} />`));
}
})());
await Promise.all(promises);
}
}
class Package extends Entity {
get ref() {
return this.props.ref;
}
get path() {
return this.props.path;
}
get adrFolderPath() {
return this.props.adrFolderPath;
}
}
class Command {}
class Query {}
class CreateAdrFromTemplateCommand extends Command {
constructor(slug, title) {
super();
this.slug = slug;
this.title = title;
}
}
class SupersedeAdrCommand extends Command {
constructor(supersededSlug, supersederSlug) {
super();
this.supersededSlug = supersededSlug;
this.supersederSlug = supersederSlug;
}
}
class CreateAdrFromTemplateCommandHandler {
constructor({
adrRepository,
adrTemplateRepository
}) {
this.commandClass = CreateAdrFromTemplateCommand;
this.adrRepository = adrRepository;
this.adrTemplateRepository = adrTemplateRepository;
}
async execute(command) {
const packageRef = command.slug.packagePart ? new PackageRef(command.slug.packagePart) : undefined;
const template = await this.adrTemplateRepository.find(packageRef);
const adr = template.createAdrFromMe(command.slug, command.title);
await this.adrRepository.save(adr);
}
}
class SupersedeAdrCommandHandler {
constructor({
adrRepository
}) {
this.commandClass = SupersedeAdrCommand;
this.adrRepository = adrRepository;
}
async execute(command) {
const supersededAdr = await this.adrRepository.find(command.supersededSlug);
const supersederAdr = await this.adrRepository.find(command.supersederSlug);
supersededAdr.supersedeBy(supersederAdr);
await this.adrRepository.save(supersededAdr);
await this.adrRepository.save(supersederAdr);
}
}
var adrCommandHandlers = {
__proto__: null,
CreateAdrFromTemplateCommandHandler: CreateAdrFromTemplateCommandHandler,
SupersedeAdrCommandHandler: SupersedeAdrCommandHandler
};
class GenerateAdrSlugFromTitleQuery extends Query {
constructor(title, packageRef) {
super();
this.title = title;
this.packageRef = packageRef;
}
}
class GetAdrBySlugQuery extends Query {
constructor(slug) {
super();
this.slug = slug;
}
}
class SearchAdrsQuery extends Query {
constructor(filters) {
super();
this.filters = filters;
}
}
class GenerateAdrSlugFromTitleQueryHandler {
constructor({
adrRepository
}) {
this.queryClass = GenerateAdrSlugFromTitleQuery;
this.adrRepository = adrRepository;
}
execute(query) {
return Promise.resolve(this.adrRepository.generateAvailableSlug(query.title, query.packageRef));
}
}
class GetAdrBySlugQueryHandler {
constructor({
adrRepository
}) {
this.queryClass = GetAdrBySlugQuery;
this.adrRepository = adrRepository;
}
async execute(query) {
try {
return await this.adrRepository.find(query.slug);
} catch (e) {
if (!(e instanceof Log4brainsError && e.name === "This ADR does not exist")) {
throw e;
}
}
return undefined;
}
}
class SearchAdrsQueryHandler {
constructor({
adrRepository
}) {
this.queryClass = SearchAdrsQuery;
this.adrRepository = adrRepository;
}
async execute(query) {
return (await this.adrRepository.findAll()).filter(adr => {
if (query.filters.statuses && !ValueObjectArray.inArray(adr.status, query.filters.statuses)) {
return false;
}
return true;
});
}
}
var adrQueryHandlers = {
__proto__: null,
GenerateAdrSlugFromTitleQueryHandler: GenerateAdrSlugFromTitleQueryHandler,
GetAdrBySlugQueryHandler: GetAdrBySlugQueryHandler,
SearchAdrsQueryHandler: SearchAdrsQueryHandler
};
class MarkdownAdrLinkResolver {
constructor({
adrRepository
}) {
this.adrRepository = adrRepository;
}
async resolve(from, uri) {
if (!from.file) {
throw new Log4brainsError("Impossible to resolve links on an non-saved ADR");
}
const path = from.file.path.join("..").join(uri);
if (!AdrFile.isPathValid(path)) {
return undefined;
}
const to = await this.adrRepository.findFromFile(new AdrFile(path));
if (!to) {
return undefined;
}
return new MarkdownAdrLink(from, to);
}
}
class AdrRepository {
constructor({
config,
workdir,
packageRepository
}) {
this.config = config;
this.workdir = workdir;
this.packageRepository = packageRepository;
this.git = simpleGit({
baseDir: workdir
});
this.markdownAdrLinkResolver = new MarkdownAdrLinkResolver({
adrRepository: this
});
}
async isGitAvailable() {
if (this.gitAvailable === undefined) {
try {
this.gitAvailable = await this.git.checkIsRepo();
} catch (e) {
this.gitAvailable = false;
}
}
return this.gitAvailable;
}
async find(slug) {
const packageRef = this.getPackageRef(slug);
const adr = await this.findInPath(slug, this.getAdrFolderPath(packageRef), packageRef);
if (!adr) {
throw new Log4brainsError("This ADR does not exist", slug.value);
}
return adr;
}
async findFromFile(adrFile) {
const adrFolderPath = adrFile.path.join("..");
const pkg = this.packageRepository.findByAdrFolderPath(adrFolderPath);
const possibleSlug = AdrSlug.createFromFile(adrFile, pkg ? pkg.ref : undefined);
try {
return await this.find(possibleSlug);
} catch (e) {// ignore
}
return undefined;
}
async findAll() {
const packages = this.packageRepository.findAll();
return (await Promise.all([this.findAllInPath(this.getAdrFolderPath()), ...packages.map(pkg => {
return this.findAllInPath(pkg.adrFolderPath, pkg.ref);
})])).flat().sort(Adr.compare);
}
async getGitMetadata(file) {
if (!(await this.isGitAvailable())) {
return undefined;
}
let logs;
let retry = 0;
do {
// eslint-disable-next-line no-await-in-loop
logs = (await this.git.log([file.path.absolutePath])).all; // TODO: debug this strange bug
// Sometimes, especially during snapshot testing, the `git log` command retruns nothing.
// And after a second retry, it works.
// Impossible to find out why for now, and since it causes a lot of false positive in the integration tests,
// we had to implement this quickfix
retry += 1;
} while (logs.length === 0 && retry <= 1);
if (logs.length === 0) {
return undefined;
}
return {
creationDate: new Date(logs[logs.length - 1].date),
lastEditDate: new Date(logs[0].date),
lastEditAuthor: new Author(logs[0].author_name, logs[0].author_email)
};
}
/**
* In preview mode, we set the Anonymous author as the current Git `user.name` global config.
* It should not append in CI. But if this is the case, it will appear as "Anonymous".
* Response is cached.
*/
async getAnonymousAuthor() {
if (!this.anonymousAuthor) {
this.anonymousAuthor = Author.createAnonymous();
if (await this.isGitAvailable()) {
const config = await this.git.listConfig();
if (config != null && config.all["user.name"]) {
this.anonymousAuthor = new Author(config.all["user.name"], config.all["user.email"]);
}
}
}
return this.anonymousAuthor;
}
async getLastEditDateFromFilesystem(file) {
const stat = await promises.stat(file.path.absolutePath);
return stat.mtime;
}
async findInPath(slug, p, packageRef) {
return (await this.findAllInPath(p, packageRef, (f, s) => s.equals(slug))).pop();
}
async findAllInPath(p, packageRef, filter) {
const files = await promises.readdir(p.absolutePath);
return Promise.all(files.map(filename => {
return new FilesystemPath(p.cwdAbsolutePath, path.join(p.pathRelativeToCwd, filename));
}).filter(fsPath => {
return AdrFile.isPathValid(fsPath);
}).map(fsPath => {
const adrFile = new AdrFile(fsPath);
const slug = AdrSlug.createFromFile(adrFile, packageRef);
return {
adrFile,
slug
};
}).filter(({
adrFile,
slug
}) => {
if (filter) {
return filter(adrFile, slug);
}
return true;
}).map(({
adrFile,
slug
}) => {
return promises.readFile(adrFile.path.absolutePath, {
encoding: "utf8"
}).then(async markdown => {
const baseAdrProps = {
slug,
package: packageRef,
body: new MarkdownBody(markdown).setAdrLinkResolver(this.markdownAdrLinkResolver),
file: adrFile
}; // The file is versionned in Git
const gitMetadata = await this.getGitMetadata(adrFile);
if (gitMetadata) {
return new Adr(_extends({}, baseAdrProps, {
creationDate: gitMetadata.creationDate,
lastEditDate: gitMetadata.lastEditDate,
lastEditAuthor: gitMetadata.lastEditAuthor
}));
} // The file is not versionned in Git yet
// So we rely on filesystem's last edit date and global git config
const lastEditDate = await this.getLastEditDateFromFilesystem(adrFile);
return new Adr(_extends({}, baseAdrProps, {
creationDate: lastEditDate,
lastEditDate,
lastEditAuthor: await this.getAnonymousAuthor()
}));
});
}));
}
generateAvailableSlug(title, packageRef) {
const adrFolderPath = this.getAdrFolderPath(packageRef);
const baseSlug = AdrSlug.createFromTitle(title, packageRef);
let i = 1;
let slug;
let filename;
do {
slug = new AdrSlug(`${baseSlug.value}${i > 1 ? `-${i}` : ""}`);
filename = `${slug.namePart}.md`;
i += 1;
} while (fs.existsSync(path.join(adrFolderPath.absolutePath, filename)));
return slug;
}
getPackageRef(slug) {
// undefined if global
return slug.packagePart ? new PackageRef(slug.packagePart) : undefined;
}
getAdrFolderPath(packageRef) {
const pkg = packageRef ? this.packageRepository.find(packageRef) : undefined;
const cwd = path.resolve(this.workdir);
return pkg ? pkg.adrFolderPath : new FilesystemPath(cwd, this.config.project.adrFolder);
}
async save(adr) {
let {
file
} = adr;
if (!file) {
file = AdrFile.createFromSlugInFolder(this.getAdrFolderPath(adr.package), adr.slug);
if (fs.existsSync(file.path.absolutePath)) {
throw new Log4brainsError("An ADR with this slug already exists", adr.slug.value);
}
adr.setFile(file);
}
await promises.writeFile(file.path.absolutePath, adr.body.getRawMarkdown(), {
encoding: "utf-8"
});
}
}
/* eslint-disable class-methods-use-this */
class AdrTemplateRepository {
constructor({
config,
workdir,
packageRepository
}) {
this.config = config;
this.workdir = workdir;
this.packageRepository = packageRepository;
}
async find(packageRef) {
const adrFolderPath = this.getAdrFolderPath(packageRef);
const templatePath = path.join(adrFolderPath.absolutePath, "template.md");
if (!fs.existsSync(templatePath)) {
if (packageRef) {
// Returns the global template when there is no custom template for a package
const globalTemplate = await this.find();
return new AdrTemplate({
package: packageRef,
body: globalTemplate.body
});
}
throw new Log4brainsError("The template.md file does not exist", path.join(adrFolderPath.pathRelativeToCwd, "template.md"));
}
const markdown = await promises.readFile(templatePath, {
encoding: "utf8"
});
return new AdrTemplate({
package: packageRef,
body: new MarkdownBody(markdown)
});
}
getAdrFolderPath(packageRef) {
const pkg = packageRef ? this.packageRepository.find(packageRef) : undefined;
const cwd = path.resolve(this.workdir);
return pkg ? pkg.adrFolderPath : new FilesystemPath(cwd, this.config.project.adrFolder);
}
}
class PackageRepository {
constructor({
config,
workdir
}) {
this.config = config;
this.workdir = workdir;
}
find(packageRef) {
const pkg = this.findAll().filter(p => p.ref.equals(packageRef)).pop();
if (!pkg) {
throw new Log4brainsError("No entry in the configuration for this package", packageRef.name);
}
return pkg;
}
findByAdrFolderPath(adrFolderPath) {
return this.findAll().filter(p => p.adrFolderPath.equals(adrFolderPath)).pop();
}
findAll() {
if (!this.packages) {
this.packages = (this.config.project.packages || []).map(packageConfig => this.buildPackage(packageConfig.name, packageConfig.path, packageConfig.adrFolder));
}
return this.packages;
}
buildPackage(name, projectPath, adrFolder) {
const cwd = path.resolve(this.workdir);
const pkg = new Package({
ref: new PackageRef(name),
path: new FilesystemPath(cwd, projectPath),
adrFolderPath: new FilesystemPath(cwd, adrFolder)
});
if (!fs.existsSync(pkg.path.absolutePath)) {
throw new Log4brainsError("Package path does not exist", `${pkg.path.pathRelativeToCwd} (${pkg.ref.name})`);
}
if (!fs.existsSync(pkg.adrFolderPath.absolutePath)) {
throw new Log4brainsError("Package ADR folder path does not exist", `${pkg.adrFolderPath.pathRelativeToCwd} (${pkg.ref.name})`);
}
return pkg;
}
}
var repositories = {
__proto__: null,
AdrRepository: AdrRepository,
AdrTemplateRepository: AdrTemplateRepository,
PackageRepository: PackageRepository
};
class CommandBus {
constructor() {
this.handlersByCommandName = new Map();
}
registerHandler(handler, commandClass) {
this.handlersByCommandName.set(commandClass.name, handler);
}
async dispatch(command) {
const commandName = command.constructor.name;
const handler = this.handlersByCommandName.get(commandName);
if (!handler) {
throw new Error(`No handler registered for this command: ${commandName}`);
}
return handler.execute(command);
}
}
class QueryBus {
constructor() {
this.handlersByQueryName = new Map();
}
registerHandler(handler, queryClass) {
this.handlersByQueryName.set(queryClass.name, handler);
}
async dispatch(query) {
const queryName = query.constructor.name;
const handler = this.handlersByQueryName.get(queryName);
if (!handler) {
throw new Error(`No handler registered for this query: ${queryName}`);
}
return handler.execute(query);
}
}
/**
* Watch files located in the main ADR folder, and in each package's ADR folder.
* Useful for Hot Reloading.
* The caller is responsible for starting and stopping it!
*/
class FileWatcher {
constructor({
config,
workdir
}) {
this.observers = new Set();
this.workdir = workdir;
this.config = config;
}
subscribe(cb) {
this.observers.add(cb);
return () => {
this.observers.delete(cb);
};
}
start() {
if (this.chokidar) {
throw new Log4brainsError("FileWatcher is already started");
}
const paths = [this.config.project.adrFolder, ...(this.config.project.packages || []).map(pkg => pkg.adrFolder)];
this.chokidar = chokidar.watch(paths, {
ignoreInitial: true,
cwd: this.workdir,
disableGlobbing: true
}).on("all", (event, filePath) => {
this.observers.forEach(observer => observer({
type: event,
relativePath: filePath
}));
});
}
async stop() {
if (!this.chokidar) {
throw new Log4brainsError("FileWatcher is not started");
}
await this.chokidar.close();
this.chokidar = undefined;
}
}
function lowerCaseFirstLetter(string) {
return string.charAt(0).toLowerCase() + string.slice(1);
}
function buildContainer(config, workdir = ".") {
const container = createContainer({
injectionMode: InjectionMode.PROXY
}); // Configuration & misc
container.register({
config: asValue(config),
workdir: asValue(workdir),
fileWatcher: asClass(FileWatcher).singleton()
}); // Repositories
Object.values(repositories).forEach(Repository => {
container.register(lowerCaseFirstLetter(Repository.name), asClass(Repository).singleton());
}); // Command handlers
Object.values(adrCommandHandlers).forEach(Handler => {
container.register(Handler.name, asClass(Handler).singleton());
}); // Command bus
container.register({
commandBus: asFunction(() => {
const bus = new CommandBus();
Object.values(adrCommandHandlers).forEach(Handler => {
const handlerInstance = container.resolve(Handler.name);
bus.registerHandler(handlerInstance, handlerInstance.commandClass);
});
return bus;
}).singleton()
}); // Query handlers
Object.values(adrQueryHandlers).forEach(Handler => {
container.register(Handler.name, asClass(Handler).singleton());
}); // Query bus
container.register({
queryBus: asFunction(() => {
const bus = new QueryBus();
Object.values(adrQueryHandlers).forEach(Handler => {
const handlerInstance = container.resolve(Handler.name);
bus.registerHandler(handlerInstance, handlerInstance.queryClass);
});
return bus;
}).singleton()
});
return container;
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const deepFreezeRecur = obj => {
if (typeof obj !== "object") {
return obj;
}
Object.keys(obj).forEach(prop => {
if (typeof obj[prop] === "object" && !Object.isFrozen(obj[prop])) {
deepFreezeRecur(obj[prop]);
}
});
return Object.freeze(obj);
};
/**
* Apply Object.freeze() recursively on the given object and sub-objects.
*/
const deepFreeze = obj => {
return deepFreezeRecur(obj);
};
const projectPackageSchema = Joi.object({
name: Joi.string().hostname().required(),
path: Joi.string().required(),
adrFolder: Joi.string().required()
});
const gitProviders = ["github", "gitlab", "bitbucket", "generic"];
const gitRepositorySchema = Joi.object({
url: Joi.string().uri(),
provider: Joi.string().valid(...gitProviders),
viewFileUriPattern: Joi.string() // Useful for unsupported providers. Example for GitHub: /blob/%branch/%path
});
const projectSchema = Joi.object({
name: Joi.string().required(),
tz: Joi.string().required(),
adrFolder: Joi.string().required(),
packages: Joi.array().items(projectPackageSchema),
repository: gitRepositorySchema
});
const schema = Joi.object({
project: projectSchema.required()
});
function isGitRemoteConfig(remoteConfig) {
return typeof remoteConfig === "object" && remoteConfig !== null && "url" in remoteConfig;
}
function guessGitRepositoryConfig(existingConfig, workdir) {
var _existingConfig$proje, _existingConfig$proje2, _existingConfig$proje3;
// URL
let url = (_existingConfig$proje = existingConfig.project.repository) == null ? void 0 : _existingConfig$proje.url;
if (!url) {
// Try to guess from the current Git configuration
// We use parse-git-config and not SimpleGit because we want this method to remain synchronous
const gitConfig = parseGitConfig.sync({
path: path.join(workdir, ".git/config")
});
if (isGitRemoteConfig(gitConfig['remote "origin"'])) {
url = gitConfig['remote "origin"'].url;
}
}
if (!url) {
return undefined;
}
const urlInfo = gitUrlParse(url);
if (!urlInfo.protocol.includes("https") && !urlInfo.protocol.includes("http")) {
// Probably an SSH URL -> we try to convert it to HTTPS
url = urlInfo.toString("https");
}
url = url.replace(/\/$/, ""); // remove a possible trailing-slash
// PROVIDER
let provider = (_existingConfig$proje2 = existingConfig.project.repository) == null ? void 0 : _existingConfig$proje2.provider;
if (!provider || !gitProviders.includes(provider)) {
// Try to guess from the URL
provider = gitProviders.filter(p => urlInfo.resource.includes(p)).pop() || "generic";
} // PATTERN
let viewFileUriPattern = (_existingConfig$proje3 = existingConfig.project.repository) == null ? void 0 : _existingConfig$proje3.viewFileUriPattern;
if (!viewFileUriPattern) {
switch (provider) {
case "gitlab":
viewFileUriPattern = "/-/blob/%branch/%path";
break;
case "bitbucket":
viewFileUriPattern = "/src/%branch/%path";
break;
case "github":
default:
viewFileUriPattern = "/blob/%branch/%path";
break;
}
}
return {
url,
provider,
viewFileUriPattern
};
}
class Log4brainsConfigNotFoundError extends Log4brainsError {
constructor() {
super("Impossible to find the .log4brains.yml config file");
}
}
const configFilename = ".log4brains.yml";
function getDuplicatedValues(objects, key) {
const values = objects.map(object => object[key]);
const countsMap = values.reduce((counts, value) => {
return _extends({}, counts, {
[value]: (counts[value] || 0) + 1
});
}, {});
return Object.keys(countsMap).filter(value => countsMap[value] > 1);
}
function buildConfig(object) {
const joiResult = schema.validate(object, {
abortEarly: false,
convert: false
});
if (joiResult.error) {
var _joiResult$error;
throw new Log4brainsError(`There is an error in the ${configFilename} config file`, (_joiResult$error = joiResult.error) == null ? void 0 : _joiResult$error.message);
}
const config = deepFreeze(joiResult.value); // Package name duplication
if (config.project.packages) {
const duplicatedPackageNames = getDuplicatedValues(config.project.packages, "name");
if (duplicatedPackageNames.length > 0) {
throw new Log4brainsError("Some package names are duplicated", duplicatedPackageNames.join(", "));
}
}
return config;
}
function buildConfigFromWorkdir(workdir = ".") {
const workdirAbsolute = path.resolve(workdir);
const configPath = path.join(workdirAbsolute, configFilename);
if (!fs.existsSync(configPath)) {
throw new Log4brainsConfigNotFoundError();
}
try {
const content = fs.readFileSync(configPath, "utf8");
const object = yaml.parse(content);
const config = buildConfig(object);
return deepFreeze(_extends({}, config, {
project: _extends({}, config.project, {
repository: guessGitRepositoryConfig(config, workdir)
})
}));
} catch (e) {
if (e instanceof Log4brainsError) {
throw e;
}
throw new Log4brainsError(`Impossible to read the ${configFilename} config file`, e);
}
}
function findWorkdirRecursive(cwd = ".") {
const cwdAbsolute = path.resolve(cwd);
if (fs.existsSync(path.join(cwdAbsolute, configFilename))) {
return cwdAbsolute;
}
const parsedPath = path.parse(cwdAbsolute);
if (parsedPath.dir === parsedPath.root) {
// we are at the filesystem root -> stop recursion
throw new Log4brainsConfigNotFoundError();
}
return findWorkdirRecursive(path.join(cwd, ".."));
}
function buildViewUrl(repositoryConfig, file) {
if (!repositoryConfig.url || !repositoryConfig.viewFileUriPattern) {
return undefined;
}
const uri = repositoryConfig.viewFileUriPattern.replace("%branch", "master") // TODO: make this customizable, and fix the branch name for the Log4brains repository (develop instead of master)
.replace("%path", file.path.pathRelativeToCwd);
return `${repositoryConfig.url.replace(/\.git$/, "")}${uri}`;
}
async function adrToDto(adr, repositoryConfig) {
var _adr$package, _adr$superseder, _adr$publicationDate;
if (!adr.file) {
throw new Error("You are serializing an non-saved ADR");
}
const viewUrl = repositoryConfig ? buildViewUrl(repositoryConfig, adr.file) : undefined;
return deepFreeze(_extends({
slug: adr.slug.value,
package: ((_adr$package = adr.package) == null ? void 0 : _adr$package.name) || null,
title: adr.title || null,
status: adr.status.name,
supersededBy: ((_adr$superseder = adr.superseder) == null ? void 0 : _adr$superseder.value) || null,
tags: adr.tags,
deciders: adr.deciders,
body: {
rawMarkdown: adr.body.getRawMarkdown(),
enhancedMdx: await adr.getEnhancedMdx()
},
creationDate: adr.creationDate.toJSON(),
lastEditDate: adr.lastEditDate.toJSON(),
lastEditAuthor: adr.lastEditAuthor.name,
publicationDate: ((_adr$publicationDate = adr.publicationDate) == null ? void 0 : _adr$publicationDate.toJSON()) || null,
file: {
relativePath: adr.file.path.pathRelativeToCwd,
absolutePath: adr.file.path.absolutePath
}
}, repositoryConfig && repositoryConfig.provider && viewUrl ? {
repository: {
provider: repositoryConfig.provider,
viewUrl
}
} : undefined));
}
/**
* Log4brains core API.
* Use {@link Log4brains.create} to build an instance.
*/
class Log4brains {
constructor(config, workdir = ".") {
this.config = config;
this.workdir = workdir;
this.container = buildContainer(config, workdir);
this.commandBus = this.container.resolve("commandBus");
this.queryBus = this.container.resolve("queryBus");
this.adrRepository =