UNPKG

@log4brains/core

Version:

Log4brains architecture knowledge base core API

1,963 lines (1,541 loc) 55.9 kB
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); } 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 =