diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..91e96322 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..8e4b29b9 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,85 @@ +module.exports = { + root: true, + env: { + es2021: true + }, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + project: "./tsconfig.json" + }, + ignorePatterns: ["**/*.js", "**/*.d.ts", "dist", "node_modules"], + plugins: ["jest", "sonarjs", "promise", "@typescript-eslint", "react"], + extends: [ + "eslint:recommended", + "plugin:jest/recommended", + "plugin:jest/style", + "plugin:sonarjs/recommended", + "plugin:promise/recommended", + "airbnb-typescript", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "prettier", + "prettier/@typescript-eslint", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "airbnb/hooks", + "prettier/react" + ], + rules: { + "import/prefer-default-export": "off", // @adr 20200927-avoid-default-exports + "import/no-default-export": "error", // @adr 20200927-avoid-default-exports + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }], + "sonarjs/no-duplicate-string": "off", + "react/prefer-stateless-function": [2, { ignorePureComponents: false }], + "react/function-component-definition": [ + 2, + { + namedComponents: "function-declaration", + unnamedComponents: "arrow-function" + } + ], + "react/require-default-props": [2, { ignoreFunctionalComponents: true }], // DefaultProps are deprecated for functional components (https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/require-default-props.md) + "react/jsx-props-no-spreading": "off", // For HOC + "no-void": [2, { allowAsStatement: true }], // For React.useEffect() with async functions + "react/display-name": "off" + }, + overrides: [ + { + files: ["*.ts", "*.tsx"] // Forces Jest to include these files + }, + { + files: "*.tsx", + rules: { + "sonarjs/cognitive-complexity": [2, 18], // React functions are usually more complex + "@typescript-eslint/explicit-module-boundary-types": "off" // @adr web/20200927-avoid-react-fc-type + } + }, + { + files: ["src/**/*.stories.tsx"], // Storybook + rules: { + "import/no-extraneous-dependencies": "off", + "import/no-default-export": "off", + "react/function-component-definition": "off" + } + }, + { + files: "*", // All non-React files + excludedFiles: "*.tsx", + rules: { + "react/static-property-placement": "off" + } + }, + { + files: "**.test.ts", // Jest + rules: { + "max-classes-per-file": "off", + "import/no-extraneous-dependencies": "off", + "no-new": "off", + "no-empty": "off" + } + } + ] +}; diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..790260df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,43 @@ +--- +name: "Bug Report" +about: "Report a bug" +labels: bug +--- + +# Bug Report + +## Description + + + +## Steps to Reproduce + + + + + +1. Step 1 +2. Step 2 +3. ... + +## Expected Behavior + + + +## Context + + + + +## Environment + + + +- Log4brains version: +- Node.js version: +- OS and its version: +- Browser information: + +## Possible Solution + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..4360118e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: Give your feedback 📣 + url: https://github.com/thomvaill/log4brains/discussions/new?category=Feedback + about: Give your feedback (positive or negative!) on the BETA version + - name: Ask a question + url: https://github.com/thomvaill/log4brains/discussions + about: Ask questions and discuss with other community members diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..b5fc75cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,22 @@ +--- +name: "Feature Request" +about: "Suggest new features and changes" +labels: feature +--- + +# Feature Request + +## Feature Suggestion + + + +## Context + + + + +## Possible Implementation + + + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..64949a9c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,230 @@ +# Inspired from https://github.com/backstage/backstage/blob/master/.github/workflows/ci.yml. Thanks! +# See ci.yml for info on `quality` and `tests` stages +name: Build +on: + push: + branches: + - master + +jobs: + quality: + strategy: + # We use a matrix even if it's not needed here to be able to re-use the same Yarn setup snippet everywhere + matrix: + os: [ubuntu-latest] + node-version: [14.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup [KEEP IN SYNC BETWEEN ALL WORKFLOWS] (copy/paste of ci.yml's snippet) + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: "**/node_modules" + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup + + # TODO: make dev & test work without having to build core & cli-common (inspiration: https://github.com/Izhaki/mono.ts) + - name: build core + run: yarn build --scope @log4brains/core --scope @log4brains/cli-common + + - name: format + run: yarn format + + - name: lint + run: yarn lint + + tests: + needs: quality + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [14.x, 12.x, 10.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # fetch all history to make Jest snapshot tests work + + # Beginning of yarn setup [KEEP IN SYNC BETWEEN ALL WORKFLOWS] (copy/paste of ci.yml's snippet) + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: "**/node_modules" + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup + + # We have to build all the packages before the tests + # Because init-log4brains's integration tests use @log4brains/cli, which uses @log4brains/core + # TODO: we should separate tests that require built packages of the others, to get a quicker feedback + # Once it's done, we should add "yarn test" in each package's preVersion script + - name: build + run: yarn build && yarn links + + - name: test + run: yarn test + + - name: E2E tests + run: yarn e2e + + publish-pages: + needs: tests + strategy: + # We use a matrix even if it's not needed here to be able to re-use the same Yarn setup snippet everywhere + matrix: + os: [ubuntu-latest] + node-version: [14.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + with: + persist-credentials: false # required by JamesIves/github-pages-deploy-action + fetch-depth: 0 # required by Log4brains to work correctly (needs the whole Git history) + + # Beginning of yarn setup [KEEP IN SYNC BETWEEN ALL WORKFLOWS] (copy/paste of ci.yml's snippet) + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: "**/node_modules" + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup + + - name: build + run: yarn build + + - name: build self knowledge base + env: + HIDE_LOG4BRAINS_VERSION: "1" # TODO: use lerna to bump the version temporarily here so we don't have to hide it + run: yarn log4brains-build --basePath /${GITHUB_REPOSITORY#*/}/adr + + - name: publish self knowledge base + uses: JamesIves/github-pages-deploy-action@3.7.1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: gh-pages + FOLDER: .log4brains/out + TARGET_FOLDER: adr + + release: + needs: publish-pages # we could perform this step in parallel but this acts as a last end-to-end test before releasing + strategy: + # We use a matrix even if it's not needed here to be able to re-use the same Yarn setup snippet everywhere + matrix: + os: [ubuntu-latest] + node-version: [14.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # fetch all history for Lerna (https://stackoverflow.com/a/60184319/9285308) + + - name: fetch all git tags for Lerna # (https://stackoverflow.com/a/60184319/9285308) + run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + + # Beginning of yarn setup [KEEP IN SYNC BETWEEN ALL WORKFLOWS] (copy/paste of ci.yml's snippet) + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: "**/node_modules" + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup + + - name: build + run: yarn build + + - name: git identity + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + + - name: release and publish to NPM + # TODO: change when going stable + run: yarn lerna publish --yes --conventional-commits --conventional-prerelease --force-publish --create-release github + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..92ae6746 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,133 @@ +# Inspired from https://github.com/backstage/backstage/blob/master/.github/workflows/ci.yml. Thanks! +name: CI +on: + pull_request: + +jobs: + quality: + strategy: + # We use a matrix even if it's not needed here to be able to re-use the same Yarn setup snippet everywhere + matrix: + os: [ubuntu-latest] + node-version: [14.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup [KEEP IN SYNC BETWEEN ALL WORKFLOWS] + # TODO: create a dedicated composite GitHub Action to avoid copy/pastes everywhere + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # needed for auth when publishing + + # Cache every node_modules folder inside the monorepo + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: "**/node_modules" + # We use both yarn.lock and package.json as cache keys to ensure that + # changes to local monorepo packages bust the cache. + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + + # If we get a cache hit for node_modules, there's no need to bring in the global + # yarn cache or run yarn install, as all dependencies will be installed already. + + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup + + # TODO: make dev & test work without having to build core & cli-common (inspiration: https://github.com/Izhaki/mono.ts) + - name: build core + run: yarn build --scope @log4brains/core --scope @log4brains/cli-common + + - name: format + run: yarn format + + - name: lint + run: yarn lint + + tests: + needs: quality + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [14.x, 12.x, 10.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # fetch all history to make Jest snapshot tests work + + - name: fetch branch master + run: git fetch origin master + + # Beginning of yarn setup [KEEP IN SYNC BETWEEN ALL WORKFLOWS] (copy/paste of the snippet above) + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: "**/node_modules" + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup + + - name: check for yarn.lock changes + id: yarn-lock + run: git diff --quiet origin/master HEAD -- yarn.lock + continue-on-error: true + # - steps.yarn-lock.outcome == 'success' --> yarn.lock was not changed + # - steps.yarn-lock.outcome == 'failure' --> yarn.lock was changed + + # We have to build all the packages before the tests + # Because init-log4brains's integration tests use @log4brains/cli, which uses @log4brains/core + # TODO: we should separate tests that require built packages of the others, to get a quicker feedback + # Once it's done, we should add "yarn test" in each package's preVersion script + - name: build + run: yarn build && yarn links + + - name: test changed packages + if: ${{ steps.yarn-lock.outcome == 'success' }} + run: yarn test --since origin/master + + - name: test all packages + if: ${{ steps.yarn-lock.outcome == 'failure' }} + run: yarn test + + - name: E2E tests + run: yarn e2e diff --git a/.gitignore b/.gitignore index 67045665..7068d9a6 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ dist # TernJS port file .tern-port + +# Log4brains +.log4brains diff --git a/.log4brains.yml b/.log4brains.yml new file mode 100644 index 00000000..88d31ffa --- /dev/null +++ b/.log4brains.yml @@ -0,0 +1,14 @@ +--- +project: + name: Log4brains + tz: Europe/Paris + adrFolder: ./docs/adr + + packages: + - name: core + path: ./packages/core + adrFolder: ./packages/core/docs/adr + + - name: web + path: ./packages/web + adrFolder: ./packages/web/docs/adr diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..01487040 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +out/ +dist/ +CHANGELOG.md +/lerna.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..29e67bf6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,46 @@ +{ + "editor.formatOnSave": true, + "[typescript]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[javascript]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "cSpell.words": [ + "Diagnosticable", + "Transpiled", + "adrs", + "awilix", + "clsx", + "copyfiles", + "diagnotics", + "esnext", + "execa", + "gitlab", + "globby", + "htmlentities", + "lunr", + "microbundle", + "neverthrow", + "outdir", + "signale", + "typedoc", + "unversioned", + "workdir" + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..990fd31e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +thomvaill@bluebricks.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..712a4797 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing to Log4brains + +:+1::tada: First of all, thanks for taking the time to contribute! :tada::+1: + +All your contributions are very welcome, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +Thank you so much! :clap: + +## Development + +```bash +yarn install +yarn links +yarn dev + +# You can now develop +# `yarn dev` re-builds the changed packages live + +# You can test the different packages directly on the Log4brains project +# All its scripts are linked to your local dev version +yarn adr new +yarn log4brains-build +yarn serve # serves the build output (`.log4brains/out`) locally + +# For the Next.js app, you can enable the Fast Refresh feature just by setting NODE_ENV to `development` +NODE_ENV=development yarn log4brains-preview +# Or you can run this more convenient command on the root project: +yarn log4brains-preview:dev +# Or if you want to debug only the Next.js app without the Log4brains custom part, you can run: +cd packages/web && yarn next dev # (in this case `yarn dev` is not needed before running this command) + +# To work on the UI, you probably would like to use the Storybook instead: +cd packages/web && yarn storybook + +# You can also test the different packages on an empty project +# `npx init-log4brains` is linked to your local dev version and will install +# the dev version of each Log4brains package if you set NODE_ENV to `development` +cd $(mktemp -d -t l4b-test-XXXX) && npm init --yes && npm install +NODE_ENV=development npx init-log4brains +``` + +## Checks to run before pushing + +```bash +yarn lint # enforced automatically before every commit with husky+lint-staged +yarn format:fix # enforced automatically before every commit with husky+lint-staged +yarn test:changed # (or `yarn test` to run all the tests) +``` + +Please do not forget to add tests to your contribution if this is applicable! + +## License + +By contributing to Log4brains, you agree that your contributions will be licensed under its Apache 2.0 License. diff --git a/LICENSE b/LICENSE index 261eeb9e..ea34bfdc 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 Thomas Vaillant Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 17467331..e3b19769 100644 --- a/README.md +++ b/README.md @@ -1 +1,514 @@ -# log4brains \ No newline at end of file +# Log4brains + +

+ + Log4brains logo + +

+ +

+ + License + + + Build Status + + + @log4brains/web latest version + + + @log4brains/cli latest version + +

+ +Log4brains is a docs-as-code knowledge base for your development and infrastructure projects. +It enables you to write and manage [Architecture Decision Records](https://adr.github.io/) (ADR) right from your IDE, and to publish them automatically as a static website. + +
+Features +

+ +- Docs-as-code: ADRs are written in markdown, stored in your git repository, close to your code +- Local preview with Hot Reload +- Interactive ADR creation from the CLI +- Static site generation to publish to GitHub/GitLab Pages or S3 +- Timeline menu +- Searchable +- ADR metadata automatically guessed from its raw text and git logs +- No enforced markdown structure: you are free to write however you want +- No required file numbering schema (i.e., `adr-0001.md`, `adr-0002.md`...): avoids git merge issues +- Customizable template (default: [MADR](https://adr.github.io/madr/)) +- Multi-package projects support (mono or multi repo): notion of global and package-specific ADRs + +**Coming soon**: + +- Local images and diagrams support +- RSS feed to be notified of new ADRs +- Decision backlog +- `@adr` annotation to include code references in ADRs +- ADR creation/edition from the UI +- Create a new GitHub/GitLab issue from the UI +- ... let's [suggest a new feature](https://github.com/thomvaill/log4brains/issues/new?labels=feature&template=feature.md) if you have other needs! + +

+
+ +
+

+ + Log4brains demo + +

+

Demo: Log4brains' own architecture knowledge base

+ +## Table of contents + +- [📣 Beta version: your feedback is welcome!](#-beta-version-your-feedback-is-welcome) +- [🚀 Getting started](#-getting-started) +- [🤔 What is an ADR and why should you use them](#-what-is-an-adr-and-why-should-you-use-them) +- [💡 Why Log4brains](#-why-log4brains) +- [📨 CI/CD configuration examples](#-cicd-configuration-examples) +- [❓ FAQ](#-faq) + - [What are the prerequisites?](#what-are-the-prerequisites) + - [What about multi-package projects?](#what-about-multi-package-projects) + - [What about non-JS projects?](#what-about-non-js-projects) + - [How to configure `.log4brains.yml`?](#how-to-configure-log4brainsyml) +- [Contributing](#contributing) +- [Acknowledgments](#acknowledgments) +- [License](#license) + +## 📣 Beta version: your feedback is welcome! + +At this stage, Log4brains is just a few months old and was designed only based on my needs and my past experiences with ADRs. +But I am convinced that this project can benefit a lot of teams. +This is why it would be precious for me to get your feedback on this beta version in order to improve it. + +To do so, you are very welcome to [create a new feedback in the Discussions](https://github.com/thomvaill/log4brains/discussions/new?category=Feedback) or to reach me at . Thanks a lot 🙏 + +## 🚀 Getting started + +According to the Log4brains philosophy, you should store your Architecture Decision Records (ADR) the closest to your code, which means ideally inside your project's git repository, for example in `/docs/adr`. In the case of a JS project, we recommend installing Log4brains as a dev dependency. To do so, run our interactive setup CLI inside your project root directory: + +```bash +npx init-log4brains +``` + +... it will ask you several questions to get your knowledge base installed and configured properly. Click [here](#what-about-non-js-projects) for non-JS projects. + +Then, you can start the web UI in local preview mode: + +```bash +npm run log4brains-preview + +# OR + +yarn log4brains-preview +``` + +In this mode, the Hot Reload feature is enabled: any change +you make to a markdown file is applied live in the UI. + +You can use this command to easily create a new ADR interactively: + +```bash +npm run adr -- new + +# OR + +yarn adr new +``` + +Just add the `--help` option for more information on this command. + +Finally, do not forget to [set up your CI/CD pipeline](#-cicd-configuration-examples) to automatically publish your knowledge base on a static website service like GitHub/GitLab Pages or S3. + +## 🤔 What is an ADR and why should you use them + +The term ADR become popular in 2011 with Michael Nygard's article: [documenting architecture decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions). He aimed to reconcile Agile methods with software documentation by creating a very concise template +to record functional or non-functional "architecturally significant" decisions in a lightweight format like markdown. +The original template had only a few parts: + +- **Title**: Which sums up the solved problem and its solution +- **Context**: Probably the essential part, which describes "the forces at play, including technological, political, social, and project local" +- **Decision** +- **Status**: Proposed, accepted, deprecated, superseded... +- **Consequences**: The positive and negative ones for the future of the project + +Today, there are other ADR templates like [Y-Statements](https://medium.com/olzzio/y-statements-10eb07b5a177), or [MADR](https://adr.github.io/madr/), which is the default one that is configured in Log4brains. +Anyway, we believe that no template suits everyone's needs. You should adapt it according to your own situation. + +As you can guess from the template above, an ADR is immutable. Only its status can change. +Thanks to this, your documentation is never out-of-date! Yes, an ADR can be deprecated or superseded by another one, but it was at least true one day! +And even if it's not the case anymore, it is still a precious piece of information. + +This leads us to the main goals of this methodology: + +- Avoid blind acceptance and blind reversal when you face past decisions +- Speed up the onboarding of new developers on a project +- Formalize a collaborative decision-making process + +The first goal was the very original one, intended by Michael Nygard in his article. +I discovered the two others in my past experiences with ADRs, and this is why I decided to create Log4brains. + +To learn more on this topic, I recommend you to read these great resources: + +- [Documenting architecture decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions), by Michael Nygard +- [ADR GitHub organization](https://adr.github.io/), home of the [MADR](https://adr.github.io/madr/) template, by @boceckts and @koppor +- [Collection of ADR templates](https://github.com/joelparkerhenderson/architecture_decision_record) by @joelparkerhenderson + +## 💡 Why Log4brains + +I've been using ADRs for a long time and, I often introduce this methodology to the teams I work with as a freelance developer. +It's always the same scenario: first, no one had ever heard about ADRs, and after using them for a while, they realize [how useful yet straightforward they are](#-what-is-an-adr-and-why-should-you-use-them). So one of the reasons I decided to start working on Log4brains was to popularize this methodology. + +On the other hand, I wanted to solve some issues I encountered with them, like improving their discoverability or the poor tooling around them. +But above all, I am convinced that ADRs can have a broader impact than what they were intended for: speed up the onboarding on a project by becoming a training material, and become the support of a collaborative decision-making process. + +In the long term, I see Log4brains as part of a global strategy that would let companies build and capitalize their teams' technical knowledge collaboratively. + +## 📨 CI/CD configuration examples + +Log4brains lets you publish automatically your knowledge base on the static hosting service of your choice, thanks to the `log4brains-web build` command. +Here are some configuration examples for the most common hosting services / CI runners. + +
+Publish to GitHub Pages with GitHub Actions +

+ +First, create `.github/workflows/publish-log4brains.yml` and adapt it to your case: + +```yml +name: Publish Log4brains +on: + push: + branches: + - master +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + with: + persist-credentials: false # required by JamesIves/github-pages-deploy-action + fetch-depth: 0 # required by Log4brains to work correctly (needs the whole Git history) + - name: Install Node + uses: actions/setup-node@v1 + with: + node-version: "14" + # NPM: + # (unfortunately, we cannot use `npm ci` for now because of this bug: https://github.com/npm/cli/issues/558) + - name: Install and Build Log4brains (NPM) + run: | + npm install + npm run log4brains-build -- --basePath /${GITHUB_REPOSITORY#*/}/log4brains + # Yarn: + # - name: Install and Build Log4brains (Yarn) + # run: | + # yarn install --frozen-lockfile + # yarn log4brains-build --basePath /${GITHUB_REPOSITORY#*/}/log4brains + - name: Deploy + uses: JamesIves/github-pages-deploy-action@3.7.1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: gh-pages + FOLDER: .log4brains/out + TARGET_FOLDER: log4brains +``` + +After the first run, this workflow will create a `gh-pages` branch in your repository containing the generated static files to serve. +Then, we have to tell GitHub that we [don't want to use Jekyll](https://github.com/vercel/next.js/issues/2029), otherwise, you will get a 404 error: + +```bash +git checkout gh-pages +touch .nojekyll +git add .nojekyll +git commit -m "Add .nojekyll for Log4brains" +git push +``` + +Finally, you can [enable your GitHub page](https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site): + +- On GitHub, go to `Settings > GitHub Pages` +- Select the `gh-pages` branch as the "Source" +- Then, select the `/ (root)` folder + +You should now be able to see your knowledge base at `https://.github.io//log4brains/`. +It will be re-built and published every time you push on `master`. + +

+
+ +
+Publish to GitLab Pages with GitLab CI +

+ +Create your `.gitlab-ci.yml` and adapt it to your case: + +```yml +image: node:14-alpine3.12 +pages: + stage: deploy + variables: + GIT_DEPTH: 0 # required by Log4brains to work correctly (needs the whole Git history) + script: + - mkdir -p public + # NPM: + - npm install # unfortunately we cannot use `npm ci` for now because of this bug: https://github.com/npm/cli/issues/558 + - npm run log4brains-build -- --basePath /$CI_PROJECT_NAME/log4brains --out public/log4brains + # Yarn: + # - yarn install --frozen-lockfile + # - yarn log4brains-build --basePath /$CI_PROJECT_NAME/log4brains --out public/log4brains + artifacts: + paths: + - public + rules: + - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" +``` + +You should now be able to see your knowledge base at `https://.gitlab.io//log4brains/`. +It will be re-built and published every time you push on `master`. + +

+
+ +
+Publish to S3 +

+ +First, create a bucket with the "Static website hosting" feature enabled: + +```bash +# This is an example: replace with the bucket name of your choice +export BUCKET_NAME=yourcompany-yourproject-log4brains + +aws s3api create-bucket --acl public-read --bucket ${BUCKET_NAME} +read -r -d '' BUCKET_POLICY << EOP +{ + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::${BUCKET_NAME}/*" + } + ] +} +EOP +aws s3api put-bucket-policy --bucket ${BUCKET_NAME} --policy "$BUCKET_POLICY" +aws s3 website s3://${BUCKET_NAME} --index-document index.html +``` + +Then, configure your CI to run these commands: + +- Install Node and the AWS CLI +- Checkout your Git repository **with the full history**. Otherwise, Log4brains won't work correctly (see previous examples) +- `npm install` or `yarn install --frozen-lockfile` to install the dev dependencies (unfortunately we cannot use `npm ci` for now because of this [bug](https://github.com/npm/cli/issues/558)) +- `npm run log4brains-build` or `yarn log4brains-build` +- `aws s3 sync .log4brains/out s3:// --delete` + +Your knowledge base will be available on `http://.s3-website-.amazonaws.com/`. +You can get some inspiration on implementing this workflow for GitHub Actions or GitLab CI by looking at the previous examples. + +

+
+ +## ❓ FAQ + +### What are the prerequisites? + +- Node.js >= 10.23 +- NPM or Yarn +- Your project versioned in Git ([not necessarily a JS project!](#what-about-non-js-projects)) + +### What about multi-package projects? + +Log4brains supports both mono and multi packages projects. The `npx init-log4brains` command will prompt you regarding this. + +In the case of a multi-package project, you have two options: + +- Mono-repository: in this case, just install Log4brains in the root folder. It will manage "global ADRs", for example in `docs/adr` and "package-specific ADRs", for example in `packages//docs/adr`. +- One repository per package: in the future, Log4brains will handle this case with a central repository for the "global ADRs" while fetching "package-specifics ADRs" directly from each package repository. For the moment, all the ADRs have to be stored in a central repository. + +Here is an example of a typical file structure for each case: + +
+Simple mono-package project +

+ +``` +project-root +├── docs +| └── adr +| ├── 20200101-your-first-adr.md +| ├── 20200115-your-second-adr.md +| ├── [...] +| ├── index.md +| └── template.md +[...] +``` + +

+
+ +
+Multi-package project in a mono-repository +

+ +``` +project-root +├── docs +| └── adr +| ├── 20200101-your-first-global-adr.md +| ├── 20200115-your-second-global-adr.md +| ├── [...] +| ├── index.md +| └── template.md +├── packages +| ├── package1 +| | ├── docs +| | | └── adr +| | | ├── 20200102-your-first-package-specific-adr.md +| | | ├── 20200116-your-second-package-specific-adr.md +| | | [...] +| | [...] +| ├── package2 +| | ├── docs +| | | └── adr +| | | ├── [...] +| | | [...] +| | [...] +| [...] +[...] +``` + +

+
+ +
+Multi-package with one repository per package +

+ +For the moment in one central repository (specific for the docs, or not): + +``` +project-docs +├── adr +| ├── global +| | ├── 20200101-your-first-global-adr.md +| | ├── 20200115-your-second-global-adr.md +| | ├── [...] +| | ├── index.md +| | └── template.md +| ├── package1 +| | ├── 20200102-your-first-package-specific-adr.md +| | ├── 20200116-your-second-package-specific-adr.md +| | [...] +| ├── package2 +| | ├── [...] +| | [...] +| [...] +[...] +``` + +In the future: + +``` +project-docs +├── adr +| ├── 20200101-your-first-global-adr.md +| ├── 20200115-your-second-global-adr.md +| ├── [...] +| ├── index.md +| └── template.md +[...] + +repo1 +├── docs +| └── adr +| ├── 20200102-your-first-package-specific-adr.md +| ├── 20200116-your-second-package-specific-adr.md +| [...] +[...] + +repo2 +├── docs +| └── adr +| ├── [...] +| [...] +[...] +``` + +

+
+ +### What about non-JS projects? + +Even if Log4brains is developed with TypeScript and is part of the NPM ecosystem, it can be used for any kind of project, in any language. + +For projects that do not have a `package.json` file, you have to install Log4brains globally: + +```bash +npm install -g @log4brains-cli @log4brains-web +``` + +Create a `.log4brains.yml` file at the root of your project and [configure it](#how-to-configure-log4brainsyml). + +You can now use these global commands inside your project: + +- Create a new ADR: `log4brains adr new` +- Start the local web UI: `log4brains-web preview` +- Build the static version: `log4brains-web build` + +### How to configure `.log4brains.yml`? + +This file is usually automatically created when you run `npx init-log4brains` (cf [getting started](#-getting-started)), but you may need to configure it manually. + +Here is an example with just the required fields: + +```yaml +project: + name: Foo Bar # The name that should be displayed in the UI + tz: Europe/Paris # The timezone that you use for the dates in your ADR files + adrFolder: ./docs/adr # The location of your ADR files +``` + +If you have multiple packages in your project, you may want to support package-specific ADRs by setting the optional `project.packages` field: + +```yaml +project: + # [...] + packages: + - name: backend # The name (unique identifier) of the package + path: ./packages/backend # The location of its codebase + adrFolder: ./packages/backend/docs/adr # The location of its ADR files +# - ... +``` + +Another optional field is `project.repository`, which is normally automatically guessed by Log4brains to create links to GitHub, GitLab, etc. But in some cases, like for GitHub or GitLab enterprise, you have to configure it manually: + +```yaml +project: + # [...] + repository: + url: https://github.com/foo/bar # Absolute URL of your repository + provider: github # Supported providers: github, gitlab, bitbucket. Use `generic` if yours is not supported + viewFileUriPattern: /blob/%branch/%path # Only required for `generic` providers +``` + +## Contributing + +Pull Requests are more than welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for more details. You can also [create a new issue](https://github.com/thomvaill/log4brains/issues/new/choose) or [give your feedback](https://github.com/thomvaill/log4brains/discussions/new?category=Feedback). + +## Acknowledgments + +- [Next.js](https://github.com/vercel/next.js/), which is used under the hood to provide the web UI and the static site generation capability (look for `#NEXTJS-HACK` in the code to see the custom adaptations we had to make) +- Michael Nygard for all his work on [Architecture Decision Records](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) +- @boceckts and @koppor for the [MADR](https://adr.github.io/madr/) template +- [Tippawan Sookruay](https://thenounproject.com/wanny4/) for the Log4brains logo +- @npryce, who inspired me for the CLI part with his [adr-tools](https://github.com/npryce/adr-tools) bash CLI +- @mrwilson, who inspired me for the static site generation part with his [adr-viewer](https://github.com/mrwilson/adr-viewer) + +## License + +This project is licensed under the Apache 2.0 license, Copyright (c) 2020 Thomas Vaillant. See the [LICENSE](LICENSE) file for more information. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..5073c20d --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = { extends: ["@commitlint/config-conventional"] }; diff --git a/docs/Log4brains-logo-full.png b/docs/Log4brains-logo-full.png new file mode 100644 index 00000000..4fa2d7d6 Binary files /dev/null and b/docs/Log4brains-logo-full.png differ diff --git a/docs/adr/20200924-use-markdown-architectural-decision-records.md b/docs/adr/20200924-use-markdown-architectural-decision-records.md new file mode 100644 index 00000000..e4ad2f5b --- /dev/null +++ b/docs/adr/20200924-use-markdown-architectural-decision-records.md @@ -0,0 +1,37 @@ +# Use Markdown Architectural Decision Records + +- Status: accepted +- Date: 2020-09-24 + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 2.1.2 with Log4brains patch +- [MADR](https://adr.github.io/madr/) 2.1.2 – The original Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.2 with Log4brains patch", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. +- Version 2.1.2 is the latest one available when starting to document ADRs. +- The Log4brains patch adds more features, like tags. + +The "Log4brains patch" performs the following modifications to the original template: + +- Change the ADR filenames format (`NNN-adr-name` becomes `YYYYMMDD-adr-name`), to avoid conflicts during Git merges. +- Add a `draft` status, to enable collaborative writing. +- Add a `Tags` field. diff --git a/docs/adr/20200925-multi-packages-architecture-in-a-monorepo-with-yarn-and-lerna.md b/docs/adr/20200925-multi-packages-architecture-in-a-monorepo-with-yarn-and-lerna.md new file mode 100644 index 00000000..d83adb04 --- /dev/null +++ b/docs/adr/20200925-multi-packages-architecture-in-a-monorepo-with-yarn-and-lerna.md @@ -0,0 +1,40 @@ +# Multi-packages architecture in a monorepo with Yarn and Lerna + +- Status: accepted +- Date: 2020-09-25 + +## Context and Problem Statement + +We have to define the initial overall architecture of the project. +For now, we are sure that we want to provide these features: + +- Local preview web UI +- Static Site Generation from the CI/CD +- CLI to create a new ADR quickly + +In the future, we might want to provide these features: + +- Create/edit ADRs from the local web UI +- VSCode extension to create and maybe edit an ADR from the IDE +- Support ADR aggregation from multiple repositories + +## Considered Options + +- Monolith +- Multi-packages, multirepo +- Multi-packages, monorepo + - with NPM and scripts for links and publication + - with Yarn and scripts for publication + - with Yarn and Lerna + +## Decision Outcome + +Chosen option: "Multi-packages, monorepo, with Yarn and Lerna", because + +- We don't want a monolith because we want the core library/API to be very well tested and probably developed with DDD and hexagonal architecture. The other packages will just call this core API, they will contain fewer business rules as possible. As we are not so sure about the features we will provide in the future, this is good for extensibility. +- Yarn + Lerna seems to be a very good practice used by a lot of other open-source projects to publish npm packages. + +## Links + +- [A Beginner's Guide to Lerna with Yarn Workspaces](https://medium.com/@jsilvax/a-workflow-guide-for-lerna-with-yarn-workspaces-60f97481149d) +- [Step by Step Guide to create a Typescript Monorepo with Yarn Workspaces and Lerna](https://blog.usejournal.com/step-by-step-guide-to-create-a-typescript-monorepo-with-yarn-workspaces-and-lerna-a8ed530ecd6d) diff --git a/docs/adr/20200925-use-prettier-eslint-airbnb-for-the-code-style.md b/docs/adr/20200925-use-prettier-eslint-airbnb-for-the-code-style.md new file mode 100644 index 00000000..ad87368b --- /dev/null +++ b/docs/adr/20200925-use-prettier-eslint-airbnb-for-the-code-style.md @@ -0,0 +1,33 @@ +# Use Prettier-ESLint Airbnb for the code style + +- Status: accepted +- Date: 2020-09-25 + +## Context and Problem Statement + +We have to choose our lint and format tools, and the code style to enforce as well. + +## Considered Options + +- Prettier only +- ESLint only +- ESLint with Airbnb code style +- ESLint with StandardJS code style +- ESLint with Google code style +- Prettier-ESLint with Airbnb code style +- Prettier-ESLint with StandardJS code style +- Prettier-ESLint with Google code style + +## Decision Outcome + +Chosen option: "Prettier-ESLint with Airbnb code style", because + +- Airbnb code style is widely used (see [npm trends](https://www.npmtrends.com/eslint-config-airbnb-vs-eslint-config-google-vs-standard-vs-eslint-config-standard)) +- Prettier-ESLint enforce some additional code style. We like it because the more opinionated the code style is, the less debates there will be :-) + +In addition, we use also Prettier to format json and markdown files. + +### Positive Consequences + +- Developers are encouraged to use the [Prettier ESLint](https://marketplace.visualstudio.com/items?itemName=rvest.vs-code-prettier-eslint) and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) VSCode extensions while developing to auto-format the files on save +- And they are encouraged to use the [ESLint VS Code extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) as well to highlight linting issues while developing diff --git a/docs/adr/20200926-use-the-adr-number-as-its-unique-id.md b/docs/adr/20200926-use-the-adr-number-as-its-unique-id.md new file mode 100644 index 00000000..3728f4d6 --- /dev/null +++ b/docs/adr/20200926-use-the-adr-number-as-its-unique-id.md @@ -0,0 +1,25 @@ +# Use the ADR number as its unique ID + +- Status: superseded by [20201016-use-the-adr-slug-as-its-unique-id](20201016-use-the-adr-slug-as-its-unique-id.md) +- Date: 2020-09-26 + +## Context and Problem Statement + +We need to be able to identify uniquely an ADR, especially in these contexts: + +- Web: to build its URL +- CLI: to identify an ADR in a command argument (example: "edit", or "preview") + +## Considered Options + +- ADR number (ie. filename prefixed number, example: `0001-use-markdown-architectural-decision-records.md`) +- ADR filename +- ADR title + +## Decision Outcome + +Chosen option: "ADR number", because + +- It is possible to have duplicated titles +- The filename is too long to enter without autocompletion, but we could support it as a second possible identifier for the CLI in the future +- Other ADR tools like [adr-tools](https://github.com/npryce/adr-tools) already use the number as a unique ID diff --git a/docs/adr/20200927-avoid-default-exports.md b/docs/adr/20200927-avoid-default-exports.md new file mode 100644 index 00000000..4d53b9bd --- /dev/null +++ b/docs/adr/20200927-avoid-default-exports.md @@ -0,0 +1,13 @@ +# Avoid default exports + +- Status: accepted +- Date: 2020-09-27 + +## Decision + +We will avoid default exports in all our codebase, and use named exports instead. + +## Links + +- +- diff --git a/docs/adr/20201016-use-the-adr-slug-as-its-unique-id.md b/docs/adr/20201016-use-the-adr-slug-as-its-unique-id.md new file mode 100644 index 00000000..2cd9ced1 --- /dev/null +++ b/docs/adr/20201016-use-the-adr-slug-as-its-unique-id.md @@ -0,0 +1,30 @@ +# Use the ADR slug as its unique ID + +- Status: accepted +- Date: 2020-10-16 + +## Context and Problem Statement + +Currently, ADR files follow this format: `NNNN-adr-title.md`, with NNNN being an incremental number from `0000` to `9999`. +It causes an issue during a `git merge` when two developers have created a new ADR on their respective branch. +There is a conflict because [an ADR number must be unique](20200926-use-the-adr-number-as-its-unique-id.md). + +## Decision + +From now on, we won't use ADR numbers anymore. +An ADR will be uniquely identified by its slug (ie. its filename without the extension), and its filename will have the following format: `YYYYMMDD-adr-title.md`, with `YYYYMMDD` being the date of creation of the file. + +As a result, there won't have conflicts anymore and the files will still be correctly sorted in the IDE thanks to the date. + +Finally, the ADRs will be sorted with these rules (ordered by priority): + +1. By Date field, in the markdown file (if present) +2. By Git creation date (does not follow renames) +3. By file creation date if no versioned yet +4. By slug + +The core library is responsible for sorting. + +## Links + +- Supersedes [20200926-use-the-adr-number-as-its-unique-id](20200926-use-the-adr-number-as-its-unique-id.md) diff --git a/docs/adr/20201026-the-core-api-is-responsible-for-enhancing-the-adr-markdown-body-with-mdx.md b/docs/adr/20201026-the-core-api-is-responsible-for-enhancing-the-adr-markdown-body-with-mdx.md new file mode 100644 index 00000000..672f7641 --- /dev/null +++ b/docs/adr/20201026-the-core-api-is-responsible-for-enhancing-the-adr-markdown-body-with-mdx.md @@ -0,0 +1,33 @@ +# The core API is responsible for enhancing the ADR markdown body with MDX + +- Status: accepted +- Date: 2020-10-26 + +## Context and Problem Statement + +The markdown body of ADRs cannot be used as is, because: + +- Links between ADRs have to be replaced with correct URLs +- Header (status, date, deciders etc...) has to be rendered with specific components + +## Decision Drivers + +- Potential future development of a VSCode extension + +## Considered Options + +- Option 1: the UI is responsible +- Option 2: the core API is responsible (with MDX) + +## Decision Outcome + +Chosen option: "Option 2: the core API is responsible (with MDX)". +Because if we develop the VSCode extension, it is better to add more business logic into the core package, and it is better tested. + +### Positive Consequences + +- The metadata in the header is simply removed + +### Negative Consequences + +- Each UI package will have to implement its own Header component diff --git a/docs/adr/20201103-use-lunr-for-search.md b/docs/adr/20201103-use-lunr-for-search.md new file mode 100644 index 00000000..a1c89fd9 --- /dev/null +++ b/docs/adr/20201103-use-lunr-for-search.md @@ -0,0 +1,47 @@ +# Use Lunr for search + +- Status: accepted +- Date: 2020-11-03 + +## Context and Problem Statement + +We have to provide a search bar to perform full-text search on ADRs. + +## Decision Drivers + +- Works in preview mode AND in the statically built version +- Provides good fuzzy search and stemming capabilities +- Is fast enough to be able to show results while typing +- Does not consume too much CPU and RAM on the client-side, especially for the statically built version + +## Considered Options + +- Option 1: Fuse.js +- Option 2: Lunr.js + +## Decision Outcome + +Chosen option: "Option 2: Lunr.js". + +## Pros and Cons of the Options + +### Option 1: Fuse.js + + + +- Fast indexing +- Slow searching +- Only fuzzy search, no stemming + +### Option 2: Lunr.js + + + +- Slow indexing, but supports index serialization to pre-build them +- Fast searching +- Stemming, multi-language support +- Retrieves the position of the matched tokens + +## Links + +- diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..c387e977 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,33 @@ +# Architecture Decision Records + +ADRs are automatically published to our Log4brains architecture knowledge base: + +🔗 **** + +Please use this link to browse them. + +## Development + +To preview the knowledge base locally, run: + +```bash +npm run log4brains-preview +# OR +yarn log4brains-preview +``` + +In preview mode, the Hot Reload feature is enabled: any change you make to a markdown file is applied live in the UI. + +To create a new ADR interactively, run: + +```bash +npm run adr new +# OR +yarn adr new +``` + +## More information + +- [Log4brains documentation](https://github.com/thomvaill/log4brains/tree/master#readme) +- [What is an ADR and why should you use them](https://github.com/thomvaill/log4brains/tree/master#-what-is-an-adr-and-why-should-you-use-them) +- [ADR GitHub organization](https://adr.github.io/) diff --git a/docs/adr/index.md b/docs/adr/index.md new file mode 100644 index 00000000..bedb18bd --- /dev/null +++ b/docs/adr/index.md @@ -0,0 +1,36 @@ + + +# Architecture knowledge base + +Welcome 👋 to the architecture knowledge base of Log4brains. +You will find here all the Architecture Decision Records (ADR) of the project. + +## Definition and purpose + +> An Architectural Decision (AD) is a software design choice that addresses a functional or non-functional requirement that is architecturally significant. +> An Architectural Decision Record (ADR) captures a single AD, such as often done when writing personal notes or meeting minutes; the collection of ADRs created and maintained in a project constitutes its decision log. + +An ADR is immutable: only its status can change (i.e., become deprecated or superseded). That way, you can become familiar with the whole project history just by reading its decision log in chronological order. +Moreover, maintaining this documentation aims at: + +- 🚀 Improving and speeding up the onboarding of a new team member +- 🔭 Avoiding blind acceptance/reversal of a past decision (cf [Michael Nygard's famous article on ADRs](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.html)) +- 🤝 Formalizing the decision process of the team + +## Usage + +This website is automatically updated after a change on the `master` branch of the project's Git repository. +In fact, the developers manage this documentation directly with markdown files located next to their code, so it is more convenient for them to keep it up-to-date. +You can browse the ADRs by using the left menu or the search bar. + +The typical workflow of an ADR is the following: + +![ADR workflow](/l4b-static/adr-workflow.png) + +The decision process is entirely collaborative and backed by pull requests. + +## More information + +- [Log4brains documentation](https://github.com/thomvaill/log4brains/tree/master#readme) +- [What is an ADR and why should you use them](https://github.com/thomvaill/log4brains/tree/master#-what-is-an-adr-and-why-should-you-use-them) +- [ADR GitHub organization](https://adr.github.io/) diff --git a/docs/adr/template.md b/docs/adr/template.md new file mode 100644 index 00000000..35479fbc --- /dev/null +++ b/docs/adr/template.md @@ -0,0 +1,73 @@ +# [short title of solved problem and solution] + +- Status: [draft | proposed | rejected | accepted | deprecated | … | superseded by [xxx](yyyymmdd-xxx.md)] +- Deciders: [list everyone involved in the decision] +- Date: [YYYY-MM-DD when the decision was last updated] +- Tags: [space and/or comma separated list of tags] + +Technical Story: [description | ticket/issue URL] + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +## Decision Drivers + +- [driver 1, e.g., a force, facing concern, …] +- [driver 2, e.g., a force, facing concern, …] +- … + +## Considered Options + +- [option 1] +- [option 2] +- [option 3] +- … + +## Decision Outcome + +Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +### Positive Consequences + +- [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …] +- … + +### Negative Consequences + +- [e.g., compromising quality attribute, follow-up decisions required, …] +- … + +## Pros and Cons of the Options + +### [option 1] + +[example | description | pointer to more information | …] + +- Good, because [argument a] +- Good, because [argument b] +- Bad, because [argument c] +- … + +### [option 2] + +[example | description | pointer to more information | …] + +- Good, because [argument a] +- Good, because [argument b] +- Bad, because [argument c] +- … + +### [option 3] + +[example | description | pointer to more information | …] + +- Good, because [argument a] +- Good, because [argument b] +- Bad, because [argument c] +- … + +## Links + +- [Link type][link to adr] +- … diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 00000000..2efd4146 Binary files /dev/null and b/docs/demo.gif differ diff --git a/e2e-tests/e2e-launcher.js b/e2e-tests/e2e-launcher.js new file mode 100644 index 00000000..1f17e7cf --- /dev/null +++ b/e2e-tests/e2e-launcher.js @@ -0,0 +1,77 @@ +const execa = require("execa"); +const rimraf = require("rimraf"); +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const chalk = require("chalk"); +const { expect } = require("chai"); + +const fsP = fs.promises; + +process.env.NODE_ENV = "test"; + +const initBin = path.resolve( + path.join(__dirname, "../packages/init/dist/log4brains-init") +); + +// Inspired by Next.js's test/integration/create-next-app/index.test.js. Thank you! +async function usingTempDir(fn) { + const folder = await fsP.mkdtemp(path.join(os.tmpdir(), "log4brains-e2e-")); + console.log(chalk.bold(`${chalk.green("WORKDIR")} ${folder}`)); + try { + return await fn(folder); + } finally { + rimraf.sync(folder); + } +} + +async function run(file, arguments, cwd) { + console.log( + chalk.bold(`${chalk.green("RUN")} ${file} ${arguments.join(" ")}`) + ); + const childProcess = execa(file, arguments, { cwd }); + childProcess.stdout.pipe(process.stdout); + childProcess.stderr.pipe(process.stderr); + return await childProcess; +} + +(async () => { + await usingTempDir(async (cwd) => { + await run("npm", ["init", "--yes"], cwd); + await run("npm", ["install"], cwd); + await run(initBin, ["--defaults"], cwd); + + await run( + "npm", + ["run", "adr", "--", "new", "--quiet", '"E2E test ADR"'], + cwd + ); + + const adrListRes = await run( + "npm", + ["run", "adr", "--", "list", "--raw"], + cwd + ); + expect(adrListRes.stdout).to.contain( + "use-log4brains-to-manage-the-adrs", + "Log4brains ADR was not created by init" + ); + expect(adrListRes.stdout).to.contain( + "use-markdown-architectural-decision-records", + "MADR ADR was not created by init" + ); + expect(adrListRes.stdout).to.contain( + "E2E test ADR", + "E2E test ADR was not created" + ); + + // TODO: preview & build tests (https://github.com/thomvaill/log4brains/issues/2) + + console.log(chalk.bold.green("END")); + }); +})().catch((e) => { + console.error(""); + console.error(`${chalk.red.bold("== FATAL ERROR ==")}`); + console.error(e); + process.exit(1); +}); diff --git a/jest.config.base.js b/jest.config.base.js new file mode 100644 index 00000000..3f878c46 --- /dev/null +++ b/jest.config.base.js @@ -0,0 +1,4 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node" +}; diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000..6104fb47 --- /dev/null +++ b/lerna.json @@ -0,0 +1,14 @@ +{ + "version": "1.0.0-beta.0", + "npmClient": "yarn", + "useWorkspaces": true, + "packages": [ + "packages/*" + ], + "command": { + "version": { + "message": "chore(release): publish %s", + "allowBranch": "dev" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..72e4c855 --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "root", + "private": true, + "engines": { + "node": ">=10.23.0" + }, + "workspaces": [ + "packages/**" + ], + "scripts": { + "dev": "lerna run --parallel dev", + "build": "lerna run build", + "clean": "lerna run clean", + "typescript": "lerna run typescript", + "test": "lerna run --stream test", + "test:changed": "lerna run --stream --since HEAD test", + "lint": "lerna run --stream lint", + "format": "prettier-eslint \"$PWD/**/{.,}*.{js,jsx,ts,tsx,json,md}\" --list-different", + "format:fix": "yarn format --write", + "typedoc": "lerna run typedoc", + "adr": "./packages/cli/dist/log4brains adr", + "log4brains-preview": "./packages/web/dist/bin/log4brains-web preview", + "log4brains-preview:dev": "cross-env NODE_ENV=development yarn log4brains-preview", + "log4brains-build": "./packages/web/dist/bin/log4brains-web build", + "serve": "serve .log4brains/out", + "links": "lerna run --stream link", + "e2e": "node e2e-tests/e2e-launcher.js", + "lerna": "lerna" + }, + "devDependencies": { + "@commitlint/cli": "^11.0.0", + "@commitlint/config-conventional": "^11.0.0", + "@types/jest": "^26.0.14", + "@types/node": "^14.11.2", + "@types/rimraf": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^4.2.0", + "@typescript-eslint/parser": "^4.2.0", + "chai": "^4.2.0", + "chalk": "^4.1.0", + "cross-env": "^7.0.2", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^7.9.0", + "eslint-config-airbnb-typescript": "^10.0.0", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jest": "^24.0.2", + "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-react": "^7.20.6", + "eslint-plugin-react-hooks": "^4.1.2", + "eslint-plugin-sonarjs": "^0.5.0", + "execa": "^4.1.0", + "husky": "^4.3.5", + "jest": "^26.4.2", + "jest-mock-extended": "^1.0.10", + "lerna": "^3.22.1", + "lint-staged": "^10.5.3", + "nodemon": "^2.0.6", + "prettier": "^2.1.2", + "prettier-eslint-cli": "^5.0.0", + "rimraf": "^3.0.2", + "serve": "^11.3.2", + "ts-jest": "^26.4.0", + "typedoc": "0.17.0-3", + "typescript": "^4.0.3" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged", + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" + } + }, + "lint-staged": { + "*.{ts,tsx}": "eslint --max-warnings=0", + "*.{js,jsx,ts,tsx,json,md}": "prettier-eslint --list-different" + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + } +} diff --git a/packages/cli-common/.eslintrc.js b/packages/cli-common/.eslintrc.js new file mode 100644 index 00000000..1f6675ff --- /dev/null +++ b/packages/cli-common/.eslintrc.js @@ -0,0 +1,11 @@ +const path = require("path"); + +module.exports = { + env: { + node: true + }, + parserOptions: { + project: path.join(__dirname, "tsconfig.json") + }, + extends: ["../../.eslintrc"] +}; diff --git a/packages/cli-common/README.md b/packages/cli-common/README.md new file mode 100644 index 00000000..b237d77b --- /dev/null +++ b/packages/cli-common/README.md @@ -0,0 +1,18 @@ +# @log4brains/cli-common + +This package provides common features for all [Log4brains](https://github.com/thomvaill/log4brains) CLI-based packages. +It is not meant to be used directly in your project. + +## Installation + +This package is not meant to be installed directly in your project. This is a common dependency of [@log4brains/cli](https://www.npmjs.com/package/@log4brains/cli), [@log4brains/init](https://www.npmjs.com/package/@log4brains/init) and [@log4brains/web](https://www.npmjs.com/package/@log4brains/web), which is installed automatically. + +## Development + +```bash +yarn dev:test +``` + +## Documentation + +- [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) diff --git a/packages/cli-common/dev-tests/run.ts b/packages/cli-common/dev-tests/run.ts new file mode 100644 index 00000000..3bccf0ee --- /dev/null +++ b/packages/cli-common/dev-tests/run.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-console */ +import chalk from "chalk"; +import { AppConsole } from "../src/AppConsole"; + +function sleep(seconds: number) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +async function t(name: string, cb: () => void | Promise): Promise { + console.log(chalk.dim(`*** TEST: ${name}`)); + await cb(); + console.log(chalk.dim(`*** END`)); + console.log(); +} + +/** + * Visual tests for AppConsole + */ +void (async () => { + await t("print()", () => { + const appConsole = new AppConsole(); + appConsole.println("Line 1"); + appConsole.println("Line 2"); + appConsole.println(); + appConsole.println("Line 3 with new line"); + }); + + await t("debug() off", () => { + const appConsole = new AppConsole(); + appConsole.debug("This should not be printed"); + }); + + await t("debug() on", () => { + const appConsole = new AppConsole({ debug: true }); + appConsole.debug("Line 1"); + appConsole.debug("Line 2"); + }); + + await t("warn() with message", () => { + const appConsole = new AppConsole(); + appConsole.warn("This is a warning"); + appConsole.warn("This is a warning line2"); + }); + + await t("warn() with Error", () => { + const appConsole = new AppConsole(); + appConsole.warn(new Error("The message or the generic error")); + appConsole.warn(new RangeError("The message or the RangeError")); + }); + + await t("warn() with Error and traces on", () => { + const appConsole = new AppConsole({ traces: true }); + appConsole.warn(new Error("The message or the generic error")); + appConsole.warn(new RangeError("The message or the RangeError")); + }); + + await t("error() with message", () => { + const appConsole = new AppConsole(); + appConsole.error("This is an error"); + appConsole.error("This is an error line2"); + }); + + await t("error() with Error", () => { + const appConsole = new AppConsole(); + appConsole.error(new Error("The message or the generic error")); + appConsole.error(new RangeError("The message or the RangeError")); + }); + + await t("error() with Error and traces on", () => { + const appConsole = new AppConsole({ traces: true }); + appConsole.error(new Error("The message or the generic error")); + appConsole.error(new RangeError("The message or the RangeError")); + }); + + await t("fatal() with message", () => { + const appConsole = new AppConsole(); + appConsole.fatal("This is an error"); + appConsole.fatal("This is an error line2"); + }); + + await t("fatal() with Error", () => { + const appConsole = new AppConsole(); + appConsole.fatal(new Error("The message or the generic error")); + appConsole.fatal(new RangeError("The message or the RangeError")); + }); + + await t("fatal() with Error and traces on", () => { + const appConsole = new AppConsole({ traces: true }); + appConsole.fatal(new Error("The message or the generic error")); + appConsole.fatal(new RangeError("The message or the RangeError")); + }); + + await t("success()", () => { + const appConsole = new AppConsole(); + appConsole.success("Yeah! This is a success!"); + }); + + await t("table", () => { + const appConsole = new AppConsole(); + const table = appConsole.createTable({ head: ["Col 1", "Col 2"] }); + table.push(["Cell 1.1", "Cell 1.2"]); + table.push(["Cell 2.1", "Cell 2.2"]); + appConsole.printTable(table); + appConsole.printTable(table, true); + }); + + await t("askYesNoQuestion()", async () => { + const appConsole = new AppConsole(); + await appConsole.askYesNoQuestion("Do you like this script?", true); + }); + + await t("askInputQuestion()", async () => { + const appConsole = new AppConsole(); + await appConsole.askInputQuestion( + "Please enter something", + "default value" + ); + }); + + await t("askListQuestion()", async () => { + const appConsole = new AppConsole(); + await appConsole.askListQuestion("Please select something", [ + { name: "Option 1", value: "opt1", short: "O1" }, + { name: "Option 2", value: "opt2", short: "O2" } + ]); + }); + + await t("spinner", async () => { + const appConsole = new AppConsole(); + appConsole.startSpinner("The spinner is spinning..."); + await sleep(5); + appConsole.stopSpinner(); + appConsole.success("This is a success!"); + }); +})(); diff --git a/packages/cli-common/nodemon.json b/packages/cli-common/nodemon.json new file mode 100644 index 00000000..002240a7 --- /dev/null +++ b/packages/cli-common/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src", "dev-tests"], + "ext": "ts", + "exec": "clear && node -r esm -r ts-node/register ./dev-tests/run.ts" +} diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json new file mode 100644 index 00000000..a7a4cce2 --- /dev/null +++ b/packages/cli-common/package.json @@ -0,0 +1,52 @@ +{ + "name": "@log4brains/cli-common", + "version": "1.0.0-beta.0", + "description": "Log4brains architecture knowledge base common CLI features", + "keywords": [ + "log4brains" + ], + "author": "Thomas Vaillant ", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/thomvaill/log4brains", + "repository": { + "type": "git", + "url": "https://github.com/thomvaill/log4brains", + "directory": "packages/cli-common" + }, + "engines": { + "node": ">=10.23.0" + }, + "files": [ + "dist" + ], + "source": "./src/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.module.js", + "types": "./dist/index.d.ts", + "scripts": { + "dev": "microbundle --no-compress --format es,cjs --tsconfig tsconfig.build.json --target node watch", + "dev:test": "nodemon", + "build": "microbundle --no-compress --format es,cjs --tsconfig tsconfig.build.json --target node", + "clean": "rimraf ./dist", + "typescript": "tsc --noEmit", + "lint": "eslint . --max-warnings=0", + "prepublishOnly": "yarn build", + "link": "npm link && rm -f ./package-lock.json" + }, + "devDependencies": { + "@types/inquirer": "^7.3.1", + "esm": "^3.2.25", + "nodemon": "^2.0.6", + "ts-node": "^9.1.1" + }, + "dependencies": { + "chalk": "^4.1.0", + "cli-table3": "^0.6.0", + "inquirer": "^7.3.3", + "ora": "^5.1.0" + } +} diff --git a/packages/cli-common/src/AppConsole.ts b/packages/cli-common/src/AppConsole.ts new file mode 100644 index 00000000..7f626b20 --- /dev/null +++ b/packages/cli-common/src/AppConsole.ts @@ -0,0 +1,212 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable no-console */ +/* eslint-disable class-methods-use-this */ +import chalk from "chalk"; +import inquirer from "inquirer"; +import ora, { Ora } from "ora"; +import CliTable3, { Table } from "cli-table3"; +import { ConsoleCapturer } from "./ConsoleCapturer"; + +export type AppConsoleOptions = { + debug: boolean; + traces: boolean; +}; + +export type ChoiceDefinition = { + name: string; + value: V; + short?: string; +}; + +export class AppConsole { + private readonly opts: AppConsoleOptions; + + private spinner?: Ora; + + private spinnerConsoleCapturer = new ConsoleCapturer(); + + constructor(opts: Partial = {}) { + this.opts = { + debug: false, + traces: false, + ...opts + }; + } + + isSpinning(): boolean { + return !!this.spinner; + } + + startSpinner(message: string): void { + if (this.spinner) { + throw new Error("Spinner already started"); + } + this.spinner = ora({ + text: message, + spinner: "bouncingBar", + stream: process.stdout + }).start(); + + // Add capturing of console.log/warn/error to allow pausing + // the spinner before logging and then restarting spinner after + this.spinnerConsoleCapturer.onLog = (method, args) => { + this.spinner?.stop(); + method(...args); + this.spinner?.start(); + }; + this.spinnerConsoleCapturer.start(); + } + + updateSpinner(message: string): void { + if (!this.spinner) { + throw new Error("Spinner is not started"); + } + this.spinner.text = message; + } + + stopSpinner(withError = false): void { + if (!this.spinner) { + throw new Error("Spinner is not started"); + } + + this.spinnerConsoleCapturer.stop(); + + this.spinner.stopAndPersist({ + symbol: chalk.dim(withError ? "[== ]" : "[====]"), + text: `${this.spinner.text} ${withError ? chalk.red("Error") : "Done"}` + }); + this.println(); + this.spinner = undefined; + } + + println(message?: any, ...optionalParams: any[]): void { + console.log(message ?? "", ...optionalParams); + } + + printlnErr(message?: any, ...optionalParams: any[]): void { + console.error(message ?? "", ...optionalParams); + } + + debug(message?: any, ...optionalParams: any[]): void { + if (this.opts.debug) { + this.println( + chalk.dim(message), + ...optionalParams.map((p) => chalk.dim(p)) + ); + } + } + + warn(messageOrErr: string | Error): void { + if (messageOrErr instanceof Error) { + this.printlnErr(chalk.yellowBright(` ⚠ ${messageOrErr.message}`)); + if (this.opts.traces && messageOrErr.stack) { + this.printlnErr(chalk.yellow(messageOrErr.stack)); + this.printlnErr(); + } + } else { + this.printlnErr(chalk.yellowBright(` ⚠ ${messageOrErr}`)); + } + } + + error(messageOrErr: string | Error): void { + if (messageOrErr instanceof Error) { + this.printlnErr(chalk.redBright(` ✖ ${messageOrErr.message}`)); + if (this.opts.traces && messageOrErr.stack) { + this.printlnErr(chalk.red(messageOrErr.stack)); + this.printlnErr(); + } + } else { + this.printlnErr(chalk.redBright(` ✖ ${messageOrErr}`)); + } + } + + fatal(messageOrErr: string | Error): void { + this.printlnErr(); + if (messageOrErr instanceof Error) { + this.printlnErr( + `${chalk.bgRed.bold(" FATAL ")} ${chalk.redBright( + messageOrErr.message + )}` + ); + if (this.opts.traces && messageOrErr.stack) { + this.printlnErr(); + this.printlnErr(chalk.red(messageOrErr.stack)); + } + } else { + this.printlnErr( + `${chalk.bgRed.bold(" FATAL ")} ${chalk.redBright(messageOrErr)}` + ); + } + this.printlnErr(); + } + + success(message: string): void { + this.println(chalk.greenBright(` ✔ ${message}`)); + } + + createTable(options?: CliTable3.TableConstructorOptions): Table { + return new CliTable3({ + style: { + head: ["blue"] + }, + ...options + }); + } + + printTable(table: Table, raw = false): void { + if (raw) { + table.forEach((value) => { + if (typeof value === "object" && value instanceof Array) { + console.log(value.join(",")); + } else { + console.log(value); + } + }); + } else { + console.log(table.toString()); + } + } + + async askYesNoQuestion( + question: string, + defaultValue: boolean + ): Promise { + const answer = await inquirer.prompt<{ q: boolean }>([ + { type: "confirm", name: "q", message: question, default: defaultValue } + ]); + return answer.q; + } + + async askInputQuestion( + question: string, + defaultValue?: string + ): Promise { + const answer = await inquirer.prompt<{ q: string }>([ + { + type: "input", + name: "q", + message: question, + default: defaultValue + } + ]); + return answer.q; + } + + async askListQuestion( + question: string, + choices: ChoiceDefinition[], + defaultValue?: V + ): Promise { + const answer = await inquirer.prompt<{ q: V }>([ + { + type: "list", + name: "q", + message: question, + default: defaultValue, + choices + } + ]); + return answer.q; + } +} diff --git a/packages/cli-common/src/ConsoleCapturer.ts b/packages/cli-common/src/ConsoleCapturer.ts new file mode 100644 index 00000000..e3e4b7b1 --- /dev/null +++ b/packages/cli-common/src/ConsoleCapturer.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-console */ + +export type ConsoleLogMethod = typeof console.log; +export type ConsoleWarnMethod = typeof console.warn; +export type ConsoleErrorMethod = typeof console.error; +export type ConsoleMethod = + | ConsoleLogMethod + | ConsoleWarnMethod + | ConsoleErrorMethod; + +/** + * Captures console.log(), console.error() and console.warn() + * Source: https://github.com/vercel/next.js/blob/canary/packages/next/build/spinner.ts Thanks! + */ +export class ConsoleCapturer { + private origConsoleLog?: ConsoleLogMethod; + + private origConsoleWarn?: ConsoleWarnMethod; + + private origConsoleError?: ConsoleErrorMethod; + + onLog?: ( + method: ConsoleMethod, + args: any[], + stream: "stdout" | "stderr" + ) => void; + + start(): void { + this.origConsoleLog = console.log; + this.origConsoleWarn = console.warn; + this.origConsoleError = console.error; + + const logHandle = (method: ConsoleMethod, args: any[]) => { + if (this.onLog) { + this.onLog( + method, + args, + method === this.origConsoleLog ? "stdout" : "stderr" + ); + } + }; + + console.log = (...args: any) => logHandle(this.origConsoleLog!, args); + console.warn = (...args: any) => logHandle(this.origConsoleWarn!, args); + console.error = (...args: any) => logHandle(this.origConsoleError!, args); + } + + doPrintln(message?: any, ...optionalParams: any[]): void { + if (!this.origConsoleLog) { + throw new Error("ConsoleCapturer is not started"); + } + this.origConsoleLog(message ?? "", ...optionalParams); + } + + doPrintlnErr(message?: any, ...optionalParams: any[]): void { + if (!this.origConsoleError) { + throw new Error("ConsoleCapturer is not started"); + } + this.origConsoleError(message ?? "", ...optionalParams); + } + + stop(): void { + if ( + !this.origConsoleLog || + !this.origConsoleWarn || + !this.origConsoleError + ) { + throw new Error("ConsoleCapturer is not started"); + } + + console.log = this.origConsoleLog; + console.warn = this.origConsoleWarn; + console.error = this.origConsoleError; + + this.origConsoleLog = undefined; + this.origConsoleWarn = undefined; + this.origConsoleError = undefined; + } +} diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts new file mode 100644 index 00000000..10d66c81 --- /dev/null +++ b/packages/cli-common/src/index.ts @@ -0,0 +1,2 @@ +export * from "./AppConsole"; +export * from "./ConsoleCapturer"; diff --git a/packages/cli-common/tsconfig.build.json b/packages/cli-common/tsconfig.build.json new file mode 100644 index 00000000..43e2a2b9 --- /dev/null +++ b/packages/cli-common/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dev-tests", "**/*.test.ts"] +} diff --git a/packages/cli-common/tsconfig.json b/packages/cli-common/tsconfig.json new file mode 100644 index 00000000..b3313b7b --- /dev/null +++ b/packages/cli-common/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "dev-tests/**/*.ts"], + "exclude": ["node_modules"], + "ts-node": { "files": true } +} diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js new file mode 100644 index 00000000..1f6675ff --- /dev/null +++ b/packages/cli/.eslintrc.js @@ -0,0 +1,11 @@ +const path = require("path"); + +module.exports = { + env: { + node: true + }, + parserOptions: { + project: path.join(__dirname, "tsconfig.json") + }, + extends: ["../../.eslintrc"] +}; diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..231d2c12 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,49 @@ +# @log4brains/cli + +This package provides the CLI to use the [Log4brains](https://github.com/thomvaill/log4brains) architecture knowledge base in your project. + +## Installation + +You should use `npx init-log4brains` as described in the [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md), which will install all the required dependencies in your project, including this one, and set up the right scripts in your `package.json`. + +You can also install this package manually via npm or yarn: + +```bash +npm install --save-dev @log4brains/cli +``` + +or + +```bash +yarn add --dev @log4brains/cli +``` + +And add this script to your `package.json`: + +```json +{ + [...] + "scripts": { + [...] + "adr": "log4brains adr" + } +} +``` + +## Usage + +See [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md), or run this command in your project: + +```bash +npm run adr -- --help +``` + +or + +```bash +yarn adr --help +``` + +## Documentation + +- [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) diff --git a/packages/cli/nodemon.json b/packages/cli/nodemon.json new file mode 100644 index 00000000..e9329fd9 --- /dev/null +++ b/packages/cli/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts", + "exec": "yarn build" +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..dd0ba78c --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@log4brains/cli", + "version": "1.0.0-beta.0", + "description": "Log4brains architecture knowledge base CLI", + "keywords": [ + "log4brains" + ], + "author": "Thomas Vaillant ", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/thomvaill/log4brains", + "repository": { + "type": "git", + "url": "https://github.com/thomvaill/log4brains", + "directory": "packages/cli" + }, + "engines": { + "node": ">=10.23.0" + }, + "files": [ + "dist" + ], + "bin": { + "log4brains": "./dist/log4brains" + }, + "scripts": { + "dev": "nodemon", + "build": "tsc --build tsconfig.build.json && copyfiles -u 1 src/log4brains dist", + "clean": "rimraf ./dist", + "typescript": "tsc --noEmit", + "lint": "eslint . --max-warnings=0", + "prepublishOnly": "yarn build", + "link": "yarn link" + }, + "dependencies": { + "@log4brains/cli-common": "^1.0.0-beta.0", + "@log4brains/core": "^1.0.0-beta.0", + "commander": "^6.1.0", + "esm": "^3.2.25", + "execa": "^5.0.0", + "has-yarn": "^2.1.0", + "terminal-link": "^2.1.1" + }, + "devDependencies": { + "copyfiles": "^2.4.0" + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 00000000..d2ea7d94 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,73 @@ +import commander from "commander"; +import { Log4brains } from "@log4brains/core"; +import type { AppConsole } from "@log4brains/cli-common"; +import { + ListCommand, + ListCommandOpts, + NewCommand, + NewCommandOpts +} from "./commands"; + +type Deps = { + l4bInstance: Log4brains; + appConsole: AppConsole; + version: string; +}; + +export function createCli({ + l4bInstance, + appConsole, + version +}: Deps): commander.Command { + const program = new commander.Command(); + program.version(version); + + const adr = program + .command("adr") + .description("Manage the Architecture Decision Records (ADR)"); + + adr + .command("new [title]") + .description("Create an ADR", { + title: "The title of the ADR. Required if --quiet is passed" + }) + .option("-q, --quiet", "Disable interactive mode", false) + .option( + "-p, --package ", + "To create the ADR for a specific package" + ) + .option( + "--from ", + "Copy contents into the ADR instead of using the default template" + ) + .action( + (title: string | undefined, opts: NewCommandOpts): Promise => { + return new NewCommand({ l4bInstance, appConsole }).execute(opts, title); + } + ); + + // adr + // .command("quick") + // .description("Create a one-sentence ADR (Y-Statement)") + // .action( + // (): Promise => { + // // TODO + // } + // ); + + adr + .command("list") + .option( + "-s, --statuses ", + "Filter on the given statuses, comma-separated" + ) // TODO: list available statuses + .option("-r, --raw", "Use a raw format instead of a table", false) + .description("List ADRs") + .action( + (opts: ListCommandOpts): Promise => { + return new ListCommand({ l4bInstance, appConsole }).execute(opts); + } + ); + + return program; +} diff --git a/packages/cli/src/commands/ListCommand.ts b/packages/cli/src/commands/ListCommand.ts new file mode 100644 index 00000000..7c7a62cf --- /dev/null +++ b/packages/cli/src/commands/ListCommand.ts @@ -0,0 +1,43 @@ +import { Log4brains, SearchAdrsFilters, AdrDtoStatus } from "@log4brains/core"; +import type { AppConsole } from "@log4brains/cli-common"; + +type Deps = { + l4bInstance: Log4brains; + appConsole: AppConsole; +}; + +export type ListCommandOpts = { + statuses: string; + raw: boolean; +}; + +export class ListCommand { + private readonly l4bInstance: Log4brains; + + private readonly console: AppConsole; + + constructor({ l4bInstance, appConsole }: Deps) { + this.l4bInstance = l4bInstance; + this.console = appConsole; + } + + async execute(opts: ListCommandOpts): Promise { + const filters: SearchAdrsFilters = {}; + if (opts.statuses) { + filters.statuses = opts.statuses.split(",") as AdrDtoStatus[]; + } + const adrs = await this.l4bInstance.searchAdrs(filters); + const table = this.console.createTable({ + head: ["Slug", "Status", "Package", "Title"] + }); + adrs.forEach((adr) => { + table.push([ + adr.slug, + adr.status.toUpperCase(), + adr.package || "", + adr.title || "Untitled" + ]); + }); + this.console.printTable(table, opts.raw); + } +} diff --git a/packages/cli/src/commands/NewCommand.ts b/packages/cli/src/commands/NewCommand.ts new file mode 100644 index 00000000..cedb2e29 --- /dev/null +++ b/packages/cli/src/commands/NewCommand.ts @@ -0,0 +1,172 @@ +/* eslint-disable no-await-in-loop */ +import path from "path"; +import { Log4brains, Log4brainsError } from "@log4brains/core"; +import fs, { promises as fsP } from "fs"; +import type { AppConsole } from "@log4brains/cli-common"; +import { previewAdr } from "../utils"; + +type Deps = { + l4bInstance: Log4brains; + appConsole: AppConsole; +}; + +export type NewCommandOpts = { + quiet: boolean; + package?: string; + from?: string; +}; + +export class NewCommand { + private readonly l4bInstance: Log4brains; + + private readonly console: AppConsole; + + constructor({ l4bInstance, appConsole }: Deps) { + this.l4bInstance = l4bInstance; + this.console = appConsole; + } + + private detectCurrentPackageFromCwd(): string | undefined { + const { packages } = this.l4bInstance.config.project; + if (!packages) { + return undefined; + } + const cwd = path.resolve("."); + const match = packages + .filter((pkg) => cwd.includes(pkg.path)) + .sort((a, b) => a.path.length - b.path.length) + .pop(); // returns the most precise path (ie. longest) + return match?.name; + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + async execute(opts: NewCommandOpts, titleArg?: string): Promise { + const { packages } = this.l4bInstance.config.project; + + let pkg = opts.package; + if (!opts.quiet && !pkg && packages && packages.length > 0) { + const currentPackage = this.detectCurrentPackageFromCwd(); + const packageChoices = [ + { + name: `Global`, + value: "" + }, + ...packages.map((p) => ({ + name: `Package: ${p.name}`, + value: p.name + })) + ]; + pkg = + (await this.console.askListQuestion( + "For which package do you want to create this new ADR?", + packageChoices, + currentPackage + )) || undefined; + } + + if (opts.quiet && !titleArg) { + throw new Log4brainsError(" is required when using --quiet"); + } + let title; + do { + title = + titleArg || + (await this.console.askInputQuestion( + "Title of the solved problem and its solution?" + )); + if (!title.trim()) { + this.console.warn("Please enter a title"); + } + } while (!title.trim()); + + // const slug = await this.console.askInputQuestion( + // "We pre-generated a slug to identify this ADR. Press [ENTER] or enter another one.", + // await this.l4bInstance.generateAdrSlug(title, pkg) + // ); + const slug = await this.l4bInstance.generateAdrSlug(title, pkg); + + const adrDto = await this.l4bInstance.createAdrFromTemplate(slug, title); + + // --from option (used by init-log4brains to create the starter ADRs) + // Since this is a private use case, we don't include it in CORE for now + if (opts.from) { + if (!fs.existsSync(opts.from)) { + throw new Log4brainsError("The given file does not exist", opts.from); + } + // TODO: use streams + await fsP.writeFile( + adrDto.file.absolutePath, + await fsP.readFile(opts.from, "utf-8"), + "utf-8" + ); + } + + if (opts.quiet) { + this.console.println(adrDto.slug); + process.exit(0); + } + + const activeAdrs = await this.l4bInstance.searchAdrs({ + statuses: ["accepted"] + }); + if (activeAdrs.length > 0) { + const supersedeChoices = [ + { + name: "No", + value: "" + }, + ...activeAdrs.map((a) => ({ + name: a.title || "Untitled", // TODO: add package and maybe date + format with tabs + value: a.slug + })) + ]; + const supersededSlug = await this.console.askListQuestion( + "Does this ADR supersede a previous one?", + supersedeChoices, + "" + ); + + if (supersededSlug !== "") { + await this.l4bInstance.supersedeAdr(supersededSlug, slug); + this.console.debug( + `${supersededSlug} was marked as superseded by ${slug}` + ); + } + } + + this.console.println(); + this.console.success(`New ADR created: ${adrDto.file.relativePath}`); + this.console.println(); + + const actionChoices = [ + { + name: "Edit and preview", + value: "edit-and-preview" + }, + { name: "Edit", value: "edit" }, + { name: "Later", value: "close" } + ]; + const action = await this.console.askListQuestion( + "How would you like to edit it?", + actionChoices, + "edit-and-preview" + ); + + if (action === "edit-and-preview" || action === "edit") { + await this.l4bInstance.openAdrInEditor(slug, () => { + this.console.warn( + "We were not able to detect your preferred editor :(" + ); + this.console.warn( + "You can define it by setting your $VISUAL or $EDITOR environment variable in ~/.zshenv or ~/.bashrc" + ); + }); + + if (action === "edit-and-preview") { + await previewAdr(slug); + } + } + + process.exit(0); + } +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts new file mode 100644 index 00000000..f1e30848 --- /dev/null +++ b/packages/cli/src/commands/index.ts @@ -0,0 +1,2 @@ +export * from "./ListCommand"; +export * from "./NewCommand"; diff --git a/packages/cli/src/log4brains b/packages/cli/src/log4brains new file mode 100755 index 00000000..05abe630 --- /dev/null +++ b/packages/cli/src/log4brains @@ -0,0 +1,5 @@ +#!/usr/bin/env node +require = require("esm")(module, { + mainFields: ["module", "main"] +}); +module.exports = require("./main"); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 00000000..cbec4d68 --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,55 @@ +import fs from "fs"; +import path from "path"; +import terminalLink from "terminal-link"; +import { Log4brains, Log4brainsError } from "@log4brains/core"; +import { AppConsole } from "@log4brains/cli-common"; +import { createCli } from "./cli"; + +const templateExampleUrl = + "https://raw.githubusercontent.com/thomvaill/log4brains/master/packages/init/assets/template.md"; + +function findRootFolder(cwd: string): string { + if (fs.existsSync(path.join(cwd, ".log4brains.yml"))) { + return cwd; + } + if (path.resolve(cwd) === "/") { + throw new Error("Impossible to find a .log4brains.yml configuration file"); + } + return findRootFolder(path.join(cwd, "..")); +} + +const debug = !!process.env.DEBUG; +const dev = process.env.NODE_ENV === "development"; +const appConsole = new AppConsole({ debug, traces: debug || dev }); + +try { + // eslint-disable-next-line + const pkgVersion = require("../package.json").version as string; + + const l4bInstance = Log4brains.create( + findRootFolder(process.env.LOG4BRAINS_CWD || ".") + ); + + const cli = createCli({ version: pkgVersion, l4bInstance, appConsole }); + cli.parseAsync(process.argv).catch((err) => { + appConsole.fatal(err); + + if ( + err instanceof Log4brainsError && + err.name === "The template.md file does not exist" + ) { + appConsole.printlnErr( + `You can use this ${terminalLink( + "template", + templateExampleUrl + )} as an example` + ); + appConsole.printlnErr(); + } + + process.exit(1); + }); +} catch (e) { + appConsole.fatal(e); + process.exit(1); +} diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts new file mode 100644 index 00000000..d70e7568 --- /dev/null +++ b/packages/cli/src/utils.ts @@ -0,0 +1,15 @@ +import hasYarn from "has-yarn"; +import execa from "execa"; + +export async function previewAdr(slug: string): Promise<void> { + const subprocess = hasYarn() + ? execa("yarn", ["run", "log4brains-preview", slug], { + stdio: "inherit" + }) + : execa("npm", ["run", "--silent", "log4brains-preview", "--", slug], { + stdio: "inherit" + }); + subprocess.stdout?.pipe(process.stdout); + subprocess.stderr?.pipe(process.stderr); + await subprocess; +} diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 00000000..d4f56f83 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..8de03f52 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"], + "ts-node": { "files": true } +} diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js new file mode 100644 index 00000000..1f6675ff --- /dev/null +++ b/packages/core/.eslintrc.js @@ -0,0 +1,11 @@ +const path = require("path"); + +module.exports = { + env: { + node: true + }, + parserOptions: { + project: path.join(__dirname, "tsconfig.json") + }, + extends: ["../../.eslintrc"] +}; diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..075f5320 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,36 @@ +# @log4brains/core + +This package provides the core API of the [Log4brains](https://github.com/thomvaill/log4brains) architecture knowledge base. +It is not meant to be used directly in your project. + +## Installation + +This package is not meant to be installed directly in your project. This is a common dependency of [@log4brains/cli](https://www.npmjs.com/package/@log4brains/cli) and [@log4brains/web](https://www.npmjs.com/package/@log4brains/web), which is installed automatically. + +However, if you want to create a package to extend [Log4brains](https://github.com/thomvaill/log4brains)' capabilities, +you can include this package as a dependency of yours via npm or yarn: + +```bash +npm install --save @log4brains/core +``` + +or + +```bash +yarn add @log4brains/core +``` + +## Usage + +```typescript +import { Log4brains } from "@log4brains/core"; + +const l4b = Log4brains.create(process.cwd()); + +// See the TypeDoc documentation (TODO: to deploy on GitHub pages) to see available API methods +``` + +## Documentation + +- TypeDoc documentation (TODO) +- [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) diff --git a/packages/core/docs/.gitignore b/packages/core/docs/.gitignore new file mode 100644 index 00000000..9b470da0 --- /dev/null +++ b/packages/core/docs/.gitignore @@ -0,0 +1 @@ +typedoc diff --git a/packages/core/docs/adr/20201002-use-explicit-architecture-and-ddd-for-the-core-api.md b/packages/core/docs/adr/20201002-use-explicit-architecture-and-ddd-for-the-core-api.md new file mode 100644 index 00000000..e9ad3d2a --- /dev/null +++ b/packages/core/docs/adr/20201002-use-explicit-architecture-and-ddd-for-the-core-api.md @@ -0,0 +1,9 @@ +# Use Explicit Architecture and DDD for the core API + +- Status: accepted +- Date: 2020-10-02 + +As mentioned in [20200925-multi-packages-architecture-in-a-monorepo-with-yarn-and-lerna](../../../../docs/adr/20200925-multi-packages-architecture-in-a-monorepo-with-yarn-and-lerna.md), we want the core API to be well-tested because all the business logic will happen here. + +Herberto Graça did an awesome job but by putting together all the best practices of DDD, hexagonal architecture, onion architecture, clean architecture, CQRS... in what he calls [Explicit Architecture](https://herbertograca.com/tag/explicit-architecture/). +We will use this architecture for our core package. diff --git a/packages/core/docs/adr/20201003-markdown-parsing-is-part-of-the-domain.md b/packages/core/docs/adr/20201003-markdown-parsing-is-part-of-the-domain.md new file mode 100644 index 00000000..70a0df47 --- /dev/null +++ b/packages/core/docs/adr/20201003-markdown-parsing-is-part-of-the-domain.md @@ -0,0 +1,26 @@ +# Markdown parsing is part of the domain + +- Status: accepted +- Date: 2020-10-03 + +## Context and Problem Statement + +Development of the core domain. + +## Considered Options + +- Markdown is part of the domain +- Markdown is a technical detail, which should be developed in the infrastructure layer + +## Decision Outcome + +Chosen option: "Markdown is part of the domain" because we want to be able to parse it "smartly", without forcing a specific structure. +Therefore, a lot of business logic is involved and should be tested. + +### Positive Consequences + +- Test coverage of the markdown parsing + +### Negative Consequences + +- The business logic is tightly tied to the markdown format. It won't be possible to switch to another format easily in the future diff --git a/packages/core/docs/adr/20201027-adr-link-resolver-in-the-domain.md b/packages/core/docs/adr/20201027-adr-link-resolver-in-the-domain.md new file mode 100644 index 00000000..f15253ac --- /dev/null +++ b/packages/core/docs/adr/20201027-adr-link-resolver-in-the-domain.md @@ -0,0 +1,21 @@ +# ADR link resolver in the domain + +- Status: accepted +- Date: 2020-10-27 + +## Context and Problem Statement + +We have to translate markdown links between ADRs to static site links. +We cannot deduce the slug easily from the paths because of the "path / package" mapping, which is only known from the config. + +## Considered Options + +- Option 1: the ADR repository sets a Map on the ADR (`path -> slug`) for every link discovered in the Markdown +- Option 2: we introduce an "ADR link resolver" in the domain +- Option 3: we don't rely on link paths, but only on link labels (ie label must === slug) + +## Decision Outcome + +Chosen option: "Option 2: we introduce an "ADR link resolver" in the domain". +Because option 3 is too restrictive and option 1 seems too hacky. +And this solution is compatible with [20201003-markdown-parsing-is-part-of-the-domain](20201003-markdown-parsing-is-part-of-the-domain.md). diff --git a/packages/core/integration-tests/__snapshots__/ro.test.ts.snap b/packages/core/integration-tests/__snapshots__/ro.test.ts.snap new file mode 100644 index 00000000..aa6f30b6 --- /dev/null +++ b/packages/core/integration-tests/__snapshots__/ro.test.ts.snap @@ -0,0 +1,976 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E tests / RO getAdrBySlug() existing ADR 1`] = ` +Object { + "body": Object { + "enhancedMdx": " +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# First ADR + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-01 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20200101-first-adr.md", + "relativePath": "docs/adr/20200101-first-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-01T22:59:59.000Z", + "slug": "20200101-first-adr", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": "First ADR", +} +`; + +exports[`E2E tests / RO searchAdrs() all 1`] = ` +Array [ + "20200101-first-adr", + "package1/20200101-first-adr", + "20200102-adr-only-with-date", + "20200102-adr-with-intro", + "20200102-adr-without-status", + "20200102-adr-without-title", + "adr_with_a_WeIrd-filename", + "20201028-links", + "package1/20201028-links-in-package", + "package2/20201028-links-to-another-package", + "package1/20201028-links-to-global", + "20201028-superseded-adr", + "20201029-proposed-adr", + "20201029-rejected-adr", + "20201029-superseder", + "20201030-draft-adr", + "20201028-adr-with-no-metadata", + "20201028-adr-with-no-metadata-no-title", + "20201028-adr-without-date", + "package1/20201028-adr-without-date", +] +`; + +exports[`E2E tests / RO searchAdrs() all 2`] = ` +Array [ + Object { + "body": Object { + "enhancedMdx": " +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# First ADR + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-01 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20200101-first-adr.md", + "relativePath": "docs/adr/20200101-first-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-01T22:59:59.000Z", + "slug": "20200101-first-adr", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": "First ADR", + }, + Object { + "body": Object { + "enhancedMdx": " +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# First ADR (in package 1) + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-01 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/packages/package1/adr/20200101-first-adr.md", + "relativePath": "packages/package1/adr/20200101-first-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": "package1", + "publicationDate": "2020-01-01T22:59:59.000Z", + "slug": "package1/20200101-first-adr", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": "First ADR (in package 1)", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR only with date + +- Date: 2020-01-02 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20200102-adr-only-with-date.md", + "relativePath": "docs/adr/20200102-adr-only-with-date.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-02T22:59:59.000Z", + "slug": "20200102-adr-only-with-date", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "ADR only with date", + }, + Object { + "body": Object { + "enhancedMdx": " +This is an introduction paragraph. + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR with intro + +This is an introduction paragraph. + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-02 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20200102-adr-with-intro.md", + "relativePath": "docs/adr/20200102-adr-with-intro.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-02T22:59:59.000Z", + "slug": "20200102-adr-with-intro", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": "ADR with intro", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR without status + +- Deciders: John Doe +- Date: 2020-01-02 +- Tags: foo + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20200102-adr-without-status.md", + "relativePath": "docs/adr/20200102-adr-without-status.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-02T22:59:59.000Z", + "slug": "20200102-adr-without-status", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + ], + "title": "ADR without status", + }, + Object { + "body": Object { + "enhancedMdx": "Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "- Deciders: John Doe, Foo bar +- Date: 2020-01-02 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20200102-adr-without-title.md", + "relativePath": "docs/adr/20200102-adr-without-title.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-02T22:59:59.000Z", + "slug": "20200102-adr-without-title", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": null, + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR with a weird filename + +- Status: accepted +- Date: 2020-01-02 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/adr_with_a_WeIrd-filename.md", + "relativePath": "docs/adr/adr_with_a_WeIrd-filename.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-01-02T22:59:59.000Z", + "slug": "adr_with_a_WeIrd-filename", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "ADR with a weird filename", + }, + Object { + "body": Object { + "enhancedMdx": " +## Tests + +- Classic link: <AdrLink slug=\\"20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR\\" /> +- Relative link: <AdrLink slug=\\"20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR\\" /> +- <AdrLink slug=\\"20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR\\" customLabel=\\"Link with a custom text\\" /> +- [External link](https://www.google.com/) +- Broken link: [20200101-first-adr](20200101-first-adr-BROKEN.md) +- Link to a package: <AdrLink slug=\\"package1/20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR (in package 1)\\" package=\\"package1\\" /> +", + "rawMarkdown": "# Links + +- Date: 2020-10-28 + +## Tests + +- Classic link: [20200101-first-adr](20200101-first-adr.md) +- Relative link: [20200101-first-adr](./20200101-first-adr.md) +- [Link with a custom text](20200101-first-adr.md) +- [External link](https://www.google.com/) +- Broken link: [20200101-first-adr](20200101-first-adr-BROKEN.md) +- Link to a package: [package1/20200101-first-adr](../../packages/package1/adr/20200101-first-adr.md) +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201028-links.md", + "relativePath": "docs/adr/20201028-links.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-10-28T22:59:59.000Z", + "slug": "20201028-links", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "Links", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Test link with complete slug: <AdrLink slug=\\"package1/20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR (in package 1)\\" package=\\"package1\\" /> +Test link with partial slug: <AdrLink slug=\\"package1/20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR (in package 1)\\" package=\\"package1\\" /> +<AdrLink slug=\\"package1/20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR (in package 1)\\" package=\\"package1\\" customLabel=\\"Custom text and relative path\\" /> + +## Decision Outcome + +- Relates to <AdrLink slug=\\"package1/20201028-links-to-global\\" status=\\"accepted\\" title=\\"Links to global\\" package=\\"package1\\" /> +", + "rawMarkdown": "# Links in package + +- Date: 2020-10-28 + +## Context and Problem Statement + +Test link with complete slug: [package1/20200101-first-adr](20200101-first-adr.md) +Test link with partial slug: [20200101-first-adr](20200101-first-adr.md) +[Custom text and relative path](./20200101-first-adr.md) + +## Decision Outcome + +- Relates to [package1/20201028-links-to-global](20201028-links-to-global.md) +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/packages/package1/adr/20201028-links-in-package.md", + "relativePath": "packages/package1/adr/20201028-links-in-package.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": "package1", + "publicationDate": "2020-10-28T22:59:59.000Z", + "slug": "package1/20201028-links-in-package", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "Links in package", + }, + Object { + "body": Object { + "enhancedMdx": " +## Test + +Test link: <AdrLink slug=\\"package1/20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR (in package 1)\\" package=\\"package1\\" /> +<AdrLink slug=\\"package1/20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR (in package 1)\\" package=\\"package1\\" customLabel=\\"Custom text\\" /> +", + "rawMarkdown": "# Links to another package + +- Date: 2020-10-28 + +## Test + +Test link: [package1/20200101-first-adr](../../package1/adr/20200101-first-adr.md) +[Custom text](../../package1/adr/20200101-first-adr.md) +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/packages/package2/adr/20201028-links-to-another-package.md", + "relativePath": "packages/package2/adr/20201028-links-to-another-package.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": "package2", + "publicationDate": "2020-10-28T22:59:59.000Z", + "slug": "package2/20201028-links-to-another-package", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "Links to another package", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. Test link: <AdrLink slug=\\"20200101-first-adr\\" status=\\"accepted\\" title=\\"First ADR\\" /> + +## Decision Outcome + +- Relates to <AdrLink slug=\\"20201028-links\\" status=\\"accepted\\" title=\\"Links\\" /> +", + "rawMarkdown": "# Links to global + +- Date: 2020-10-28 + +## Context and Problem Statement + +Lorem ipsum. Test link: [20200101-first-adr](../../docs/adr/20200101-first-adr.md) + +## Decision Outcome + +- Relates to [20201028-links](../../docs/adr/20201028-links.md) +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/packages/package1/adr/20201028-links-to-global.md", + "relativePath": "packages/package1/adr/20201028-links-to-global.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": "package1", + "publicationDate": "2020-10-28T22:59:59.000Z", + "slug": "package1/20201028-links-to-global", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "Links to global", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# Superseded ADR + +- Status: superseded by [20201029-superseder](20201029-superseder.md) +- Date: 2020-10-28 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201028-superseded-adr.md", + "relativePath": "docs/adr/20201028-superseded-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-10-28T22:59:59.000Z", + "slug": "20201028-superseded-adr", + "status": "superseded", + "supersededBy": "20201029-superseder", + "tags": Array [], + "title": "Superseded ADR", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# Proposed ADR + +- Status: proposed +- Date: 2020-10-29 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201029-proposed-adr.md", + "relativePath": "docs/adr/20201029-proposed-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-10-29T22:59:59.000Z", + "slug": "20201029-proposed-adr", + "status": "proposed", + "supersededBy": null, + "tags": Array [], + "title": "Proposed ADR", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# Rejected ADR + +- Status: rejected +- Date: 2020-10-29 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201029-rejected-adr.md", + "relativePath": "docs/adr/20201029-rejected-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-10-29T22:59:59.000Z", + "slug": "20201029-rejected-adr", + "status": "rejected", + "supersededBy": null, + "tags": Array [], + "title": "Rejected ADR", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. + +## Links + +- Supersedes <AdrLink slug=\\"20201028-superseded-adr\\" status=\\"superseded\\" title=\\"Superseded ADR\\" /> +", + "rawMarkdown": "# Superseder + +- Status: accepted +- Date: 2020-10-29 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. + +## Links + +- Supersedes [20201028-superseded-adr](20201028-superseded-adr.md) +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201029-superseder.md", + "relativePath": "docs/adr/20201029-superseder.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-10-29T22:59:59.000Z", + "slug": "20201029-superseder", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "Superseder", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# Draft ADR + +- Status: draft +- Date: 2020-10-30 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201030-draft-adr.md", + "relativePath": "docs/adr/20201030-draft-adr.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": "2020-10-30T22:59:59.000Z", + "slug": "20201030-draft-adr", + "status": "draft", + "supersededBy": null, + "tags": Array [], + "title": "Draft ADR", + }, + Object { + "body": Object { + "enhancedMdx": " +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR with no metadata + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201028-adr-with-no-metadata.md", + "relativePath": "docs/adr/20201028-adr-with-no-metadata.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": null, + "slug": "20201028-adr-with-no-metadata", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": "ADR with no metadata", + }, + Object { + "body": Object { + "enhancedMdx": "## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201028-adr-with-no-metadata-no-title.md", + "relativePath": "docs/adr/20201028-adr-with-no-metadata-no-title.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": null, + "slug": "20201028-adr-with-no-metadata-no-title", + "status": "accepted", + "supersededBy": null, + "tags": Array [], + "title": null, + }, + Object { + "body": Object { + "enhancedMdx": " +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR without date + +- Status: accepted +- Deciders: John Doe, Foo bar +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/docs/adr/20201028-adr-without-date.md", + "relativePath": "docs/adr/20201028-adr-without-date.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": null, + "publicationDate": null, + "slug": "20201028-adr-without-date", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": "ADR without date", + }, + Object { + "body": Object { + "enhancedMdx": " +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + "rawMarkdown": "# ADR without date (in package 1) + +- Status: accepted +- Deciders: John Doe, Foo bar +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. +", + }, + "creationDate": "2020-11-19T11:33:33.000Z", + "deciders": Array [ + "John Doe", + "Foo bar", + ], + "file": Object { + "absolutePath": "/ABSOLUTE-PATH/ro-project/packages/package1/adr/20201028-adr-without-date.md", + "relativePath": "packages/package1/adr/20201028-adr-without-date.md", + }, + "lastEditAuthor": "Thomas Vaillant", + "lastEditDate": "2020-11-19T11:33:33.000Z", + "package": "package1", + "publicationDate": null, + "slug": "package1/20201028-adr-without-date", + "status": "accepted", + "supersededBy": null, + "tags": Array [ + "foo", + "bar", + ], + "title": "ADR without date (in package 1)", + }, +] +`; diff --git a/packages/core/integration-tests/__snapshots__/rw.test.ts.snap b/packages/core/integration-tests/__snapshots__/rw.test.ts.snap new file mode 100644 index 00000000..c970eef6 --- /dev/null +++ b/packages/core/integration-tests/__snapshots__/rw.test.ts.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E tests / RW createAdrFromTemplate() in global scope 1`] = ` +" +Technical Story: [description | ticket/issue URL] <!-- optional --> + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +<!-- TRUNCATED FOR TESTS --> + +## Decision Outcome + +Chosen option: \\"[option 1]\\", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +<!-- TRUNCATED FOR TESTS --> + +## Links <!-- optional --> + +- [Link type][link to adr] <!-- example: Refined by [ADR-0005](0005-example.md) --> +- … <!-- numbers of links can vary --> +" +`; + +exports[`E2E tests / RW createAdrFromTemplate() in package with custom template 1`] = ` +" +## Context and Problem Statement + +This is a custom template for this package. +" +`; + +exports[`E2E tests / RW createAdrFromTemplate() in package with global template 1`] = ` +" +Technical Story: [description | ticket/issue URL] <!-- optional --> + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +<!-- TRUNCATED FOR TESTS --> + +## Decision Outcome + +Chosen option: \\"[option 1]\\", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +<!-- TRUNCATED FOR TESTS --> + +## Links <!-- optional --> + +- [Link type][link to adr] <!-- example: Refined by [ADR-0005](0005-example.md) --> +- … <!-- numbers of links can vary --> +" +`; + +exports[`E2E tests / RW supersedeAdr() basic 1`] = ` +" +Technical Story: [description | ticket/issue URL] <!-- optional --> + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +<!-- TRUNCATED FOR TESTS --> + +## Decision Outcome + +Chosen option: \\"[option 1]\\", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +<!-- TRUNCATED FOR TESTS --> + +## Links <!-- optional --> + +- [Link type][link to adr] <!-- example: Refined by [ADR-0005](0005-example.md) --> +- Supersedes <AdrLink slug=\\"superseded\\" status=\\"superseded\\" title=\\"Superseded\\" /> +- … <!-- numbers of links can vary --> +" +`; diff --git a/packages/core/integration-tests/ro-project/.log4brains.yml b/packages/core/integration-tests/ro-project/.log4brains.yml new file mode 100644 index 00000000..7aa7a521 --- /dev/null +++ b/packages/core/integration-tests/ro-project/.log4brains.yml @@ -0,0 +1,13 @@ +--- +project: + name: log4brains-tests-ro + tz: Europe/Paris + adrFolder: ./docs/adr + + packages: + - name: package1 + path: ./packages/package1 + adrFolder: ./packages/package1/adr + - name: package2 + path: ./packages/package2 + adrFolder: ./packages/package2/adr diff --git a/packages/core/integration-tests/ro-project/docs/adr/20200101-first-adr.md b/packages/core/integration-tests/ro-project/docs/adr/20200101-first-adr.md new file mode 100644 index 00000000..d3b906ac --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20200101-first-adr.md @@ -0,0 +1,16 @@ +# First ADR + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-01 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-only-with-date.md b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-only-with-date.md new file mode 100644 index 00000000..ae9c3136 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-only-with-date.md @@ -0,0 +1,11 @@ +# ADR only with date + +- Date: 2020-01-02 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-with-intro.md b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-with-intro.md new file mode 100644 index 00000000..8187a687 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-with-intro.md @@ -0,0 +1,18 @@ +# ADR with intro + +This is an introduction paragraph. + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-02 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-without-status.md b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-without-status.md new file mode 100644 index 00000000..26ce375e --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-without-status.md @@ -0,0 +1,13 @@ +# ADR without status + +- Deciders: John Doe +- Date: 2020-01-02 +- Tags: foo + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-without-title.md b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-without-title.md new file mode 100644 index 00000000..858ed5b2 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20200102-adr-without-title.md @@ -0,0 +1,13 @@ +- Deciders: John Doe, Foo bar +- Date: 2020-01-02 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-with-no-metadata-no-title.md b/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-with-no-metadata-no-title.md new file mode 100644 index 00000000..a2e91dd8 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-with-no-metadata-no-title.md @@ -0,0 +1,7 @@ +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-with-no-metadata.md b/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-with-no-metadata.md new file mode 100644 index 00000000..41e56bdb --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-with-no-metadata.md @@ -0,0 +1,9 @@ +# ADR with no metadata + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-without-date.md b/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-without-date.md new file mode 100644 index 00000000..a65000f2 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201028-adr-without-date.md @@ -0,0 +1,15 @@ +# ADR without date + +- Status: accepted +- Deciders: John Doe, Foo bar +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201028-links.md b/packages/core/integration-tests/ro-project/docs/adr/20201028-links.md new file mode 100644 index 00000000..dd4326cb --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201028-links.md @@ -0,0 +1,12 @@ +# Links + +- Date: 2020-10-28 + +## Tests + +- Classic link: [20200101-first-adr](20200101-first-adr.md) +- Relative link: [20200101-first-adr](./20200101-first-adr.md) +- [Link with a custom text](20200101-first-adr.md) +- [External link](https://www.google.com/) +- Broken link: [20200101-first-adr](20200101-first-adr-BROKEN.md) +- Link to a package: [package1/20200101-first-adr](../../packages/package1/adr/20200101-first-adr.md) diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201028-superseded-adr.md b/packages/core/integration-tests/ro-project/docs/adr/20201028-superseded-adr.md new file mode 100644 index 00000000..f8360d86 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201028-superseded-adr.md @@ -0,0 +1,12 @@ +# Superseded ADR + +- Status: superseded by [20201029-superseder](20201029-superseder.md) +- Date: 2020-10-28 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201029-proposed-adr.md b/packages/core/integration-tests/ro-project/docs/adr/20201029-proposed-adr.md new file mode 100644 index 00000000..5806323e --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201029-proposed-adr.md @@ -0,0 +1,12 @@ +# Proposed ADR + +- Status: proposed +- Date: 2020-10-29 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201029-rejected-adr.md b/packages/core/integration-tests/ro-project/docs/adr/20201029-rejected-adr.md new file mode 100644 index 00000000..ee6729c8 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201029-rejected-adr.md @@ -0,0 +1,12 @@ +# Rejected ADR + +- Status: rejected +- Date: 2020-10-29 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201029-superseder.md b/packages/core/integration-tests/ro-project/docs/adr/20201029-superseder.md new file mode 100644 index 00000000..646d688c --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201029-superseder.md @@ -0,0 +1,16 @@ +# Superseder + +- Status: accepted +- Date: 2020-10-29 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. + +## Links + +- Supersedes [20201028-superseded-adr](20201028-superseded-adr.md) diff --git a/packages/core/integration-tests/ro-project/docs/adr/20201030-draft-adr.md b/packages/core/integration-tests/ro-project/docs/adr/20201030-draft-adr.md new file mode 100644 index 00000000..f0e05700 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/20201030-draft-adr.md @@ -0,0 +1,12 @@ +# Draft ADR + +- Status: draft +- Date: 2020-10-30 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/adr_with_a_WeIrd-filename.md b/packages/core/integration-tests/ro-project/docs/adr/adr_with_a_WeIrd-filename.md new file mode 100644 index 00000000..e2317b94 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/adr_with_a_WeIrd-filename.md @@ -0,0 +1,12 @@ +# ADR with a weird filename + +- Status: accepted +- Date: 2020-01-02 + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/docs/adr/template.md b/packages/core/integration-tests/ro-project/docs/adr/template.md new file mode 100644 index 00000000..56468207 --- /dev/null +++ b/packages/core/integration-tests/ro-project/docs/adr/template.md @@ -0,0 +1,3 @@ +# [short title of solved problem and solution] + +<!-- TRUNCATED FOR TESTS --> diff --git a/packages/core/integration-tests/ro-project/packages/package1/adr/20200101-first-adr.md b/packages/core/integration-tests/ro-project/packages/package1/adr/20200101-first-adr.md new file mode 100644 index 00000000..aec161db --- /dev/null +++ b/packages/core/integration-tests/ro-project/packages/package1/adr/20200101-first-adr.md @@ -0,0 +1,16 @@ +# First ADR (in package 1) + +- Status: accepted +- Deciders: John Doe, Foo bar +- Date: 2020-01-01 +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-adr-without-date.md b/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-adr-without-date.md new file mode 100644 index 00000000..24071253 --- /dev/null +++ b/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-adr-without-date.md @@ -0,0 +1,15 @@ +# ADR without date (in package 1) + +- Status: accepted +- Deciders: John Doe, Foo bar +- Tags: foo, bar + +Technical Story: lorem ipsum + +## Context and Problem Statement + +Lorem ipsum. + +## Decision Outcome + +Lorem ipsum. diff --git a/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-links-in-package.md b/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-links-in-package.md new file mode 100644 index 00000000..4919d6a1 --- /dev/null +++ b/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-links-in-package.md @@ -0,0 +1,13 @@ +# Links in package + +- Date: 2020-10-28 + +## Context and Problem Statement + +Test link with complete slug: [package1/20200101-first-adr](20200101-first-adr.md) +Test link with partial slug: [20200101-first-adr](20200101-first-adr.md) +[Custom text and relative path](./20200101-first-adr.md) + +## Decision Outcome + +- Relates to [package1/20201028-links-to-global](20201028-links-to-global.md) diff --git a/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-links-to-global.md b/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-links-to-global.md new file mode 100644 index 00000000..14049df2 --- /dev/null +++ b/packages/core/integration-tests/ro-project/packages/package1/adr/20201028-links-to-global.md @@ -0,0 +1,11 @@ +# Links to global + +- Date: 2020-10-28 + +## Context and Problem Statement + +Lorem ipsum. Test link: [20200101-first-adr](../../docs/adr/20200101-first-adr.md) + +## Decision Outcome + +- Relates to [20201028-links](../../docs/adr/20201028-links.md) diff --git a/packages/core/integration-tests/ro-project/packages/package1/adr/template.md b/packages/core/integration-tests/ro-project/packages/package1/adr/template.md new file mode 100644 index 00000000..56468207 --- /dev/null +++ b/packages/core/integration-tests/ro-project/packages/package1/adr/template.md @@ -0,0 +1,3 @@ +# [short title of solved problem and solution] + +<!-- TRUNCATED FOR TESTS --> diff --git a/packages/core/integration-tests/ro-project/packages/package2/adr/20201028-links-to-another-package.md b/packages/core/integration-tests/ro-project/packages/package2/adr/20201028-links-to-another-package.md new file mode 100644 index 00000000..3d65a5a6 --- /dev/null +++ b/packages/core/integration-tests/ro-project/packages/package2/adr/20201028-links-to-another-package.md @@ -0,0 +1,8 @@ +# Links to another package + +- Date: 2020-10-28 + +## Test + +Test link: [package1/20200101-first-adr](../../package1/adr/20200101-first-adr.md) +[Custom text](../../package1/adr/20200101-first-adr.md) diff --git a/packages/core/integration-tests/ro.test.ts b/packages/core/integration-tests/ro.test.ts new file mode 100644 index 00000000..8ed0d579 --- /dev/null +++ b/packages/core/integration-tests/ro.test.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import moment from "moment"; +import path from "path"; +import { Log4brains } from "../src/infrastructure/api/Log4brains"; +import { forceUnixPath } from "../src/lib/paths"; + +function prepareDataForSnapshot(data: any): any { + const json = JSON.stringify(data); + return JSON.parse( + json.replace( + new RegExp(forceUnixPath(path.resolve(__dirname)), "g"), + "/ABSOLUTE-PATH" + ) + ); +} + +describe("E2E tests / RO", () => { + jest.setTimeout(1000 * 15); + + const instance = Log4brains.create(path.join(__dirname, "ro-project")); + + describe("searchAdrs()", () => { + test("all", async () => { + const adrs = await instance.searchAdrs(); + expect(adrs.map((adr) => adr.slug)).toMatchSnapshot(); // To see easily the order + expect(prepareDataForSnapshot(adrs)).toMatchSnapshot(); + }); + + test("with filter on statuses", async () => { + const acceptedAdrs = await instance.searchAdrs({ + statuses: ["accepted"] + }); + expect( + acceptedAdrs.every((adr) => adr.status === "accepted") + ).toBeTruthy(); + + const supersededAdrs = await instance.searchAdrs({ + statuses: ["superseded"] + }); + expect( + supersededAdrs.every((adr) => adr.status === "superseded") + ).toBeTruthy(); + + const acceptedAndSupersededAdrs = await instance.searchAdrs({ + statuses: ["accepted", "superseded"] + }); + expect( + acceptedAndSupersededAdrs.every( + (adr) => adr.status === "accepted" || adr.status === "superseded" + ) + ).toBeTruthy(); + + const acceptedAdrSlugs = acceptedAdrs.map((adr) => adr.slug); + const supersededAdrSlugs = supersededAdrs.map((adr) => adr.slug); + const acceptedAndSupersededAdrSlugs = acceptedAndSupersededAdrs.map( + (adr) => adr.slug + ); + const a = [...acceptedAdrSlugs, ...supersededAdrSlugs].sort(); + const b = [...acceptedAndSupersededAdrSlugs].sort(); + expect(b).toEqual(a); + }); + }); + + describe("getAdrBySlug()", () => { + test("existing ADR", async () => { + const adr = await instance.getAdrBySlug("20200101-first-adr"); + expect(prepareDataForSnapshot(adr)).toMatchSnapshot(); + }); + + test("unknown ADR", async () => { + const adr = await instance.getAdrBySlug("unknown"); + expect(adr).toBeUndefined(); + }); + }); + + describe("generateAdrSlug()", () => { + test("in global scope", async () => { + const date = moment().format("YYYYMMDD"); + expect(await instance.generateAdrSlug("My end-to-end test !")).toEqual( + `${date}-my-end-to-end-test` + ); + }); + + test("in a package", async () => { + const date = moment().format("YYYYMMDD"); + expect( + await instance.generateAdrSlug("My end-to-end test !", "package1") + ).toEqual(`package1/${date}-my-end-to-end-test`); + }); + }); +}); diff --git a/packages/core/integration-tests/rw-project/.gitignore b/packages/core/integration-tests/rw-project/.gitignore new file mode 100644 index 00000000..b03ae66a --- /dev/null +++ b/packages/core/integration-tests/rw-project/.gitignore @@ -0,0 +1,2 @@ +*.md +!template.md diff --git a/packages/core/integration-tests/rw-project/.log4brains.yml b/packages/core/integration-tests/rw-project/.log4brains.yml new file mode 100644 index 00000000..5400b22a --- /dev/null +++ b/packages/core/integration-tests/rw-project/.log4brains.yml @@ -0,0 +1,13 @@ +--- +project: + name: log4brains-tests-rw + tz: Europe/Paris + adrFolder: ./docs/adr + + packages: + - name: package1 + path: ./packages/package1 + adrFolder: ./packages/package1/adr + - name: package2 + path: ./packages/package2 + adrFolder: ./packages/package2/adr diff --git a/packages/core/integration-tests/rw-project/docs/adr/template.md b/packages/core/integration-tests/rw-project/docs/adr/template.md new file mode 100644 index 00000000..8db4c0b8 --- /dev/null +++ b/packages/core/integration-tests/rw-project/docs/adr/template.md @@ -0,0 +1,25 @@ +# [short title of solved problem and solution] + +- Status: [draft | proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] <!-- optional --> +- Deciders: [list everyone involved in the decision] <!-- optional --> +- Date: [YYYY-MM-DD when the decision was last updated] <!-- optional - changes the order displayed in the UI --> +- Tags: [space and/or comma separated list of tags] <!-- optional --> + +Technical Story: [description | ticket/issue URL] <!-- optional --> + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +<!-- TRUNCATED FOR TESTS --> + +## Decision Outcome + +Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +<!-- TRUNCATED FOR TESTS --> + +## Links <!-- optional --> + +- [Link type][link to adr] <!-- example: Refined by [ADR-0005](0005-example.md) --> +- … <!-- numbers of links can vary --> diff --git a/packages/core/integration-tests/rw-project/packages/package1/adr/.gitignore b/packages/core/integration-tests/rw-project/packages/package1/adr/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/packages/core/integration-tests/rw-project/packages/package1/adr/template.md b/packages/core/integration-tests/rw-project/packages/package1/adr/template.md new file mode 100644 index 00000000..daef03e1 --- /dev/null +++ b/packages/core/integration-tests/rw-project/packages/package1/adr/template.md @@ -0,0 +1,9 @@ +# [short title of solved problem and solution] + +- Status: [draft | proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] <!-- optional --> +- Date: [YYYY-MM-DD when the decision was last updated] <!-- optional - changes the order displayed in the UI --> +- Tags: [space and/or comma separated list of tags] <!-- optional --> + +## Context and Problem Statement + +This is a custom template for this package. diff --git a/packages/core/integration-tests/rw-project/packages/package2/adr/.gitignore b/packages/core/integration-tests/rw-project/packages/package2/adr/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/packages/core/integration-tests/rw.test.ts b/packages/core/integration-tests/rw.test.ts new file mode 100644 index 00000000..07d5b0a5 --- /dev/null +++ b/packages/core/integration-tests/rw.test.ts @@ -0,0 +1,112 @@ +import path from "path"; +import globby from "globby"; +import rimraf from "rimraf"; +import moment from "moment"; +import { Log4brains } from "../src/infrastructure/api"; +import { forceUnixPath } from "../src/lib/paths"; + +const PROJECT_PATH = forceUnixPath(path.join(__dirname, "rw-project")); + +function clean(): void { + globby + .sync([`${PROJECT_PATH}/**/*.md`, `!${PROJECT_PATH}/**/template.md`]) + .forEach((fileToClean) => rimraf.sync(fileToClean)); +} + +describe("E2E tests / RW", () => { + jest.setTimeout(1000 * 15); + + beforeAll(clean); + afterAll(clean); + + const instance = Log4brains.create(PROJECT_PATH); + + describe("createAdrFromTemplate()", () => { + test("in global scope", async () => { + await instance.createAdrFromTemplate( + "create-adr-from-template", + "Hello World" + ); + const adr = await instance.getAdrBySlug("create-adr-from-template"); + + expect(adr).toBeDefined(); + expect(adr?.title).toEqual("Hello World"); + expect(adr?.status).toEqual("draft"); + expect(adr?.package).toBeNull(); + expect(adr?.body.enhancedMdx).toMatchSnapshot(); + }); + + test("in package with custom template", async () => { + await instance.createAdrFromTemplate( + "package1/create-adr-from-template-package-custom-template", + "Foo Bar" + ); + const adr = await instance.getAdrBySlug( + "package1/create-adr-from-template-package-custom-template" + ); + + expect(adr).toBeDefined(); + expect(adr?.title).toEqual("Foo Bar"); + expect(adr?.status).toEqual("draft"); + expect(adr?.package).toEqual("package1"); + expect(adr?.body.enhancedMdx).toMatchSnapshot(); + }); + + test("in package with global template", async () => { + await instance.createAdrFromTemplate( + "package2/create-adr-from-template-package-global-template", + "Foo Baz" + ); + const adr = await instance.getAdrBySlug( + "package2/create-adr-from-template-package-global-template" + ); + + expect(adr).toBeDefined(); + expect(adr?.title).toEqual("Foo Baz"); + expect(adr?.status).toEqual("draft"); + expect(adr?.package).toEqual("package2"); + expect(adr?.body.enhancedMdx).toMatchSnapshot(); + }); + + test("slug duplication", async () => { + await instance.createAdrFromTemplate("duplicated-slug", "Hello World"); + await expect( + instance.createAdrFromTemplate("duplicated-slug", "Hello World 2") + ).rejects.toThrow(); + }); + + test("unknown package", async () => { + await expect( + instance.createAdrFromTemplate("unknown-package/test", "Hello World") + ).rejects.toThrow(); + }); + }); + + describe("supersedeAdr()", () => { + test("basic", async () => { + await instance.createAdrFromTemplate("superseded", "Superseded"); + await instance.createAdrFromTemplate("superseder", "Superseder"); + await instance.supersedeAdr("superseded", "superseder"); + + const superseded = await instance.getAdrBySlug("superseded"); + const superseder = await instance.getAdrBySlug("superseder"); + + expect(superseded?.status).toEqual("superseded"); + expect(superseded?.supersededBy).toEqual(superseder?.slug); + expect(superseder?.body.enhancedMdx).toMatchSnapshot(); + }); + }); + + describe("generateAdrSlug()", () => { + test("duplicate", async () => { + const date = moment().format("YYYYMMDD"); + const slug = await instance.generateAdrSlug("Duplicate Test"); + expect(slug).toEqual(`${date}-duplicate-test`); + + await instance.createAdrFromTemplate(slug, "Duplicate Test"); + expect(await instance.generateAdrSlug("Duplicate Test")).toEqual( + `${date}-duplicate-test-2` + ); + }); + }); +}); diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js new file mode 100644 index 00000000..70b54dce --- /dev/null +++ b/packages/core/jest.config.js @@ -0,0 +1,14 @@ +const base = require("../../jest.config.base"); +const { pathsToModuleNameMapper } = require("ts-jest/utils"); +const { compilerOptions } = require("./tsconfig"); +const packageJson = require("./package"); + +module.exports = { + ...base, + name: packageJson.name, + displayName: packageJson.name, + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "<rootDir>/" + }), + setupFiles: ["<rootDir>/src/polyfills.ts"] +}; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..fdefbf54 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,70 @@ +{ + "name": "@log4brains/core", + "version": "1.0.0-beta.0", + "description": "Log4brains architecture knowledge base core API", + "keywords": [ + "log4brains" + ], + "author": "Thomas Vaillant <thomvaill@bluebricks.dev>", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/thomvaill/log4brains", + "repository": { + "type": "git", + "url": "https://github.com/thomvaill/log4brains", + "directory": "packages/core" + }, + "engines": { + "node": ">=10.23.0" + }, + "files": [ + "dist" + ], + "source": "./src/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.module.js", + "types": "./dist/index.d.ts", + "scripts": { + "dev": "microbundle --no-compress --format es,cjs --tsconfig tsconfig.build.json --target node watch", + "build": "microbundle --no-compress --format es,cjs --tsconfig tsconfig.build.json --target node", + "clean": "rimraf ./dist", + "typescript": "tsc --noEmit", + "test": "jest", + "lint": "eslint . --max-warnings=0", + "typedoc": "typedoc --mode library --includeVersion --readme none --theme minimal --excludePrivate --out docs/typedoc src/index.ts", + "prepublishOnly": "yarn build" + }, + "dependencies": { + "awilix": "^4.2.6", + "cheerio": "^1.0.0-rc.3", + "chokidar": "^3.4.3", + "core-js": "^3.7.0", + "git-url-parse": "^11.4.0", + "joi": "^17.2.1", + "launch-editor": "^2.2.1", + "lodash": "^4.17.20", + "markdown-it": "^11.0.1", + "markdown-it-source-map": "^0.1.1", + "moment": "^2.29.1", + "moment-timezone": "^0.5.32", + "neverthrow": "^2.7.1", + "open": "^7.3.0", + "parse-git-config": "^3.0.0", + "simple-git": "^2.21.0", + "slugify": "^1.4.5", + "yaml": "^1.10.0" + }, + "devDependencies": { + "@types/cheerio": "^0.22.22", + "@types/git-url-parse": "^9.0.0", + "@types/joi": "^14.3.4", + "@types/lodash": "^4.14.161", + "@types/markdown-it": "^10.0.2", + "@types/parse-git-config": "^3.0.0", + "globby": "^11.0.1", + "microbundle": "^0.12.4" + } +} diff --git a/packages/core/src/adr/application/command-handlers/CreateAdrFromTemplateCommandHandler.ts b/packages/core/src/adr/application/command-handlers/CreateAdrFromTemplateCommandHandler.ts new file mode 100644 index 00000000..1a8b3e2c --- /dev/null +++ b/packages/core/src/adr/application/command-handlers/CreateAdrFromTemplateCommandHandler.ts @@ -0,0 +1,31 @@ +import { PackageRef } from "@src/adr/domain"; +import { CommandHandler } from "@src/application"; +import { CreateAdrFromTemplateCommand } from "../commands"; +import { AdrRepository, AdrTemplateRepository } from "../repositories"; + +type Deps = { + adrRepository: AdrRepository; + adrTemplateRepository: AdrTemplateRepository; +}; + +export class CreateAdrFromTemplateCommandHandler implements CommandHandler { + readonly commandClass = CreateAdrFromTemplateCommand; + + private readonly adrRepository: AdrRepository; + + private readonly adrTemplateRepository: AdrTemplateRepository; + + constructor({ adrRepository, adrTemplateRepository }: Deps) { + this.adrRepository = adrRepository; + this.adrTemplateRepository = adrTemplateRepository; + } + + async execute(command: CreateAdrFromTemplateCommand): Promise<void> { + 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); + } +} diff --git a/packages/core/src/adr/application/command-handlers/SupersedeAdrCommandHandler.ts b/packages/core/src/adr/application/command-handlers/SupersedeAdrCommandHandler.ts new file mode 100644 index 00000000..db056cc3 --- /dev/null +++ b/packages/core/src/adr/application/command-handlers/SupersedeAdrCommandHandler.ts @@ -0,0 +1,25 @@ +import { CommandHandler } from "@src/application"; +import { SupersedeAdrCommand } from "../commands"; +import { AdrRepository } from "../repositories"; + +type Deps = { + adrRepository: AdrRepository; +}; + +export class SupersedeAdrCommandHandler implements CommandHandler { + readonly commandClass = SupersedeAdrCommand; + + private readonly adrRepository: AdrRepository; + + constructor({ adrRepository }: Deps) { + this.adrRepository = adrRepository; + } + + async execute(command: SupersedeAdrCommand): Promise<void> { + 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); + } +} diff --git a/packages/core/src/adr/application/command-handlers/index.ts b/packages/core/src/adr/application/command-handlers/index.ts new file mode 100644 index 00000000..ef8c4aa5 --- /dev/null +++ b/packages/core/src/adr/application/command-handlers/index.ts @@ -0,0 +1,2 @@ +export * from "./CreateAdrFromTemplateCommandHandler"; +export * from "./SupersedeAdrCommandHandler"; diff --git a/packages/core/src/adr/application/commands/CreateAdrFromTemplateCommand.ts b/packages/core/src/adr/application/commands/CreateAdrFromTemplateCommand.ts new file mode 100644 index 00000000..fc8857af --- /dev/null +++ b/packages/core/src/adr/application/commands/CreateAdrFromTemplateCommand.ts @@ -0,0 +1,8 @@ +import { AdrSlug } from "@src/adr/domain"; +import { Command } from "@src/application"; + +export class CreateAdrFromTemplateCommand extends Command { + constructor(public readonly slug: AdrSlug, public readonly title: string) { + super(); + } +} diff --git a/packages/core/src/adr/application/commands/SupersedeAdrCommand.ts b/packages/core/src/adr/application/commands/SupersedeAdrCommand.ts new file mode 100644 index 00000000..595cf0fa --- /dev/null +++ b/packages/core/src/adr/application/commands/SupersedeAdrCommand.ts @@ -0,0 +1,11 @@ +import { AdrSlug } from "@src/adr/domain"; +import { Command } from "@src/application"; + +export class SupersedeAdrCommand extends Command { + constructor( + public readonly supersededSlug: AdrSlug, + public readonly supersederSlug: AdrSlug + ) { + super(); + } +} diff --git a/packages/core/src/adr/application/commands/index.ts b/packages/core/src/adr/application/commands/index.ts new file mode 100644 index 00000000..7508c3b7 --- /dev/null +++ b/packages/core/src/adr/application/commands/index.ts @@ -0,0 +1,2 @@ +export * from "./CreateAdrFromTemplateCommand"; +export * from "./SupersedeAdrCommand"; diff --git a/packages/core/src/adr/application/index.ts b/packages/core/src/adr/application/index.ts new file mode 100644 index 00000000..bc4a4f36 --- /dev/null +++ b/packages/core/src/adr/application/index.ts @@ -0,0 +1,5 @@ +export * from "./command-handlers"; +export * from "./commands"; +export * from "./queries"; +export * from "./query-handlers"; +export * from "./repositories"; diff --git a/packages/core/src/adr/application/queries/GenerateAdrSlugFromTitleCommand.ts b/packages/core/src/adr/application/queries/GenerateAdrSlugFromTitleCommand.ts new file mode 100644 index 00000000..1092e1ae --- /dev/null +++ b/packages/core/src/adr/application/queries/GenerateAdrSlugFromTitleCommand.ts @@ -0,0 +1,11 @@ +import { PackageRef } from "@src/adr/domain"; +import { Query } from "@src/application"; + +export class GenerateAdrSlugFromTitleQuery extends Query { + constructor( + public readonly title: string, + public readonly packageRef?: PackageRef + ) { + super(); + } +} diff --git a/packages/core/src/adr/application/queries/GetAdrBySlugQuery.ts b/packages/core/src/adr/application/queries/GetAdrBySlugQuery.ts new file mode 100644 index 00000000..0952ec28 --- /dev/null +++ b/packages/core/src/adr/application/queries/GetAdrBySlugQuery.ts @@ -0,0 +1,8 @@ +import { AdrSlug } from "@src/adr/domain"; +import { Query } from "@src/application"; + +export class GetAdrBySlugQuery extends Query { + constructor(public readonly slug: AdrSlug) { + super(); + } +} diff --git a/packages/core/src/adr/application/queries/SearchAdrsQuery.ts b/packages/core/src/adr/application/queries/SearchAdrsQuery.ts new file mode 100644 index 00000000..272a2ae0 --- /dev/null +++ b/packages/core/src/adr/application/queries/SearchAdrsQuery.ts @@ -0,0 +1,12 @@ +import { AdrStatus } from "@src/adr/domain"; +import { Query } from "@src/application"; + +export type SearchAdrsFilters = { + statuses?: AdrStatus[]; +}; + +export class SearchAdrsQuery extends Query { + constructor(public readonly filters: SearchAdrsFilters) { + super(); + } +} diff --git a/packages/core/src/adr/application/queries/index.ts b/packages/core/src/adr/application/queries/index.ts new file mode 100644 index 00000000..83bd940d --- /dev/null +++ b/packages/core/src/adr/application/queries/index.ts @@ -0,0 +1,3 @@ +export * from "./GenerateAdrSlugFromTitleCommand"; +export * from "./GetAdrBySlugQuery"; +export * from "./SearchAdrsQuery"; diff --git a/packages/core/src/adr/application/query-handlers/GenerateAdrSlugFromTitleCommandHandler.ts b/packages/core/src/adr/application/query-handlers/GenerateAdrSlugFromTitleCommandHandler.ts new file mode 100644 index 00000000..0229ab1a --- /dev/null +++ b/packages/core/src/adr/application/query-handlers/GenerateAdrSlugFromTitleCommandHandler.ts @@ -0,0 +1,24 @@ +import { AdrSlug } from "@src/adr/domain"; +import { QueryHandler } from "@src/application"; +import { GenerateAdrSlugFromTitleQuery } from "../queries"; +import { AdrRepository } from "../repositories"; + +type Deps = { + adrRepository: AdrRepository; +}; + +export class GenerateAdrSlugFromTitleQueryHandler implements QueryHandler { + readonly queryClass = GenerateAdrSlugFromTitleQuery; + + private readonly adrRepository: AdrRepository; + + constructor({ adrRepository }: Deps) { + this.adrRepository = adrRepository; + } + + execute(query: GenerateAdrSlugFromTitleQuery): Promise<AdrSlug> { + return Promise.resolve( + this.adrRepository.generateAvailableSlug(query.title, query.packageRef) + ); + } +} diff --git a/packages/core/src/adr/application/query-handlers/GetAdrBySlugQueryHandler.ts b/packages/core/src/adr/application/query-handlers/GetAdrBySlugQueryHandler.ts new file mode 100644 index 00000000..bdddb66d --- /dev/null +++ b/packages/core/src/adr/application/query-handlers/GetAdrBySlugQueryHandler.ts @@ -0,0 +1,32 @@ +import { Adr } from "@src/adr/domain"; +import { QueryHandler } from "@src/application"; +import { Log4brainsError } from "@src/domain"; +import { GetAdrBySlugQuery } from "../queries"; +import { AdrRepository } from "../repositories"; + +type Deps = { + adrRepository: AdrRepository; +}; + +export class GetAdrBySlugQueryHandler implements QueryHandler { + readonly queryClass = GetAdrBySlugQuery; + + private readonly adrRepository: AdrRepository; + + constructor({ adrRepository }: Deps) { + this.adrRepository = adrRepository; + } + + async execute(query: GetAdrBySlugQuery): Promise<Adr | undefined> { + 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; + } +} diff --git a/packages/core/src/adr/application/query-handlers/SearchAdrsQueryHandler.test.ts b/packages/core/src/adr/application/query-handlers/SearchAdrsQueryHandler.test.ts new file mode 100644 index 00000000..a260ffe9 --- /dev/null +++ b/packages/core/src/adr/application/query-handlers/SearchAdrsQueryHandler.test.ts @@ -0,0 +1,53 @@ +import { mock, mockClear } from "jest-mock-extended"; +import { AdrRepository } from "@src/adr/application"; +import { + Adr, + AdrFile, + AdrSlug, + AdrStatus, + FilesystemPath, + MarkdownBody +} from "@src/adr/domain"; +import { SearchAdrsQuery } from "../queries"; +import { SearchAdrsQueryHandler } from "./SearchAdrsQueryHandler"; + +describe("SearchAdrsQueryHandler", () => { + const adr1 = new Adr({ + slug: new AdrSlug("adr1"), + file: new AdrFile(new FilesystemPath("/", "adr1.md")), + body: new MarkdownBody("") + }); + const adr2 = new Adr({ + slug: new AdrSlug("adr2"), + file: new AdrFile(new FilesystemPath("/", "adr2.md")), + body: new MarkdownBody("") + }); + + const adrRepository = mock<AdrRepository>(); + adrRepository.findAll.mockReturnValue(Promise.resolve([adr1, adr2])); + + const handler = new SearchAdrsQueryHandler({ adrRepository }); + + beforeAll(() => { + Adr.setTz("Etc/UTC"); + }); + afterAll(() => { + Adr.clearTz(); + }); + + beforeEach(() => { + mockClear(adrRepository); + }); + + it("returns all ADRs when no filter", async () => { + const adrs = await handler.execute(new SearchAdrsQuery({})); + expect(adrs).toHaveLength(2); + }); + + it("filters the ADRs on their status", async () => { + const adrs = await handler.execute( + new SearchAdrsQuery({ statuses: [AdrStatus.createFromName("proposed")] }) + ); + expect(adrs).toHaveLength(0); + }); +}); diff --git a/packages/core/src/adr/application/query-handlers/SearchAdrsQueryHandler.ts b/packages/core/src/adr/application/query-handlers/SearchAdrsQueryHandler.ts new file mode 100644 index 00000000..41ab60c0 --- /dev/null +++ b/packages/core/src/adr/application/query-handlers/SearchAdrsQueryHandler.ts @@ -0,0 +1,31 @@ +import { Adr } from "@src/adr/domain"; +import { QueryHandler } from "@src/application"; +import { ValueObjectArray } from "@src/domain"; +import { SearchAdrsQuery } from "../queries"; +import { AdrRepository } from "../repositories"; + +type Deps = { + adrRepository: AdrRepository; +}; + +export class SearchAdrsQueryHandler implements QueryHandler { + readonly queryClass = SearchAdrsQuery; + + private readonly adrRepository: AdrRepository; + + constructor({ adrRepository }: Deps) { + this.adrRepository = adrRepository; + } + + async execute(query: SearchAdrsQuery): Promise<Adr[]> { + return (await this.adrRepository.findAll()).filter((adr) => { + if ( + query.filters.statuses && + !ValueObjectArray.inArray(adr.status, query.filters.statuses) + ) { + return false; + } + return true; + }); + } +} diff --git a/packages/core/src/adr/application/query-handlers/index.ts b/packages/core/src/adr/application/query-handlers/index.ts new file mode 100644 index 00000000..53bda2ed --- /dev/null +++ b/packages/core/src/adr/application/query-handlers/index.ts @@ -0,0 +1,3 @@ +export * from "./GenerateAdrSlugFromTitleCommandHandler"; +export * from "./GetAdrBySlugQueryHandler"; +export * from "./SearchAdrsQueryHandler"; diff --git a/packages/core/src/adr/application/repositories/AdrRepository.ts b/packages/core/src/adr/application/repositories/AdrRepository.ts new file mode 100644 index 00000000..8b3aed36 --- /dev/null +++ b/packages/core/src/adr/application/repositories/AdrRepository.ts @@ -0,0 +1,8 @@ +import { Adr, AdrSlug, PackageRef } from "@src/adr/domain"; + +export interface AdrRepository { + find(slug: AdrSlug): Promise<Adr>; + findAll(): Promise<Adr[]>; + generateAvailableSlug(title: string, packageRef?: PackageRef): AdrSlug; + save(adr: Adr): Promise<void>; +} diff --git a/packages/core/src/adr/application/repositories/AdrTemplateRepository.ts b/packages/core/src/adr/application/repositories/AdrTemplateRepository.ts new file mode 100644 index 00000000..b3b25b8b --- /dev/null +++ b/packages/core/src/adr/application/repositories/AdrTemplateRepository.ts @@ -0,0 +1,5 @@ +import { AdrTemplate, PackageRef } from "@src/adr/domain"; + +export interface AdrTemplateRepository { + find(packageRef?: PackageRef): Promise<AdrTemplate>; +} diff --git a/packages/core/src/adr/application/repositories/index.ts b/packages/core/src/adr/application/repositories/index.ts new file mode 100644 index 00000000..95772044 --- /dev/null +++ b/packages/core/src/adr/application/repositories/index.ts @@ -0,0 +1,2 @@ +export * from "./AdrRepository"; +export * from "./AdrTemplateRepository"; diff --git a/packages/core/src/adr/domain/Adr.test.ts b/packages/core/src/adr/domain/Adr.test.ts new file mode 100644 index 00000000..1a19465d --- /dev/null +++ b/packages/core/src/adr/domain/Adr.test.ts @@ -0,0 +1,440 @@ +import moment from "moment-timezone"; +import { Adr } from "./Adr"; +import { AdrSlug } from "./AdrSlug"; +import { AdrStatus } from "./AdrStatus"; +import { MarkdownAdrLink } from "./MarkdownAdrLink"; +import { MarkdownBody } from "./MarkdownBody"; + +describe("Adr", () => { + beforeAll(() => { + Adr.setTz("Etc/UTC"); + }); + afterAll(() => { + Adr.clearTz(); + }); + + describe("get title()", () => { + it("returns the title", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# Lorem Ipsum`) + }); + expect(adr.title).toEqual("Lorem Ipsum"); + }); + + it("returns undefined when no title", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`## Subtitle`) + }); + expect(adr.title).toBeUndefined(); + }); + }); + + describe("get status()", () => { + it("returns the status", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Status: accepted + `) + }); + expect(adr.status.equals(AdrStatus.ACCEPTED)).toBeTruthy(); + }); + + it("returns the SUPERSEDED special status", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Status: superseded by XXX + `) + }); + expect(adr.status.equals(AdrStatus.SUPERSEDED)).toBeTruthy(); + }); + + it("returns the default status", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR`) + }); + expect(adr.status.equals(AdrStatus.ACCEPTED)).toBeTruthy(); + }); + }); + + describe("get superseder()", () => { + it("returns undefined when no relevant", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Status: accepted + `) + }); + expect(adr.superseder).toBeUndefined(); + }); + + it("returns the superseder", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Status: superseded by foo/bar + `) + }); + expect(adr.superseder?.value).toEqual("foo/bar"); + }); + }); + + describe("get tags()", () => { + it("returns the tags", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Tags: frontend,BaCkEnD with-space, with-space-and-comma, with-a-lot-of-spaces + `) + }); + expect(adr.tags).toEqual([ + "frontend", + "backend", + "with-space", + "with-space-and-comma", + "with-a-lot-of-spaces" + ]); + }); + + it("returns no tags", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Status: accepted + `) + }); + expect(adr.tags).toEqual([]); + }); + }); + + describe("get deciders()", () => { + it("returns the deciders", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Deciders: John Doe,Lorem Ipsum test , FOO BAR,bar + `) + }); + expect(adr.deciders).toEqual([ + "John Doe", + "Lorem Ipsum test", + "FOO BAR", + "bar" + ]); + }); + + it("returns no deciders", () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + + - Status: accepted + `) + }); + expect(adr.deciders).toEqual([]); + }); + }); + + describe("getEnhancedMdx()", () => { + const markdownAdrLinkResolver = { + resolve: ( + from: Adr, + uri: string + ): Promise<MarkdownAdrLink | undefined> => { + if (uri === "test-link.md") { + return Promise.resolve( + new MarkdownAdrLink( + from, + new Adr({ + slug: new AdrSlug("test-link"), + body: new MarkdownBody("") + }) + ) + ); + } + return Promise.resolve(undefined); + } + }; + + test("default case", async () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + +- Status: accepted +- Deciders: John Doe, Lorem Ipsum +- Date: 2020-01-01 +- Tags: foo bar + +## Subtitle + +Hello +`).setAdrLinkResolver(markdownAdrLinkResolver) + }); + + expect(await adr.getEnhancedMdx()).toEqual(` +## Subtitle + +Hello +`); + }); + + test("with additional information", async () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# My ADR + +Hello this is a paragraph. + +- Status: accepted +- Deciders: John Doe, Lorem Ipsum +- Date: 2020-01-01 +- Unknown Metadata: test +- Tags: foo bar +- Unknown Metadata2: test2 + +Technical Story: test + +## Subtitle + +Hello +`).setAdrLinkResolver(markdownAdrLinkResolver) + }); + + expect(await adr.getEnhancedMdx()).toEqual(` +Hello this is a paragraph. + +- Unknown Metadata: test +- Unknown Metadata2: test2 + +Technical Story: test + +## Subtitle + +Hello +`); + }); + + test("links replacement", async () => { + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`## Subtitle + +Link to an actual ADR: [custom text](test-link.md). +Link to an actual ADR: [test-link](test-link.md). +Link to an unknown ADR: [lorem ipsum](unknown.md). +Link to an other file: [lorem ipsum](test.html). +Link to an URL: [lorem ipsum](https://www.google.com/). +`).setAdrLinkResolver(markdownAdrLinkResolver) + }); + + expect(await adr.getEnhancedMdx()).toEqual(`## Subtitle + +Link to an actual ADR: <AdrLink slug="test-link" status="accepted" customLabel="custom text" />. +Link to an actual ADR: <AdrLink slug="test-link" status="accepted" />. +Link to an unknown ADR: [lorem ipsum](unknown.md). +Link to an other file: [lorem ipsum](test.html). +Link to an URL: [lorem ipsum](https://www.google.com/). +`); + }); + }); + + describe("compare()", () => { + function bodyWithDate(date: string): MarkdownBody { + return new MarkdownBody(`# Test\n\n- Date: ${date}\n`); + } + function bodyWithoutDate(): MarkdownBody { + return new MarkdownBody("# Test\n"); + } + + describe("when there is a publicationDate", () => { + it("sorts between two publicationDates", () => { + const one = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-02 00:00:00"), + body: bodyWithDate("2020-01-01") + }); + const two = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithDate("2020-01-02") + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + it("sorts between a publicationDate and a creationDate", () => { + const one = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-02 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithDate("2020-01-03") + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + test("a publicationDate on the same day of a creationDate is always older", () => { + const one = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-02 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithDate("2020-01-02") + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + it("sorts by slug when two same publicationDates", () => { + const one = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-02 00:00:00"), + body: bodyWithDate("2020-01-02") + }); + const two = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithDate("2020-01-02") + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + }); + + describe("when there is no publicationDate", () => { + it("sorts between two creationDate", () => { + const one = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-02 00:00:00"), + body: bodyWithoutDate() + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + it("sorts by slug when two same creationDates", () => { + const one = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("abb"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + it("does not take slug's package part into account", () => { + const one = new Adr({ + slug: new AdrSlug("zzz/aaa"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + it("does not take case into account", () => { + const one = new Adr({ + slug: new AdrSlug("AAA"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("bbb"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + + it("sorts numbers before letters", () => { + const one = new Adr({ + slug: new AdrSlug("999"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + const two = new Adr({ + slug: new AdrSlug("aaa"), + creationDate: new Date("2020-01-01 00:00:00"), + body: bodyWithoutDate() + }); + + expect([two, one].sort(Adr.compare)).toEqual([one, two]); + expect([one, two].sort(Adr.compare)).toEqual([one, two]); + }); + }); + }); +}); + +describe("Adr - timezones", () => { + it("fails when not calling setTz() before getting publicationDate", () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# Lorem Ipsum`) + }).publicationDate; + }).toThrow(); + }); + + test("timezone works", () => { + Adr.setTz("Europe/Paris"); + + const expectedDate = moment.tz("2020-01-01 23:59:59", "Europe/Paris"); + const adr = new Adr({ + slug: new AdrSlug("test"), + body: new MarkdownBody(`# Lorem Ipsum + + - Date: ${expectedDate.format("YYYY-MM-DD")} +`) + }); + + expect(adr.publicationDate?.toJSON()).toEqual( + expectedDate.toDate().toJSON() + ); + + Adr.clearTz(); + }); +}); diff --git a/packages/core/src/adr/domain/Adr.ts b/packages/core/src/adr/domain/Adr.ts new file mode 100644 index 00000000..4963d127 --- /dev/null +++ b/packages/core/src/adr/domain/Adr.ts @@ -0,0 +1,231 @@ +import moment from "moment-timezone"; +import { AggregateRoot, Log4brainsError } from "@src/domain"; +import { AdrFile } from "./AdrFile"; +import { AdrSlug } from "./AdrSlug"; +import { AdrStatus } from "./AdrStatus"; +import type { MarkdownBody } from "./MarkdownBody"; +import { PackageRef } from "./PackageRef"; +import { AdrRelation } from "./AdrRelation"; +import { Author } from "./Author"; + +// TODO: make this configurable +const dateFormats = ["YYYY-MM-DD", "DD/MM/YYYY"]; + +type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; + +type Props = { + slug: AdrSlug; + package?: PackageRef; + body: MarkdownBody; + file?: AdrFile; // set by the repository after save() + creationDate: Date; // set by the repository after save() or automatically set to now() + lastEditDate: Date; // set by the repository after save() or automatically set to now() + lastEditAuthor: Author; // set by the repository after save() or automatically set to anonymous +}; + +export class Adr extends AggregateRoot<Props> { + /** + * Global TimeZone. + * This static property must be set at startup with Adr.setTz(), otherwise it will throw an Error. + * This dirty behavior is temporary, until we get a better vision on how to deal with timezones in the project. + * TODO: refactor + */ + private static tz?: string; + + constructor( + props: WithOptional< + Props, + "creationDate" | "lastEditDate" | "lastEditAuthor" + > + ) { + super({ + creationDate: props.creationDate || new Date(), + lastEditDate: props.lastEditDate || new Date(), + lastEditAuthor: props.lastEditAuthor || Author.createAnonymous(), + ...props + }); + } + + /** + * @see Adr.tz + */ + static setTz(tz: string): void { + if (!moment.tz.zone(tz)) { + throw new Log4brainsError("Unknown timezone", Adr.tz); + } + Adr.tz = tz; + } + + /** + * For test purposes only + */ + static clearTz(): void { + Adr.tz = undefined; + } + + get slug(): AdrSlug { + return this.props.slug; + } + + get package(): PackageRef | undefined { + return this.props.package; + } + + get body(): MarkdownBody { + return this.props.body; + } + + get file(): AdrFile | undefined { + return this.props.file; + } + + get creationDate(): Date { + return this.props.creationDate; + } + + get lastEditDate(): Date { + return this.props.lastEditDate; + } + + get lastEditAuthor(): Author { + return this.props.lastEditAuthor; + } + + get title(): string | undefined { + return this.body.getFirstH1Title(); // TODO: log when no title + } + + get status(): AdrStatus { + 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(): AdrSlug | undefined { + 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(): Date | undefined { + 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.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(): string[] { + 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(): string[] { + 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: AdrFile): void { + this.props.file = file; + } + + setTitle(title: string): void { + this.body.setFirstH1Title(title); + } + + supersedeBy(superseder: Adr): void { + const relation = new AdrRelation(this, "superseded by", superseder); + this.body.setHeaderMetadata("Status", relation.toMarkdown()); + superseder.markAsSuperseder(this); + } + + private markAsSuperseder(superseded: Adr): void { + const relation = new AdrRelation(this, "Supersedes", superseded); + this.body.addLinkNoDuplicate(relation.toMarkdown()); + } + + async getEnhancedMdx(): Promise<string> { + 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: Adr, b: Adr): number { + // 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; + } +} diff --git a/packages/core/src/adr/domain/AdrFile.test.ts b/packages/core/src/adr/domain/AdrFile.test.ts new file mode 100644 index 00000000..7265cd94 --- /dev/null +++ b/packages/core/src/adr/domain/AdrFile.test.ts @@ -0,0 +1,35 @@ +import { AdrFile } from "./AdrFile"; +import { AdrSlug } from "./AdrSlug"; +import { FilesystemPath } from "./FilesystemPath"; + +describe("AdrFile", () => { + it("throws when not .md", () => { + expect(() => { + new AdrFile(new FilesystemPath("/", "test")); + }).toThrow(); + }); + + it("throws when reserved filename", () => { + expect(() => { + new AdrFile(new FilesystemPath("/", "template.md")); + }).toThrow(); + expect(() => { + new AdrFile(new FilesystemPath("/", "README.md")); + }).toThrow(); + expect(() => { + new AdrFile(new FilesystemPath("/", "index.md")); + }).toThrow(); + expect(() => { + new AdrFile(new FilesystemPath("/", "backlog.md")); + }).toThrow(); + }); + + it("creates from slug in folder", () => { + expect( + AdrFile.createFromSlugInFolder( + new FilesystemPath("/", "test"), + new AdrSlug("my-package/20200101-hello-world") + ).path.absolutePath + ).toEqual("/test/20200101-hello-world.md"); + }); +}); diff --git a/packages/core/src/adr/domain/AdrFile.ts b/packages/core/src/adr/domain/AdrFile.ts new file mode 100644 index 00000000..592305c2 --- /dev/null +++ b/packages/core/src/adr/domain/AdrFile.ts @@ -0,0 +1,52 @@ +import { Log4brainsError, ValueObject } from "@src/domain"; +import type { AdrSlug } from "./AdrSlug"; +import { FilesystemPath } from "./FilesystemPath"; + +type Props = { + path: FilesystemPath; +}; + +const reservedFilenames = [ + "template.md", + "readme.md", + "index.md", + "backlog.md" +]; + +export class AdrFile extends ValueObject<Props> { + constructor(path: FilesystemPath) { + 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(): FilesystemPath { + return this.props.path; + } + + static isPathValid(path: FilesystemPath): boolean { + try { + // eslint-disable-next-line no-new + new AdrFile(path); + return true; + } catch (e) { + return false; + } + } + + static createFromSlugInFolder( + folder: FilesystemPath, + slug: AdrSlug + ): AdrFile { + return new AdrFile(folder.join(`${slug.namePart}.md`)); + } +} diff --git a/packages/core/src/adr/domain/AdrRelation.test.ts b/packages/core/src/adr/domain/AdrRelation.test.ts new file mode 100644 index 00000000..5853a858 --- /dev/null +++ b/packages/core/src/adr/domain/AdrRelation.test.ts @@ -0,0 +1,42 @@ +import { Adr } from "./Adr"; +import { AdrFile } from "./AdrFile"; +import { AdrRelation } from "./AdrRelation"; +import { AdrSlug } from "./AdrSlug"; +import { FilesystemPath } from "./FilesystemPath"; +import { MarkdownBody } from "./MarkdownBody"; +import { PackageRef } from "./PackageRef"; + +describe("AdrRelation", () => { + beforeAll(() => { + Adr.setTz("Etc/UTC"); + }); + afterAll(() => { + Adr.clearTz(); + }); + + it("correctly prints to markdown", () => { + const from = new Adr({ + slug: new AdrSlug("from"), + file: new AdrFile(new FilesystemPath("/", "docs/adr/from.md")), + body: new MarkdownBody("") + }); + const to = new Adr({ + slug: new AdrSlug("test/to"), + package: new PackageRef("test"), + file: new AdrFile( + new FilesystemPath("/", "packages/test/docs/adr/to.md") + ), + body: new MarkdownBody("") + }); + + const relation1 = new AdrRelation(from, "superseded by", to); + expect(relation1.toMarkdown()).toEqual( + "superseded by [test/to](../../packages/test/docs/adr/to.md)" + ); + + const relation2 = new AdrRelation(from, "refines", to); + expect(relation2.toMarkdown()).toEqual( + "refines [test/to](../../packages/test/docs/adr/to.md)" + ); + }); +}); diff --git a/packages/core/src/adr/domain/AdrRelation.ts b/packages/core/src/adr/domain/AdrRelation.ts new file mode 100644 index 00000000..720c182f --- /dev/null +++ b/packages/core/src/adr/domain/AdrRelation.ts @@ -0,0 +1,32 @@ +import { ValueObject } from "@src/domain"; +import type { Adr } from "./Adr"; +import { MarkdownAdrLink } from "./MarkdownAdrLink"; + +type Props = { + from: Adr; + relation: string; + to: Adr; +}; + +export class AdrRelation extends ValueObject<Props> { + constructor(from: Adr, relation: string, to: Adr) { + super({ from, relation, to }); + } + + get from(): Adr { + return this.props.from; + } + + get relation(): string { + return this.props.relation; + } + + get to(): Adr { + return this.props.to; + } + + toMarkdown(): string { + const link = new MarkdownAdrLink(this.from, this.to); + return `${this.relation} ${link.toMarkdown()}`; + } +} diff --git a/packages/core/src/adr/domain/AdrSlug.test.ts b/packages/core/src/adr/domain/AdrSlug.test.ts new file mode 100644 index 00000000..00317030 --- /dev/null +++ b/packages/core/src/adr/domain/AdrSlug.test.ts @@ -0,0 +1,65 @@ +import { AdrFile } from "./AdrFile"; +import { AdrSlug } from "./AdrSlug"; +import { FilesystemPath } from "./FilesystemPath"; +import { PackageRef } from "./PackageRef"; + +describe("AdrSlug", () => { + it("returns the package part", () => { + expect(new AdrSlug("my-package/0001-test").packagePart).toEqual( + "my-package" + ); + expect(new AdrSlug("0001-test").packagePart).toBeUndefined(); + }); + + it("returns the name part", () => { + expect(new AdrSlug("my-package/0001-test").namePart).toEqual("0001-test"); + expect(new AdrSlug("0001-test").namePart).toEqual("0001-test"); + }); + + describe("createFromFile()", () => { + it("creates from AdrFile with package", () => { + expect( + AdrSlug.createFromFile( + new AdrFile(new FilesystemPath("/", "0001-my-adr.md")), + new PackageRef("my-package") + ).value + ).toEqual("my-package/0001-my-adr"); + }); + + it("creates from AdrFile without package", () => { + expect( + AdrSlug.createFromFile( + new AdrFile(new FilesystemPath("/", "0001-my-adr.md")) + ).value + ).toEqual("0001-my-adr"); + }); + }); + + describe("createFromTitle()", () => { + it("creates from title with package", () => { + expect( + AdrSlug.createFromTitle( + "My ADR", + new PackageRef("my-package"), + new Date(2020, 0, 1) + ).value + ).toEqual("my-package/20200101-my-adr"); + }); + + it("creates from title without package", () => { + expect( + AdrSlug.createFromTitle("My ADR", undefined, new Date(2020, 0, 1)).value + ).toEqual("20200101-my-adr"); + }); + + it("creates from title with complex title", () => { + expect( + AdrSlug.createFromTitle( + "L'exemple d'un titre compliqué ! @test", + undefined, + new Date(2020, 0, 1) + ).value + ).toEqual("20200101-lexemple-dun-titre-complique-test"); + }); + }); +}); diff --git a/packages/core/src/adr/domain/AdrSlug.ts b/packages/core/src/adr/domain/AdrSlug.ts new file mode 100644 index 00000000..1bde6f92 --- /dev/null +++ b/packages/core/src/adr/domain/AdrSlug.ts @@ -0,0 +1,58 @@ +import moment from "moment"; +import slugify from "slugify"; +import { Log4brainsError, ValueObject } from "@src/domain"; +import { AdrFile } from "./AdrFile"; +import { PackageRef } from "./PackageRef"; + +type Props = { + value: string; +}; + +export class AdrSlug extends ValueObject<Props> { + constructor(value: string) { + 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(): string { + return this.props.value; + } + + get packagePart(): string | undefined { + const s = this.value.split("/", 2); + return s.length >= 2 ? s[0] : undefined; + } + + get namePart(): string { + const s = this.value.split("/", 2); + return s.length >= 2 ? s[1] : s[0]; + } + + static createFromFile(file: AdrFile, packageRef?: PackageRef): AdrSlug { + const localSlug = file.path.basenameWithoutExtension; + return new AdrSlug( + packageRef ? `${packageRef.name}/${localSlug}` : localSlug + ); + } + + static createFromTitle( + title: string, + packageRef?: PackageRef, + date?: Date + ): AdrSlug { + const slugifiedTitle = slugify(title, { + lower: true, + strict: true + }).replace(/-*$/, ""); + const localSlug = `${moment(date).format("YYYYMMDD")}-${slugifiedTitle}`; + return new AdrSlug( + packageRef ? `${packageRef.name}/${localSlug}` : localSlug + ); + } +} diff --git a/packages/core/src/adr/domain/AdrStatus.test.ts b/packages/core/src/adr/domain/AdrStatus.test.ts new file mode 100644 index 00000000..995c206e --- /dev/null +++ b/packages/core/src/adr/domain/AdrStatus.test.ts @@ -0,0 +1,19 @@ +import { AdrStatus } from "./AdrStatus"; + +describe("AdrStatus", () => { + it("create from name", () => { + const status = AdrStatus.createFromName("draft"); + expect(status.name).toEqual("draft"); + }); + + it("throws when unknown name", () => { + expect(() => { + AdrStatus.createFromName("loremipsum"); + }).toThrow(); + }); + + it("works with 'superseded by XXX", () => { + const status = AdrStatus.createFromName("superseded by XXX"); + expect(status.name).toEqual("superseded"); + }); +}); diff --git a/packages/core/src/adr/domain/AdrStatus.ts b/packages/core/src/adr/domain/AdrStatus.ts new file mode 100644 index 00000000..b5901a53 --- /dev/null +++ b/packages/core/src/adr/domain/AdrStatus.ts @@ -0,0 +1,45 @@ +import { Log4brainsError, ValueObject } from "@src/domain"; + +type Props = { + name: string; +}; + +export class AdrStatus extends ValueObject<Props> { + static DRAFT = new AdrStatus("draft"); + + static PROPOSED = new AdrStatus("proposed"); + + static REJECTED = new AdrStatus("rejected"); + + static ACCEPTED = new AdrStatus("accepted"); + + static DEPRECATED = new AdrStatus("deprecated"); + + static SUPERSEDED = new AdrStatus("superseded"); + + private constructor(name: string) { + super({ name }); + } + + get name(): string { + return this.props.name; + } + + static createFromName(name: string): AdrStatus { + 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 as AdrStatus; + } +} diff --git a/packages/core/src/adr/domain/AdrTemplate.test.ts b/packages/core/src/adr/domain/AdrTemplate.test.ts new file mode 100644 index 00000000..97af8ae6 --- /dev/null +++ b/packages/core/src/adr/domain/AdrTemplate.test.ts @@ -0,0 +1,53 @@ +import { Adr } from "./Adr"; +import { AdrSlug } from "./AdrSlug"; +import { AdrTemplate } from "./AdrTemplate"; +import { MarkdownBody } from "./MarkdownBody"; +import { PackageRef } from "./PackageRef"; + +describe("AdrTemplate", () => { + beforeAll(() => { + Adr.setTz("Etc/UTC"); + }); + afterAll(() => { + Adr.clearTz(); + }); + + const tplMarkdown = `# [short title of solved problem and solution] + + - Status: [draft | proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] <!-- optional --> + - Deciders: [list everyone involved in the decision] <!-- optional --> + - Date: [YYYY-MM-DD when the decision was last updated] <!-- optional - changes the order displayed in the UI --> + + Technical Story: [description | ticket/issue URL] <!-- optional --> + + ## Context and Problem Statement + + [Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] +`; + + it("creates an ADR from the template", () => { + const template = new AdrTemplate({ + package: new PackageRef("test"), + body: new MarkdownBody(tplMarkdown) + }); + const adr = template.createAdrFromMe( + new AdrSlug("test/20200101-hello-world"), + "Hello World" + ); + expect(adr.slug.value).toEqual("test/20200101-hello-world"); + expect(adr.title).toEqual("Hello World"); + }); + + it("throws when package mismatch in slug", () => { + expect(() => { + const template = new AdrTemplate({ + package: new PackageRef("test"), + body: new MarkdownBody(tplMarkdown) + }); + template.createAdrFromMe( + new AdrSlug("other-package/20200101-hello-world"), + "Hello World" + ); + }).toThrow(); + }); +}); diff --git a/packages/core/src/adr/domain/AdrTemplate.ts b/packages/core/src/adr/domain/AdrTemplate.ts new file mode 100644 index 00000000..70a564fe --- /dev/null +++ b/packages/core/src/adr/domain/AdrTemplate.ts @@ -0,0 +1,42 @@ +import { AggregateRoot, Log4brainsError } from "@src/domain"; +import { Adr } from "./Adr"; +import { AdrSlug } from "./AdrSlug"; +import { MarkdownBody } from "./MarkdownBody"; +import { PackageRef } from "./PackageRef"; + +type Props = { + package?: PackageRef; + body: MarkdownBody; +}; + +export class AdrTemplate extends AggregateRoot<Props> { + get package(): PackageRef | undefined { + return this.props.package; + } + + get body(): MarkdownBody { + return this.props.body; + } + + createAdrFromMe(slug: AdrSlug, title: string): Adr { + const packageRef = slug.packagePart + ? new PackageRef(slug.packagePart) + : undefined; + if ( + (!this.package && packageRef) || + (this.package && !this.package.equals(packageRef)) + ) { + throw new Log4brainsError( + "The given slug does not match this template package name", + `slug: ${slug.value} / template package: ${this.package?.name}` + ); + } + const adr = new Adr({ + slug, + package: packageRef, + body: this.body.clone() + }); + adr.setTitle(title); + return adr; + } +} diff --git a/packages/core/src/adr/domain/Author.ts b/packages/core/src/adr/domain/Author.ts new file mode 100644 index 00000000..1b6bbdb5 --- /dev/null +++ b/packages/core/src/adr/domain/Author.ts @@ -0,0 +1,24 @@ +import { ValueObject } from "@src/domain"; + +type Props = { + name: string; + email?: string; +}; + +export class Author extends ValueObject<Props> { + constructor(name: string, email?: string) { + super({ name, email }); + } + + get name(): string { + return this.props.name; + } + + get email(): string | undefined { + return this.props.email; + } + + static createAnonymous(): Author { + return new Author("Anonymous"); + } +} diff --git a/packages/core/src/adr/domain/FilesystemPath.test.ts b/packages/core/src/adr/domain/FilesystemPath.test.ts new file mode 100644 index 00000000..7d06a8fa --- /dev/null +++ b/packages/core/src/adr/domain/FilesystemPath.test.ts @@ -0,0 +1,74 @@ +import { FilesystemPath } from "./FilesystemPath"; + +describe("FilesystemPath", () => { + it("throws when CWD is not absolute", () => { + expect(() => { + new FilesystemPath(".", "test"); + }).toThrow(); + }); + + it("returns the absolute path", () => { + expect(new FilesystemPath("/foo", "./bar/test").absolutePath).toEqual( + "/foo/bar/test" + ); + expect(new FilesystemPath("/foo", "bar/test").absolutePath).toEqual( + "/foo/bar/test" + ); + expect(new FilesystemPath("/foo/bar", "../test").absolutePath).toEqual( + "/foo/test" + ); + }); + + it("returns the basename", () => { + expect(new FilesystemPath("/foo", "bar/test").basename).toEqual("test"); + expect(new FilesystemPath("/foo", "bar/test.md").basename).toEqual( + "test.md" + ); + }); + + it("returns the extension", () => { + expect(new FilesystemPath("/foo", "bar/test").extension).toEqual(""); + expect(new FilesystemPath("/foo", "bar/test.md").extension).toEqual(".md"); + }); + + it("returns the basename without the extension", () => { + expect( + new FilesystemPath("/foo", "bar/test").basenameWithoutExtension + ).toEqual("test"); + expect( + new FilesystemPath("/foo", "bar/test.md").basenameWithoutExtension + ).toEqual("test"); + }); + + it("joins a FilesystemPath to a string path", () => { + expect( + new FilesystemPath("/foo", "bar/test").join("hello-world.md").absolutePath + ).toEqual("/foo/bar/test/hello-world.md"); + }); + + it("returns the relative path between two paths (from a directory)", () => { + const from = new FilesystemPath("/test", "foo/bar"); + expect(from.relative(new FilesystemPath("/test", "foo"), true)).toEqual( + ".." + ); + expect( + from.relative(new FilesystemPath("/test", "foo/lorem/ipsum"), true) + ).toEqual("../lorem/ipsum"); + expect( + from.relative(new FilesystemPath("/test", "foo/bar/test"), true) + ).toEqual("test"); + }); + + it("returns the relative path between two paths (from a file)", () => { + const from = new FilesystemPath("/test", "foo/bar.md"); + expect(from.relative(new FilesystemPath("/test", "foo"), false)).toEqual( + "" + ); + expect( + from.relative(new FilesystemPath("/test", "foo/lorem/ipsum"), false) + ).toEqual("lorem/ipsum"); + expect( + from.relative(new FilesystemPath("/test", "bar/test"), false) + ).toEqual("../bar/test"); + }); +}); diff --git a/packages/core/src/adr/domain/FilesystemPath.ts b/packages/core/src/adr/domain/FilesystemPath.ts new file mode 100644 index 00000000..b64d3851 --- /dev/null +++ b/packages/core/src/adr/domain/FilesystemPath.ts @@ -0,0 +1,77 @@ +import path from "path"; +import { Log4brainsError, ValueObject } from "@src/domain"; +import { forceUnixPath } from "@src/lib/paths"; + +type Props = { + cwdAbsolutePath: string; + pathRelativeToCwd: string; +}; + +export class FilesystemPath extends ValueObject<Props> { + constructor(cwdAbsolutePath: string, pathRelativeToCwd: string) { + super({ + cwdAbsolutePath: forceUnixPath(cwdAbsolutePath), + pathRelativeToCwd: forceUnixPath(pathRelativeToCwd) + }); + + if (!path.isAbsolute(cwdAbsolutePath)) { + throw new Log4brainsError("CWD path is not absolute", cwdAbsolutePath); + } + } + + get cwdAbsolutePath(): string { + return this.props.cwdAbsolutePath; + } + + get pathRelativeToCwd(): string { + return this.props.pathRelativeToCwd; + } + + get absolutePath(): string { + return forceUnixPath( + path.join(this.props.cwdAbsolutePath, this.pathRelativeToCwd) + ); + } + + get basename(): string { + return forceUnixPath(path.basename(this.pathRelativeToCwd)); + } + + get extension(): string { + // with the dot (.) + return path.extname(this.pathRelativeToCwd); + } + + get basenameWithoutExtension(): string { + if (!this.extension) { + return this.basename; + } + return this.basename.substring( + 0, + this.basename.length - this.extension.length + ); + } + + join(p: string): FilesystemPath { + return new FilesystemPath( + this.cwdAbsolutePath, + path.join(this.pathRelativeToCwd, p) + ); + } + + relative(to: FilesystemPath, amIaDirectory = false): string { + const from = amIaDirectory + ? this.absolutePath + : path.dirname(this.absolutePath); + return forceUnixPath(path.relative(from, to.absolutePath)); + } + + public equals(vo?: ValueObject<Props>): boolean { + // 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; + } +} diff --git a/packages/core/src/adr/domain/MarkdownAdrLink.test.ts b/packages/core/src/adr/domain/MarkdownAdrLink.test.ts new file mode 100644 index 00000000..55a6e046 --- /dev/null +++ b/packages/core/src/adr/domain/MarkdownAdrLink.test.ts @@ -0,0 +1,36 @@ +import { Adr } from "./Adr"; +import { AdrFile } from "./AdrFile"; +import { AdrSlug } from "./AdrSlug"; +import { FilesystemPath } from "./FilesystemPath"; +import { MarkdownAdrLink } from "./MarkdownAdrLink"; +import { MarkdownBody } from "./MarkdownBody"; +import { PackageRef } from "./PackageRef"; + +describe("MarkdownAdrLink", () => { + beforeAll(() => { + Adr.setTz("Etc/UTC"); + }); + afterAll(() => { + Adr.clearTz(); + }); + + it("works with relative paths", () => { + const from = new Adr({ + slug: new AdrSlug("from"), + file: new AdrFile(new FilesystemPath("/", "docs/adr/from.md")), + body: new MarkdownBody("") + }); + const to = new Adr({ + slug: new AdrSlug("test/to"), + package: new PackageRef("test"), + file: new AdrFile( + new FilesystemPath("/", "packages/test/docs/adr/to.md") + ), + body: new MarkdownBody("") + }); + const link = new MarkdownAdrLink(from, to); + expect(link.toMarkdown()).toEqual( + "[test/to](../../packages/test/docs/adr/to.md)" + ); + }); +}); diff --git a/packages/core/src/adr/domain/MarkdownAdrLink.ts b/packages/core/src/adr/domain/MarkdownAdrLink.ts new file mode 100644 index 00000000..f6debe44 --- /dev/null +++ b/packages/core/src/adr/domain/MarkdownAdrLink.ts @@ -0,0 +1,32 @@ +import { Log4brainsError, ValueObject } from "@src/domain"; +import type { Adr } from "./Adr"; + +type Props = { + from: Adr; + to: Adr; +}; + +export class MarkdownAdrLink extends ValueObject<Props> { + constructor(from: Adr, to: Adr) { + super({ from, to }); + } + + get from(): Adr { + return this.props.from; + } + + get to(): Adr { + return this.props.to; + } + + toMarkdown(): string { + 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})`; + } +} diff --git a/packages/core/src/adr/domain/MarkdownAdrLinkResolver.ts b/packages/core/src/adr/domain/MarkdownAdrLinkResolver.ts new file mode 100644 index 00000000..3dc57005 --- /dev/null +++ b/packages/core/src/adr/domain/MarkdownAdrLinkResolver.ts @@ -0,0 +1,6 @@ +import { Adr } from "./Adr"; +import { MarkdownAdrLink } from "./MarkdownAdrLink"; + +export interface MarkdownAdrLinkResolver { + resolve(from: Adr, uri: string): Promise<MarkdownAdrLink | undefined>; +} diff --git a/packages/core/src/adr/domain/MarkdownBody.test.ts b/packages/core/src/adr/domain/MarkdownBody.test.ts new file mode 100644 index 00000000..ace8c8de --- /dev/null +++ b/packages/core/src/adr/domain/MarkdownBody.test.ts @@ -0,0 +1,297 @@ +import { MarkdownBody } from "./MarkdownBody"; + +describe("MarkdownBody", () => { + describe("getFirstH1Title()", () => { + it("returns the first H1 title", () => { + const body = new MarkdownBody( + `# First title +Lorem ipsum +## Subtitle +## Subtitle +# Second title` + ); + expect(body.getFirstH1Title()).toEqual("First title"); + }); + + it("returns undefined when there is no first title", () => { + const body = new MarkdownBody( + `Lorem ipsum +## Subtitle +## Subtitle` + ); + expect(body.getFirstH1Title()).toBeUndefined(); + }); + }); + + describe("setFirstH1Title()", () => { + it("replaces the existing one", () => { + const body = new MarkdownBody( + `# First title +Lorem ipsum +## Subtitle +## Subtitle +# Second title` + ); + body.setFirstH1Title("New title"); + expect(body.getRawMarkdown()).toEqual(`# New title +Lorem ipsum +## Subtitle +## Subtitle +# Second title`); + }); + + it("creates one if needed", () => { + const body = new MarkdownBody( + `Lorem ipsum +## Subtitle +## Subtitle` + ); + body.setFirstH1Title("New title"); + expect(body.getRawMarkdown()).toEqual(`# New title +Lorem ipsum +## Subtitle +## Subtitle`); + }); + }); + + describe("getHeaderMetadata()", () => { + it("returns a metadata", () => { + const body = new MarkdownBody( + `# Hello World + +- Lorem Ipsum +- Status: draft +- DATE : 2020-01-01 + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title` + ); + expect(body.getHeaderMetadata("status")).toEqual("draft"); + expect(body.getHeaderMetadata("date")).toEqual("2020-01-01"); + }); + + it("returns a metadata even if there is a paragraph before", () => { + const body = new MarkdownBody( + `# Hello World +Hello! + +- Lorem Ipsum +- Status: draft +- DATE : 2020-01-01 + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title` + ); + expect(body.getHeaderMetadata("status")).toEqual("draft"); + expect(body.getHeaderMetadata("date")).toEqual("2020-01-01"); + }); + + it("returns undefined when the metadata is not set", () => { + const body = new MarkdownBody( + `# Hello World +Hello! + +- Lorem Ipsum +- Status: draft +- DATE : 2020-01-01 + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title` + ); + expect(body.getHeaderMetadata("Deciders")).toBeUndefined(); + }); + }); + + describe("setHeaderMetadata()", () => { + it("modifies an already existing metadata", () => { + const body = new MarkdownBody( + `# Hello World +Hello! + +- Lorem Ipsum +- Status: draft +- DATE : 2020-01-01 + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title` + ); + + body.setHeaderMetadata("Status", "accepted"); + + expect(body.getRawMarkdown()).toEqual(`# Hello World +Hello! + +- Lorem Ipsum +- Status: accepted +- DATE : 2020-01-01 + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title`); + }); + + it("creates a metadata", () => { + const body = new MarkdownBody( + `# Hello World +Hello! + +- Lorem Ipsum +- Status: draft +- DATE : 2020-01-01 + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title` + ); + + body.setHeaderMetadata("Deciders", "@JohnDoe"); + + expect(body.getRawMarkdown()).toEqual(`# Hello World +Hello! + +- Lorem Ipsum +- Status: draft +- DATE : 2020-01-01 +- Deciders: @JohnDoe + +Technical Story: [description | ticket/issue URL] <!-- optional --> +## Subtitle +## Subtitle +# Second title`); + }); + + it("creates a metadata even if the paragraph does not exist", () => { + const body = new MarkdownBody( + `# Hello World + +## Subtitle +## Subtitle +# Second title` + ); + + body.setHeaderMetadata("Deciders", "@JohnDoe"); + + expect(body.getRawMarkdown()).toEqual(`# Hello World + +- Deciders: @JohnDoe + + +## Subtitle +## Subtitle +# Second title`); + }); + }); + + describe("getlinks()", () => { + it("returns links", () => { + const body = new MarkdownBody( + `# Hello World +## Subtitle + +- test + +## Subtitle +## Links + +- link1: [foo](bar.md) +- link2` + ); + expect(body.getLinks()).toEqual(["link1: [foo](bar.md)", "link2"]); + }); + + it("returns undefined when no links", () => { + const body = new MarkdownBody( + `# Hello World +## Subtitle + +- test + +## Subtitle` + ); + expect(body.getLinks()).toBeUndefined(); + }); + }); + + describe("addLink()", () => { + it("adds a link", () => { + const body = new MarkdownBody( + `# Hello World +## Subtitle +## Links + +- link1: [foo](bar.md) +- link2 + +` + ); // TODO: fix this whitespace issue + + body.addLink("link3"); + + expect(body.getRawMarkdown()).toEqual(`# Hello World +## Subtitle +## Links + +- link1: [foo](bar.md) +- link2 +- link3 + +`); + }); + + it("adds a link even if the paragraph does not exist", () => { + const body = new MarkdownBody( + `# Hello World +## Subtitle +Lorem ipsum` + ); // TODO: fix this whitespace issue + + body.addLink("link1"); + + expect(body.getRawMarkdown()).toEqual(`# Hello World +## Subtitle +Lorem ipsum + +## Links + +- link1 + +`); + }); + }); + + describe("addLinkNoDuplicate()", () => { + it("does not add the link if there is a duplicate", () => { + const body = new MarkdownBody( + `# Hello World +## Subtitle +## Links + +- link test + +` + ); // TODO: fix this whitespace issue + + body.addLinkNoDuplicate("Link TEST"); + body.addLinkNoDuplicate("Link2"); + + expect(body.getRawMarkdown()).toEqual(`# Hello World +## Subtitle +## Links + +- link test +- Link2 + +`); + }); + }); +}); diff --git a/packages/core/src/adr/domain/MarkdownBody.ts b/packages/core/src/adr/domain/MarkdownBody.ts new file mode 100644 index 00000000..5aa9e8ab --- /dev/null +++ b/packages/core/src/adr/domain/MarkdownBody.ts @@ -0,0 +1,248 @@ +import cheerio from "cheerio"; +import { Entity, Log4brainsError } from "@src/domain"; +import { CheerioMarkdown, cheerioToMarkdown } from "@src/lib/cheerio-markdown"; +import type { Adr } from "./Adr"; +import { MarkdownAdrLinkResolver } from "./MarkdownAdrLinkResolver"; + +type Props = { + value: string; +}; + +type ElementAndRegExpMatch = { + element: cheerio.Cheerio; + match: string[]; +}; + +type Link = { + text: string; + href: string; +}; + +function htmlentities(str: string): string { + return str + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +export class MarkdownBody extends Entity<Props> { + private cm: CheerioMarkdown; + + private adrLinkResolver?: MarkdownAdrLinkResolver; + + constructor(value: string) { + super({ value }); + this.cm = new CheerioMarkdown(value); + this.cm.onChange((newValue) => { + this.props.value = newValue; + }); + } + + setAdrLinkResolver(resolver: MarkdownAdrLinkResolver): MarkdownBody { + this.adrLinkResolver = resolver; + return this; + } + + private getFirstH1TitleElement(): cheerio.Cheerio | undefined { + const elt = this.cm.$("h1").first(); + return elt.length > 0 ? elt : undefined; + } + + getFirstH1Title(): string | undefined { + return this.getFirstH1TitleElement()?.text(); + } + + setFirstH1Title(title: string): void { + const elt = this.getFirstH1TitleElement(); + if (elt) { + this.cm.replaceText(elt, title); + } else { + this.cm.insertLineAt(0, `# ${title}`); + } + } + + deleteFirstH1Title(): void { + const elt = this.getFirstH1TitleElement(); + if (elt) { + this.cm.deleteElement(elt); + } + } + + private getHeaderMetadataUl(): cheerio.Cheerio | undefined { + const elts = this.cm.$("body > *:first-child").nextUntil("h2").addBack(); + const ul = elts.filter("ul").first(); + return ul.length > 0 ? ul : undefined; + } + + private getHeaderMetadataElementAndMatch( + key: string + ): ElementAndRegExpMatch | undefined { + 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() as ElementAndRegExpMatch[]; + return result[0] ?? undefined; + } + + getHeaderMetadata(key: string): string | undefined { + return this.getHeaderMetadataElementAndMatch(key)?.match[2].trim(); + } + + setHeaderMetadata(key: string, value: string): void { + 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: string): void { + // 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); + } + } + + private getLinksUl(): cheerio.Cheerio | undefined { + 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(): string[] | undefined { + const ul = this.getLinksUl(); + if (!ul) { + return undefined; + } + return ul + .children() + .map((i, li) => cheerioToMarkdown(this.cm.$(li))) + .get() as string[]; + } + + addLink(link: string): void { + const ul = this.getLinksUl(); + if (ul === undefined) { + this.cm.appendLine(`\n## Links\n\n- ${link}`); + } else { + this.cm.appendToList(ul, link); + } + } + + addLinkNoDuplicate(link: string): void { + 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(): string { + return this.props.value; + } + + clone(): MarkdownBody { + const copy = new MarkdownBody(this.props.value); + if (this.adrLinkResolver) { + copy.setAdrLinkResolver(this.adrLinkResolver); + } + return copy; + } + + async replaceAdrLinks(from: Adr): Promise<void> { + const links = this.cm + .$("a") + .map((_, element) => ({ + text: this.cm.$(element).text(), + href: this.cm.$(element).attr("href") + })) + .get() as Link[]; + + 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); + } +} diff --git a/packages/core/src/adr/domain/Package.ts b/packages/core/src/adr/domain/Package.ts new file mode 100644 index 00000000..1783e75c --- /dev/null +++ b/packages/core/src/adr/domain/Package.ts @@ -0,0 +1,23 @@ +import { Entity } from "@src/domain"; +import { FilesystemPath } from "./FilesystemPath"; +import { PackageRef } from "./PackageRef"; + +type Props = { + ref: PackageRef; + path: FilesystemPath; + adrFolderPath: FilesystemPath; +}; + +export class Package extends Entity<Props> { + get ref(): PackageRef { + return this.props.ref; + } + + get path(): FilesystemPath { + return this.props.path; + } + + get adrFolderPath(): FilesystemPath { + return this.props.adrFolderPath; + } +} diff --git a/packages/core/src/adr/domain/PackageRef.ts b/packages/core/src/adr/domain/PackageRef.ts new file mode 100644 index 00000000..2cc522aa --- /dev/null +++ b/packages/core/src/adr/domain/PackageRef.ts @@ -0,0 +1,15 @@ +import { ValueObject } from "@src/domain"; + +type Props = { + name: string; +}; + +export class PackageRef extends ValueObject<Props> { + constructor(name: string) { + super({ name }); + } + + get name(): string { + return this.props.name; + } +} diff --git a/packages/core/src/adr/domain/index.ts b/packages/core/src/adr/domain/index.ts new file mode 100644 index 00000000..2404da00 --- /dev/null +++ b/packages/core/src/adr/domain/index.ts @@ -0,0 +1,13 @@ +export * from "./Adr"; +export * from "./AdrFile"; +export * from "./AdrRelation"; +export * from "./AdrSlug"; +export * from "./AdrStatus"; +export * from "./AdrTemplate"; +export * from "./Author"; +export * from "./FilesystemPath"; +export * from "./MarkdownAdrLink"; +export * from "./MarkdownAdrLinkResolver"; +export * from "./MarkdownBody"; +export * from "./Package"; +export * from "./PackageRef"; diff --git a/packages/core/src/adr/infrastructure/MarkdownAdrLinkResolver.ts b/packages/core/src/adr/infrastructure/MarkdownAdrLinkResolver.ts new file mode 100644 index 00000000..8ca13859 --- /dev/null +++ b/packages/core/src/adr/infrastructure/MarkdownAdrLinkResolver.ts @@ -0,0 +1,40 @@ +import { + Adr, + AdrFile, + MarkdownAdrLink, + MarkdownAdrLinkResolver as IMarkdownAdrLinkResolver +} from "@src/adr/domain"; +import { Log4brainsError } from "@src/domain"; +import type { AdrRepository } from "./repositories"; + +type Deps = { + adrRepository: AdrRepository; +}; + +export class MarkdownAdrLinkResolver implements IMarkdownAdrLinkResolver { + private readonly adrRepository: AdrRepository; + + constructor({ adrRepository }: Deps) { + this.adrRepository = adrRepository; + } + + async resolve(from: Adr, uri: string): Promise<MarkdownAdrLink | undefined> { + 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); + } +} diff --git a/packages/core/src/adr/infrastructure/repositories/AdrRepository.ts b/packages/core/src/adr/infrastructure/repositories/AdrRepository.ts new file mode 100644 index 00000000..ed72274e --- /dev/null +++ b/packages/core/src/adr/infrastructure/repositories/AdrRepository.ts @@ -0,0 +1,301 @@ +/* eslint-disable class-methods-use-this */ +import fs, { promises as fsP } from "fs"; +import path from "path"; +import simpleGit, { SimpleGit } from "simple-git"; +import { AdrRepository as IAdrRepository } from "@src/adr/application"; +import { + Adr, + AdrFile, + AdrSlug, + Author, + FilesystemPath, + MarkdownBody, + PackageRef +} from "@src/adr/domain"; +import { Log4brainsConfig } from "@src/infrastructure/config"; +import { Log4brainsError } from "@src/domain"; +import { PackageRepository } from "./PackageRepository"; +import { MarkdownAdrLinkResolver } from "../MarkdownAdrLinkResolver"; + +type GitMetadata = { + creationDate: Date; + lastEditDate: Date; + lastEditAuthor: Author; +}; + +type Deps = { + config: Log4brainsConfig; + workdir: string; + packageRepository: PackageRepository; +}; + +export class AdrRepository implements IAdrRepository { + private readonly config: Log4brainsConfig; + + private readonly workdir: string; + + private readonly packageRepository: PackageRepository; + + private readonly git: SimpleGit; + + private gitAvailable?: boolean; + + private anonymousAuthor?: Author; + + private readonly markdownAdrLinkResolver: MarkdownAdrLinkResolver; + + constructor({ config, workdir, packageRepository }: Deps) { + this.config = config; + this.workdir = workdir; + this.packageRepository = packageRepository; + this.git = simpleGit({ baseDir: workdir }); + this.markdownAdrLinkResolver = new MarkdownAdrLinkResolver({ + adrRepository: this + }); + } + + private async isGitAvailable(): Promise<boolean> { + if (this.gitAvailable === undefined) { + try { + this.gitAvailable = await this.git.checkIsRepo(); + } catch (e) { + this.gitAvailable = false; + } + } + return this.gitAvailable; + } + + async find(slug: AdrSlug): Promise<Adr> { + 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: AdrFile): Promise<Adr | undefined> { + 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(): Promise<Adr[]> { + 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); + } + + private async getGitMetadata( + file: AdrFile + ): Promise<GitMetadata | undefined> { + 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. + */ + private async getAnonymousAuthor(): Promise<Author> { + if (!this.anonymousAuthor) { + this.anonymousAuthor = Author.createAnonymous(); + if (await this.isGitAvailable()) { + const config = await this.git.listConfig(); + if (config?.all["user.name"]) { + this.anonymousAuthor = new Author( + config.all["user.name"] as string, + config.all["user.email"] as string | undefined + ); + } + } + } + return this.anonymousAuthor; + } + + private async getLastEditDateFromFilesystem(file: AdrFile): Promise<Date> { + const stat = await fsP.stat(file.path.absolutePath); + return stat.mtime; + } + + private async findInPath( + slug: AdrSlug, + p: FilesystemPath, + packageRef?: PackageRef + ): Promise<Adr | undefined> { + return ( + await this.findAllInPath(p, packageRef, (f: AdrFile, s: AdrSlug) => + s.equals(slug) + ) + ).pop(); + } + + private async findAllInPath( + p: FilesystemPath, + packageRef?: PackageRef, + filter?: (f: AdrFile, s: AdrSlug) => boolean + ): Promise<Adr[]> { + const files = await fsP.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 fsP + .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({ + ...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({ + ...baseAdrProps, + creationDate: lastEditDate, + lastEditDate, + lastEditAuthor: await this.getAnonymousAuthor() + }); + }); + }) + ); + } + + generateAvailableSlug(title: string, packageRef?: PackageRef): AdrSlug { + const adrFolderPath = this.getAdrFolderPath(packageRef); + const baseSlug = AdrSlug.createFromTitle(title, packageRef); + + let i = 1; + let slug: AdrSlug; + let filename: string; + 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; + } + + private getPackageRef(slug: AdrSlug): PackageRef | undefined { + // undefined if global + return slug.packagePart ? new PackageRef(slug.packagePart) : undefined; + } + + private getAdrFolderPath(packageRef?: PackageRef): FilesystemPath { + 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: Adr): Promise<void> { + 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 fsP.writeFile(file.path.absolutePath, adr.body.getRawMarkdown(), { + encoding: "utf-8" + }); + } +} diff --git a/packages/core/src/adr/infrastructure/repositories/AdrTemplateRepository.ts b/packages/core/src/adr/infrastructure/repositories/AdrTemplateRepository.ts new file mode 100644 index 00000000..5a647871 --- /dev/null +++ b/packages/core/src/adr/infrastructure/repositories/AdrTemplateRepository.ts @@ -0,0 +1,70 @@ +/* eslint-disable class-methods-use-this */ +import fs, { promises as fsP } from "fs"; +import path from "path"; +import { AdrTemplateRepository as IAdrTemplateRepository } from "@src/adr/application"; +import { + AdrTemplate, + FilesystemPath, + MarkdownBody, + PackageRef +} from "@src/adr/domain"; +import { Log4brainsConfig } from "@src/infrastructure/config"; +import { Log4brainsError } from "@src/domain"; +import { PackageRepository } from "./PackageRepository"; + +type Deps = { + config: Log4brainsConfig; + workdir: string; + packageRepository: PackageRepository; +}; + +export class AdrTemplateRepository implements IAdrTemplateRepository { + private readonly config: Log4brainsConfig; + + private readonly workdir: string; + + private readonly packageRepository: PackageRepository; + + constructor({ config, workdir, packageRepository }: Deps) { + this.config = config; + this.workdir = workdir; + this.packageRepository = packageRepository; + } + + async find(packageRef?: PackageRef): Promise<AdrTemplate> { + 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 fsP.readFile(templatePath, { + encoding: "utf8" + }); + return new AdrTemplate({ + package: packageRef, + body: new MarkdownBody(markdown) + }); + } + + private getAdrFolderPath(packageRef?: PackageRef): FilesystemPath { + 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); + } +} diff --git a/packages/core/src/adr/infrastructure/repositories/PackageRepository.ts b/packages/core/src/adr/infrastructure/repositories/PackageRepository.ts new file mode 100644 index 00000000..2e9bef4d --- /dev/null +++ b/packages/core/src/adr/infrastructure/repositories/PackageRepository.ts @@ -0,0 +1,87 @@ +import path from "path"; +import fs from "fs"; +import { FilesystemPath, Package, PackageRef } from "@src/adr/domain"; +import { Log4brainsConfig } from "@src/infrastructure/config"; +import { Log4brainsError } from "@src/domain"; + +type Deps = { + config: Log4brainsConfig; + workdir: string; +}; + +export class PackageRepository { + private readonly config: Log4brainsConfig; + + private readonly workdir: string; + + // We cache findAll() results to avoid unnecessary I/O checks + private packages?: Package[]; + + constructor({ config, workdir }: Deps) { + this.config = config; + this.workdir = workdir; + } + + find(packageRef: PackageRef): Package { + 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: FilesystemPath): Package | undefined { + return this.findAll() + .filter((p) => p.adrFolderPath.equals(adrFolderPath)) + .pop(); + } + + findAll(): Package[] { + if (!this.packages) { + this.packages = ( + this.config.project.packages || [] + ).map((packageConfig) => + this.buildPackage( + packageConfig.name, + packageConfig.path, + packageConfig.adrFolder + ) + ); + } + return this.packages; + } + + private buildPackage( + name: string, + projectPath: string, + adrFolder: string + ): Package { + 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; + } +} diff --git a/packages/core/src/adr/infrastructure/repositories/index.ts b/packages/core/src/adr/infrastructure/repositories/index.ts new file mode 100644 index 00000000..505db30d --- /dev/null +++ b/packages/core/src/adr/infrastructure/repositories/index.ts @@ -0,0 +1,3 @@ +export * from "./AdrRepository"; +export * from "./AdrTemplateRepository"; +export * from "./PackageRepository"; diff --git a/packages/core/src/application/Command.ts b/packages/core/src/application/Command.ts new file mode 100644 index 00000000..90744453 --- /dev/null +++ b/packages/core/src/application/Command.ts @@ -0,0 +1 @@ +export abstract class Command {} diff --git a/packages/core/src/application/CommandHandler.ts b/packages/core/src/application/CommandHandler.ts new file mode 100644 index 00000000..28663eaf --- /dev/null +++ b/packages/core/src/application/CommandHandler.ts @@ -0,0 +1,8 @@ +import { Command } from "./Command"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface CommandHandler<C extends Command = any> { + // eslint-disable-next-line @typescript-eslint/ban-types + readonly commandClass: Function; + execute(command: C): Promise<void>; +} diff --git a/packages/core/src/application/Query.ts b/packages/core/src/application/Query.ts new file mode 100644 index 00000000..1acca031 --- /dev/null +++ b/packages/core/src/application/Query.ts @@ -0,0 +1 @@ +export abstract class Query {} diff --git a/packages/core/src/application/QueryHandler.ts b/packages/core/src/application/QueryHandler.ts new file mode 100644 index 00000000..8b3396e4 --- /dev/null +++ b/packages/core/src/application/QueryHandler.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Query } from "./Query"; + +export interface QueryHandler<Q extends Query = any, QR = any> { + // eslint-disable-next-line @typescript-eslint/ban-types + readonly queryClass: Function; + execute(query: Q): Promise<QR>; +} diff --git a/packages/core/src/application/index.ts b/packages/core/src/application/index.ts new file mode 100644 index 00000000..c21d020c --- /dev/null +++ b/packages/core/src/application/index.ts @@ -0,0 +1,4 @@ +export * from "./Command"; +export * from "./CommandHandler"; +export * from "./Query"; +export * from "./QueryHandler"; diff --git a/packages/core/src/decs.d.ts b/packages/core/src/decs.d.ts new file mode 100644 index 00000000..ffa330a7 --- /dev/null +++ b/packages/core/src/decs.d.ts @@ -0,0 +1,12 @@ +declare module "markdown-it-source-map"; + +declare module "launch-editor" { + export default function ( + path: string, + specifiedEditor?: string, + onErrorCallback?: ( + filename: string, + message?: string + ) => void | Promise<void> + ): void; +} diff --git a/packages/core/src/domain/AggregateRoot.ts b/packages/core/src/domain/AggregateRoot.ts new file mode 100644 index 00000000..b06b53c4 --- /dev/null +++ b/packages/core/src/domain/AggregateRoot.ts @@ -0,0 +1,5 @@ +import { Entity } from "./Entity"; + +export abstract class AggregateRoot< + T extends Record<string, unknown> +> extends Entity<T> {} diff --git a/packages/core/src/domain/Entity.ts b/packages/core/src/domain/Entity.ts new file mode 100644 index 00000000..f1940477 --- /dev/null +++ b/packages/core/src/domain/Entity.ts @@ -0,0 +1,7 @@ +export abstract class Entity<T extends Record<string, unknown>> { + constructor(public readonly props: T) {} + + public equals(e?: Entity<T>): boolean { + return e === this; // One instance allowed per entity + } +} diff --git a/packages/core/src/domain/Log4brainsError.ts b/packages/core/src/domain/Log4brainsError.ts new file mode 100644 index 00000000..e36fdf79 --- /dev/null +++ b/packages/core/src/domain/Log4brainsError.ts @@ -0,0 +1,9 @@ +/** + * Log4brains Error base class. + * Any error thrown by the core API extends this class. + */ +export class Log4brainsError extends Error { + constructor(public readonly name: string, public readonly details?: string) { + super(`${name}${details ? ` (${details})` : ""}`); + } +} diff --git a/packages/core/src/domain/ValueObject.test.ts b/packages/core/src/domain/ValueObject.test.ts new file mode 100644 index 00000000..8596d96c --- /dev/null +++ b/packages/core/src/domain/ValueObject.test.ts @@ -0,0 +1,49 @@ +import { ValueObject } from "./ValueObject"; + +describe("ValueObject", () => { + type MyVo1Props = { + prop1: string; + prop2: number; + }; + class MyVo1 extends ValueObject<MyVo1Props> {} + class MyVo1bis extends ValueObject<MyVo1Props> {} + + type MyVo2Props = { + prop1: string; + }; + class MyVo2 extends ValueObject<MyVo2Props> {} + + describe("equals()", () => { + it("returns true for the same instance", () => { + const vo = new MyVo1({ prop1: "foo", prop2: 42 }); + expect(vo.equals(vo)).toBeTruthy(); + }); + + it("returns true for a different instance with the same props", () => { + const vo1 = new MyVo1({ prop1: "foo", prop2: 42 }); + const vo2 = new MyVo1({ prop1: "foo", prop2: 42 }); + expect(vo1.equals(vo2)).toBeTruthy(); + expect(vo2.equals(vo1)).toBeTruthy(); + }); + + it("returns false for a different instance", () => { + const vo1 = new MyVo1({ prop1: "foo", prop2: 42 }); + const vo2 = new MyVo1({ prop1: "bar", prop2: 42 }); + expect(vo1.equals(vo2)).toBeFalsy(); + expect(vo2.equals(vo1)).toBeFalsy(); + }); + + it("returns false for a different class", () => { + const vo1 = new MyVo1({ prop1: "foo", prop2: 42 }); + const vo2 = new MyVo2({ prop1: "foo" }); + expect(vo2.equals(vo1)).toBeFalsy(); + }); + + it("returns false for a different class with the same props", () => { + const vo1 = new MyVo1({ prop1: "foo", prop2: 42 }); + const vo1bis = new MyVo1bis({ prop1: "foo", prop2: 42 }); + expect(vo1bis.equals(vo1)).toBeFalsy(); + expect(vo1.equals(vo1bis)).toBeFalsy(); + }); + }); +}); diff --git a/packages/core/src/domain/ValueObject.ts b/packages/core/src/domain/ValueObject.ts new file mode 100644 index 00000000..5af58824 --- /dev/null +++ b/packages/core/src/domain/ValueObject.ts @@ -0,0 +1,31 @@ +import isEqual from "lodash/isEqual"; + +// Inspired from https://khalilstemmler.com/articles/typescript-value-object/ +// Thank you :-) + +export type ValueObjectProps = Record<string, unknown>; + +/** + * @desc ValueObjects are objects that we determine their + * equality through their structural property. + */ +export abstract class ValueObject<T extends ValueObjectProps> { + public readonly props: T; + + constructor(props: T) { + this.props = Object.freeze(props); + } + + public equals(vo?: ValueObject<T>): boolean { + 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); + } +} diff --git a/packages/core/src/domain/ValueObjectArray.ts b/packages/core/src/domain/ValueObjectArray.ts new file mode 100644 index 00000000..17f487a0 --- /dev/null +++ b/packages/core/src/domain/ValueObjectArray.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ValueObject } from "./ValueObject"; + +export class ValueObjectArray { + static inArray<VO extends ValueObject<any>>( + object: VO, + array: VO[] + ): boolean { + return array.some((o) => o.equals(object)); + } +} diff --git a/packages/core/src/domain/ValueObjectMap.ts b/packages/core/src/domain/ValueObjectMap.ts new file mode 100644 index 00000000..eb1f825a --- /dev/null +++ b/packages/core/src/domain/ValueObjectMap.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { ValueObject } from "./ValueObject"; + +export class ValueObjectMap<K extends ValueObject<any>, V> + implements Map<K, V> { + private readonly map: Map<K, V>; + + constructor(tuples?: [K, V][]) { + this.map = new Map<K, V>(tuples); + } + + private getKeyRef(key: K): K | undefined { + // eslint-disable-next-line no-restricted-syntax + for (const i of this.map.keys()) { + if (i.equals(key)) { + return i; + } + } + return undefined; + } + + clear(): void { + this.map.clear(); + } + + delete(key: K): boolean { + const keyRef = this.getKeyRef(key); + if (!keyRef) { + return false; + } + return this.delete(keyRef); + } + + forEach( + callbackfn: (value: V, key: K, map: Map<K, V>) => void, + thisArg?: any + ): void { + this.map.forEach(callbackfn, thisArg); + } + + get(key: K): V | undefined { + const keyRef = this.getKeyRef(key); + if (!keyRef) { + return undefined; + } + return this.map.get(keyRef); + } + + has(key: K): boolean { + const keyRef = this.getKeyRef(key); + if (!keyRef) { + return false; + } + return this.map.has(keyRef); + } + + set(key: K, value: V): this { + this.map.set(key, value); + return this; + } + + get size(): number { + return this.map.size; + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.map[Symbol.iterator](); + } + + entries(): IterableIterator<[K, V]> { + return this.map.entries(); + } + + keys(): IterableIterator<K> { + return this.map.keys(); + } + + values(): IterableIterator<V> { + return this.map.values(); + } + + get [Symbol.toStringTag](): string { + return this.map[Symbol.toStringTag]; + } +} diff --git a/packages/core/src/domain/index.ts b/packages/core/src/domain/index.ts new file mode 100644 index 00000000..37864b4a --- /dev/null +++ b/packages/core/src/domain/index.ts @@ -0,0 +1,6 @@ +export * from "./AggregateRoot"; +export * from "./Entity"; +export * from "./Log4brainsError"; +export * from "./ValueObject"; +export * from "./ValueObjectArray"; +export * from "./ValueObjectMap"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..9a3c9fb9 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,6 @@ +import "./polyfills"; + +export * from "./infrastructure/api"; +export * from "./infrastructure/file-watcher"; +export { Log4brainsError } from "./domain"; +export { Log4brainsConfig } from "./infrastructure/config"; diff --git a/packages/core/src/infrastructure/api/Log4brains.ts b/packages/core/src/infrastructure/api/Log4brains.ts new file mode 100644 index 00000000..295c96d7 --- /dev/null +++ b/packages/core/src/infrastructure/api/Log4brains.ts @@ -0,0 +1,214 @@ +import { AwilixContainer } from "awilix"; +import { buildContainer } from "@src/infrastructure/di"; +import open from "open"; +import launchEditor from "launch-editor"; +import { Adr, AdrSlug, AdrStatus, PackageRef } from "@src/adr/domain"; +import { + CreateAdrFromTemplateCommand, + SupersedeAdrCommand +} from "@src/adr/application/commands"; +import { + SearchAdrsQuery, + SearchAdrsFilters as AppSearchAdrsFilters, + GenerateAdrSlugFromTitleQuery, + AdrRepository, + GetAdrBySlugQuery +} from "@src/adr/application"; +import { Log4brainsError } from "@src/domain"; +import { buildConfigFromWorkdir, Log4brainsConfig } from "../config"; +import { AdrDto, AdrDtoStatus } from "./types"; +import { adrToDto } from "./transformers"; +import { CommandBus, QueryBus } from "../buses"; +import { FileWatcher } from "../file-watcher"; + +export type SearchAdrsFilters = { + statuses?: AdrDtoStatus[]; +}; + +/** + * Log4brains core API. + * Use {@link Log4brains.create} to build an instance. + */ +export class Log4brains { + private readonly container: AwilixContainer; + + private readonly commandBus: CommandBus; + + private readonly queryBus: QueryBus; + + private readonly adrRepository: AdrRepository; + + private constructor( + public readonly config: Log4brainsConfig, + public readonly workdir = "." + ) { + this.container = buildContainer(config, workdir); + this.commandBus = this.container.resolve<CommandBus>("commandBus"); + this.queryBus = this.container.resolve<QueryBus>("queryBus"); + this.adrRepository = this.container.resolve<AdrRepository>("adrRepository"); + + // @see Adr.tz + Adr.setTz(config.project.tz); + } + + /** + * Returns the ADRs which match the given search filters. + * Returns all the ADRs of the project if no filter is given. + * The results are sorted with this order priority (ASC): + * 1. By the Date field from the markdown file (if available) + * 2. By the Git creation date (does not follow renames) + * 3. By slug + * + * @param filters Optional. Filters to apply to the search + * + * @throws {@link Log4brainsError} + * In case of a non-recoverable error. + */ + async searchAdrs(filters?: SearchAdrsFilters): Promise<AdrDto[]> { + const appFilters: AppSearchAdrsFilters = {}; + if (filters?.statuses) { + appFilters.statuses = filters.statuses.map((status) => + AdrStatus.createFromName(status) + ); + } + const adrs = await this.queryBus.dispatch<Adr[]>( + new SearchAdrsQuery(appFilters) + ); + return Promise.all( + adrs.map((adr) => adrToDto(adr, this.config.project.repository)) + ); + } + + /** + * Returns an ADR by its slug. + * + * @param slug ADR slug + * + * @throws {@link Log4brainsError} + * In case of a non-recoverable error. + */ + async getAdrBySlug(slug: string): Promise<AdrDto | undefined> { + const adr = await this.queryBus.dispatch<Adr | undefined>( + new GetAdrBySlugQuery(new AdrSlug(slug)) + ); + return adr ? adrToDto(adr, this.config.project.repository) : undefined; + } + + /** + * Generates an available ADR slug for the given title and package. + * Format: [package-name/]yyyymmdd-slugified-lowercased-title + * + * @param title The title of the ADR + * @param packageName Optional. The package name of the ADR. + * + * @throws {@link Log4brainsError} + * In case of a non-recoverable error. + */ + async generateAdrSlug(title: string, packageName?: string): Promise<string> { + const packageRef = packageName ? new PackageRef(packageName) : undefined; + return ( + await this.queryBus.dispatch<AdrSlug>( + new GenerateAdrSlugFromTitleQuery(title, packageRef) + ) + ).value; + } + + /** + * Creates a new ADR with the given slug and title with the default template. + * @param slug The slug of the ADR + * @param title The title of the ADR + * + * @throws {@link Log4brainsError} + * In case of a non-recoverable error. + */ + async createAdrFromTemplate(slug: string, title: string): Promise<AdrDto> { + const slugObj = new AdrSlug(slug); + await this.commandBus.dispatch( + new CreateAdrFromTemplateCommand(slugObj, title) + ); + return adrToDto(await this.adrRepository.find(slugObj)); + } + + /** + * Supersede an ADR with another one. + * @param supersededSlug Slug of the superseded ADR + * @param supersederSlug Slug of the superseder ADR + * + * @throws {@link Log4brainsError} + * In case of a non-recoverable error. + */ + async supersedeAdr( + supersededSlug: string, + supersederSlug: string + ): Promise<void> { + const supersededSlugObj = new AdrSlug(supersededSlug); + const supersederSlugObj = new AdrSlug(supersederSlug); + await this.commandBus.dispatch( + new SupersedeAdrCommand(supersededSlugObj, supersederSlugObj) + ); + } + + /** + * Opens the given ADR in an editor on the local machine. + * Tries first to guess the preferred editor of the user thanks to https://github.com/yyx990803/launch-editor. + * If impossible to guess, uses xdg-open (or similar, depending on the OS, thanks to https://github.com/sindresorhus/open) as a fallback. + * The overall order is thus the following: + * 1) The currently running editor among the supported ones by launch-editor + * 2) The editor defined by the $VISUAL environment variable + * 3) The editor defined by the $EDITOR environment variable + * 4) Fallback: xdg-open or similar + * + * @param slug The ADR slug to open + * @param onImpossibleToGuess Optional. Callback called when the fallback method is used. + * Useful to display a warning to the user to tell him to set his $VISUAL environment variable for the next time. + * + * @throws {@link Log4brainsError} + * If the ADR does not exist or if even the fallback method fails. + */ + async openAdrInEditor( + slug: string, + onImpossibleToGuess?: () => void + ): Promise<void> { + const adr = await this.queryBus.dispatch<Adr | undefined>( + new GetAdrBySlugQuery(new AdrSlug(slug)) + ); + if (!adr) { + throw new Log4brainsError("This ADR does not exist", slug); + } + const { file } = adr; + if (!file) { + throw new Log4brainsError( + "You are trying to open an non-saved ADR", + slug + ); + } + + launchEditor(file.path.absolutePath, undefined, async () => { + await open(file.path.absolutePath); + if (onImpossibleToGuess) { + onImpossibleToGuess(); + } + }); + } + + /** + * Returns a singleton instance of FileWatcher. + * Useful for Hot Reloading. + * @see FileWatcher + */ + get fileWatcher(): FileWatcher { + return this.container.resolve<FileWatcher>("fileWatcher"); + } + + /** + * Creates an instance of the Log4brains API. + * + * @param workdir Path to working directory (ie. where ".log4brains.yml" is located) + * + * @throws {@link Log4brainsError} + * In case of missing or invalid config file. + */ + static create(workdir = "."): Log4brains { + return new Log4brains(buildConfigFromWorkdir(workdir), workdir); + } +} diff --git a/packages/core/src/infrastructure/api/index.ts b/packages/core/src/infrastructure/api/index.ts new file mode 100644 index 00000000..de29dddb --- /dev/null +++ b/packages/core/src/infrastructure/api/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./Log4brains"; diff --git a/packages/core/src/infrastructure/api/transformers/adr-transformers.ts b/packages/core/src/infrastructure/api/transformers/adr-transformers.ts new file mode 100644 index 00000000..ae26b390 --- /dev/null +++ b/packages/core/src/infrastructure/api/transformers/adr-transformers.ts @@ -0,0 +1,60 @@ +import { Adr, AdrFile } from "@src/adr/domain"; +import { GitRepositoryConfig } from "@src/infrastructure/config"; +import { deepFreeze } from "@src/utils"; +import { AdrDto, AdrDtoStatus } from "../types"; + +function buildViewUrl( + repositoryConfig: GitRepositoryConfig, + file: AdrFile +): string | undefined { + if (!repositoryConfig.url || !repositoryConfig.viewFileUriPattern) { + return undefined; + } + const uri = repositoryConfig.viewFileUriPattern + .replace("%branch", "master") // TODO: make this customizable + .replace("%path", file.path.pathRelativeToCwd); + return `${repositoryConfig.url.replace(/\.git$/, "")}${uri}`; +} + +export async function adrToDto( + adr: Adr, + repositoryConfig?: GitRepositoryConfig +): Promise<AdrDto> { + if (!adr.file) { + throw new Error("You are serializing an non-saved ADR"); + } + + const viewUrl = repositoryConfig + ? buildViewUrl(repositoryConfig, adr.file) + : undefined; + + return deepFreeze<AdrDto>({ + slug: adr.slug.value, + package: adr.package?.name || null, + title: adr.title || null, + status: adr.status.name as AdrDtoStatus, + supersededBy: 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?.toJSON() || null, + file: { + relativePath: adr.file.path.pathRelativeToCwd, + absolutePath: adr.file.path.absolutePath + }, + ...(repositoryConfig && repositoryConfig.provider && viewUrl + ? { + repository: { + provider: repositoryConfig.provider, + viewUrl + } + } + : undefined) + }); +} diff --git a/packages/core/src/infrastructure/api/transformers/index.ts b/packages/core/src/infrastructure/api/transformers/index.ts new file mode 100644 index 00000000..6bd15c51 --- /dev/null +++ b/packages/core/src/infrastructure/api/transformers/index.ts @@ -0,0 +1 @@ +export * from "./adr-transformers"; diff --git a/packages/core/src/infrastructure/api/types/AdrDto.ts b/packages/core/src/infrastructure/api/types/AdrDto.ts new file mode 100644 index 00000000..b9ba74a6 --- /dev/null +++ b/packages/core/src/infrastructure/api/types/AdrDto.ts @@ -0,0 +1,37 @@ +import { GitProvider } from "@src/infrastructure/config"; + +export type AdrDtoStatus = + | "draft" + | "proposed" + | "rejected" + | "accepted" + | "deprecated" + | "superseded"; + +// Dates are string (Date.toJSON()) because because Next.js cannot serialize Date objects + +export type AdrDto = Readonly<{ + slug: string; // Follows this pattern: <package name>/<sub slug> or just <sub slug> when the ADR does not belong to a specific package + package: string | null; // Null when the ADR does not belong to a package + title: string | null; + status: AdrDtoStatus; + supersededBy: string | null; // Optionally contains the target ADR slug when status === "superseded" + tags: string[]; // Can be empty + deciders: string[]; // Can be empty. In this case, it is encouraged to use lastEditAuthor as the only decider + body: Readonly<{ + rawMarkdown: string; + enhancedMdx: string; + }>; + creationDate: string; // Comes from Git or filesystem + lastEditDate: string; // Comes from Git or filesystem + lastEditAuthor: string; // Comes from Git (Git last author, or current Git user.name when unversioned, or "Anonymous" when Git is not installed) + publicationDate: string | null; // Comes from the Markdown body + file: Readonly<{ + relativePath: string; + absolutePath: string; + }>; + repository?: Readonly<{ + provider: GitProvider; + viewUrl: string; + }>; +}>; diff --git a/packages/core/src/infrastructure/api/types/index.ts b/packages/core/src/infrastructure/api/types/index.ts new file mode 100644 index 00000000..fc081cf0 --- /dev/null +++ b/packages/core/src/infrastructure/api/types/index.ts @@ -0,0 +1 @@ +export * from "./AdrDto"; diff --git a/packages/core/src/infrastructure/buses/CommandBus.ts b/packages/core/src/infrastructure/buses/CommandBus.ts new file mode 100644 index 00000000..985c78bf --- /dev/null +++ b/packages/core/src/infrastructure/buses/CommandBus.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { Command, CommandHandler } from "@src/application"; + +export class CommandBus { + private readonly handlersByCommandName: Map<string, CommandHandler> = new Map< + string, + CommandHandler + >(); + + registerHandler(handler: CommandHandler, commandClass: Function): void { + this.handlersByCommandName.set(commandClass.name, handler); + } + + async dispatch(command: Command): Promise<void> { + 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); + } +} diff --git a/packages/core/src/infrastructure/buses/QueryBus.ts b/packages/core/src/infrastructure/buses/QueryBus.ts new file mode 100644 index 00000000..0ba3c8e9 --- /dev/null +++ b/packages/core/src/infrastructure/buses/QueryBus.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { Query, QueryHandler } from "@src/application"; + +export class QueryBus { + private readonly handlersByQueryName: Map<string, QueryHandler> = new Map< + string, + QueryHandler + >(); + + registerHandler(handler: QueryHandler, queryClass: Function): void { + this.handlersByQueryName.set(queryClass.name, handler); + } + + async dispatch<QR>(query: Query): Promise<QR> { + 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) as Promise<QR>; + } +} diff --git a/packages/core/src/infrastructure/buses/index.ts b/packages/core/src/infrastructure/buses/index.ts new file mode 100644 index 00000000..f152a233 --- /dev/null +++ b/packages/core/src/infrastructure/buses/index.ts @@ -0,0 +1,2 @@ +export * from "./CommandBus"; +export * from "./QueryBus"; diff --git a/packages/core/src/infrastructure/config/builders.ts b/packages/core/src/infrastructure/config/builders.ts new file mode 100644 index 00000000..b6e32d2a --- /dev/null +++ b/packages/core/src/infrastructure/config/builders.ts @@ -0,0 +1,85 @@ +import path from "path"; +import fs from "fs"; +import yaml from "yaml"; +import { deepFreeze } from "@src/utils"; +import { Log4brainsError } from "@src/domain"; +import { Log4brainsConfig, schema } from "./schema"; +import { guessGitRepositoryConfig } from "./guessGitRepositoryConfig"; + +const configFilename = ".log4brains.yml"; + +function getDuplicatedValues<O extends Record<string, unknown>>( + objects: O[], + key: keyof O +): string[] { + const values = objects.map((object) => object[key]) as string[]; + const countsMap = values.reduce<Record<string, number>>((counts, value) => { + return { + ...counts, + [value]: (counts[value] || 0) + 1 + }; + }, {}); + return Object.keys(countsMap).filter((value) => countsMap[value] > 1); +} + +export function buildConfig(object: Record<string, unknown>): Log4brainsConfig { + const joiResult = schema.validate(object, { + abortEarly: false, + convert: false + }); + if (joiResult.error) { + throw new Log4brainsError( + `There is an error in the ${configFilename} config file`, + joiResult.error?.message + ); + } + const config = deepFreeze(joiResult.value) as Log4brainsConfig; + + // 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; +} + +export function buildConfigFromWorkdir(workdir = "."): Log4brainsConfig { + const workdirAbsolute = path.resolve(workdir); + const configPath = path.join(workdirAbsolute, configFilename); + if (!fs.existsSync(configPath)) { + throw new Log4brainsError( + `Impossible to find the ${configFilename} config file`, + `workdir: ${workdirAbsolute}` + ); + } + + try { + const content = fs.readFileSync(configPath, "utf8"); + const object = yaml.parse(content) as Record<string, unknown>; + const config = buildConfig(object); + return deepFreeze({ + ...config, + project: { + ...config.project, + repository: guessGitRepositoryConfig(config, workdir) + } + }) as Log4brainsConfig; + } catch (e) { + if (e instanceof Log4brainsError) { + throw e; + } + throw new Log4brainsError( + `Impossible to read the ${configFilename} config file`, + e + ); + } +} diff --git a/packages/core/src/infrastructure/config/guessGitRepositoryConfig.ts b/packages/core/src/infrastructure/config/guessGitRepositoryConfig.ts new file mode 100644 index 00000000..f1dd0d88 --- /dev/null +++ b/packages/core/src/infrastructure/config/guessGitRepositoryConfig.ts @@ -0,0 +1,84 @@ +import gitUrlParse from "git-url-parse"; +import parseGitConfig from "parse-git-config"; +import path from "path"; +import { GitRepositoryConfig, Log4brainsConfig, gitProviders } from "./schema"; + +type GitRemoteConfig = { + url: string; +}; +function isGitRemoteConfig( + remoteConfig: unknown +): remoteConfig is GitRemoteConfig { + return ( + typeof remoteConfig === "object" && + remoteConfig !== null && + "url" in remoteConfig + ); +} + +export function guessGitRepositoryConfig( + existingConfig: Log4brainsConfig, + workdir: string +): GitRepositoryConfig | undefined { + // URL + let url = existingConfig.project.repository?.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.project.repository?.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.project.repository?.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 + }; +} diff --git a/packages/core/src/infrastructure/config/index.ts b/packages/core/src/infrastructure/config/index.ts new file mode 100644 index 00000000..6075eb32 --- /dev/null +++ b/packages/core/src/infrastructure/config/index.ts @@ -0,0 +1,2 @@ +export * from "./builders"; +export { Log4brainsConfig, GitProvider, GitRepositoryConfig } from "./schema"; diff --git a/packages/core/src/infrastructure/config/schema.ts b/packages/core/src/infrastructure/config/schema.ts new file mode 100644 index 00000000..4e85ecbd --- /dev/null +++ b/packages/core/src/infrastructure/config/schema.ts @@ -0,0 +1,58 @@ +import Joi from "joi"; + +type ProjectPackageConfig = Readonly<{ + name: string; + path: string; + adrFolder: string; +}>; + +const projectPackageSchema = Joi.object({ + name: Joi.string().hostname().required(), + path: Joi.string().required(), + adrFolder: Joi.string().required() +}); + +export const gitProviders = [ + "github", + "gitlab", + "bitbucket", + "generic" +] as const; +export type GitProvider = typeof gitProviders[number]; + +// Optional values are automatically guessed at configuration build time +export type GitRepositoryConfig = Readonly<{ + url?: string; + provider?: GitProvider; + viewFileUriPattern?: string; +}>; + +const gitRepositorySchema = Joi.object({ + url: Joi.string().uri(), // Guessed from the current Git configuration if omitted + provider: Joi.string().valid(...gitProviders), // Guessed from url if omitted (useful for enterprise plans with custom domains) + viewFileUriPattern: Joi.string() // Useful for unsupported providers. Example for GitHub: /blob/%branch/%path +}); + +type ProjectConfig = Readonly<{ + name: string; + tz: string; + adrFolder: string; + packages?: ProjectPackageConfig[]; + repository?: GitRepositoryConfig; +}>; + +const projectSchema = Joi.object({ + name: Joi.string().required(), + tz: Joi.string().required(), + adrFolder: Joi.string().required(), + packages: Joi.array().items(projectPackageSchema), + repository: gitRepositorySchema +}); + +export type Log4brainsConfig = Readonly<{ + project: ProjectConfig; +}>; + +export const schema = Joi.object({ + project: projectSchema.required() +}); diff --git a/packages/core/src/infrastructure/di/buildContainer.ts b/packages/core/src/infrastructure/di/buildContainer.ts new file mode 100644 index 00000000..41637ea6 --- /dev/null +++ b/packages/core/src/infrastructure/di/buildContainer.ts @@ -0,0 +1,89 @@ +import { + createContainer, + asValue, + InjectionMode, + asClass, + AwilixContainer, + asFunction +} from "awilix"; +import { Log4brainsConfig } from "@src/infrastructure/config"; +import * as adrCommandHandlers from "@src/adr/application/command-handlers"; +import * as adrQueryHandlers from "@src/adr/application/query-handlers"; +import { CommandHandler, QueryHandler } from "@src/application"; +import * as repositories from "@src/adr/infrastructure/repositories"; +import { CommandBus, QueryBus } from "../buses"; +import { FileWatcher } from "../file-watcher"; + +function lowerCaseFirstLetter(string: string): string { + return string.charAt(0).toLowerCase() + string.slice(1); +} + +export function buildContainer( + config: Log4brainsConfig, + workdir = "." +): AwilixContainer { + const container: AwilixContainer = 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<unknown>(Repository).singleton() + ); + }); + + // Command handlers + Object.values(adrCommandHandlers).forEach((Handler) => { + container.register( + Handler.name, + asClass<CommandHandler>(Handler).singleton() + ); + }); + + // Command bus + container.register({ + commandBus: asFunction(() => { + const bus = new CommandBus(); + + Object.values(adrCommandHandlers).forEach((Handler) => { + const handlerInstance = container.resolve<CommandHandler>(Handler.name); + bus.registerHandler(handlerInstance, handlerInstance.commandClass); + }); + + return bus; + }).singleton() + }); + + // Query handlers + Object.values(adrQueryHandlers).forEach((Handler) => { + container.register( + Handler.name, + asClass<QueryHandler>(Handler).singleton() + ); + }); + + // Query bus + container.register({ + queryBus: asFunction(() => { + const bus = new QueryBus(); + + Object.values(adrQueryHandlers).forEach((Handler) => { + const handlerInstance = container.resolve<QueryHandler>(Handler.name); + bus.registerHandler(handlerInstance, handlerInstance.queryClass); + }); + + return bus; + }).singleton() + }); + + return container; +} diff --git a/packages/core/src/infrastructure/di/index.ts b/packages/core/src/infrastructure/di/index.ts new file mode 100644 index 00000000..94226c6f --- /dev/null +++ b/packages/core/src/infrastructure/di/index.ts @@ -0,0 +1 @@ +export * from "./buildContainer"; diff --git a/packages/core/src/infrastructure/file-watcher/FileWatcher.ts b/packages/core/src/infrastructure/file-watcher/FileWatcher.ts new file mode 100644 index 00000000..5ff9223c --- /dev/null +++ b/packages/core/src/infrastructure/file-watcher/FileWatcher.ts @@ -0,0 +1,83 @@ +import { Log4brainsError } from "@src/domain"; +import chokidar, { FSWatcher } from "chokidar"; +import { Log4brainsConfig } from "../config"; + +export type FileWatcherEventType = + | "add" + | "addDir" + | "change" + | "unlink" + | "unlinkDir"; + +export type FileWatcherEvent = { + type: FileWatcherEventType; + relativePath: string; +}; + +export type FileWatcherObserver = (event: FileWatcherEvent) => void; +export type FileWatcherUnsubscribeCb = () => void; + +type Deps = { + config: Log4brainsConfig; + workdir: string; +}; + +/** + * 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! + */ +export class FileWatcher { + private readonly config: Log4brainsConfig; + + private readonly workdir: string; + + private chokidar: FSWatcher | undefined; + + private observers: Set<FileWatcherObserver> = new Set<FileWatcherObserver>(); + + constructor({ config, workdir }: Deps) { + this.workdir = workdir; + this.config = config; + } + + subscribe(cb: FileWatcherObserver): FileWatcherUnsubscribeCb { + this.observers.add(cb); + return () => { + this.observers.delete(cb); + }; + } + + start(): void { + 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(): Promise<void> { + if (!this.chokidar) { + throw new Log4brainsError("FileWatcher is not started"); + } + await this.chokidar.close(); + this.chokidar = undefined; + } +} diff --git a/packages/core/src/infrastructure/file-watcher/index.ts b/packages/core/src/infrastructure/file-watcher/index.ts new file mode 100644 index 00000000..239014bf --- /dev/null +++ b/packages/core/src/infrastructure/file-watcher/index.ts @@ -0,0 +1 @@ +export * from "./FileWatcher"; diff --git a/packages/core/src/lib/cheerio-markdown/CheerioMarkdown.ts b/packages/core/src/lib/cheerio-markdown/CheerioMarkdown.ts new file mode 100644 index 00000000..ccaa60d8 --- /dev/null +++ b/packages/core/src/lib/cheerio-markdown/CheerioMarkdown.ts @@ -0,0 +1,141 @@ +import cheerio from "cheerio"; +import MarkdownIt from "markdown-it"; +import { markdownItSourceMap } from "./markdown-it-source-map-plugin"; +import { CheerioMarkdownElement } from "./CheerioMarkdownElement"; +import { cheerioToMarkdown } from "./cheerioToMarkdown"; + +// TODO: I am thinking to create a standalone library for this one + +const markdownItInstance = new MarkdownIt(); +markdownItInstance.use(markdownItSourceMap); + +function isWindowsLine(line: string): boolean { + return line.endsWith(`\r\n`) || line.endsWith(`\r`); +} + +type OnChangeObserver = (markdown: string) => void; + +export class CheerioMarkdown { + public $!: cheerio.Root; // for read-only purposes only! + + private readonly observers: OnChangeObserver[] = []; + + constructor(private $markdown: string) { + this.updateMarkdown($markdown); + } + + get markdown(): string { + return this.$markdown; + } + + get nbLines(): number { + return this.markdown.split(`\n`).length; + } + + onChange(cb: OnChangeObserver): void { + this.observers.push(cb); + } + + updateMarkdown(markdown: string): void { + this.$markdown = markdown; + this.$ = cheerio.load(markdownItInstance.render(this.markdown)); + this.observers.forEach((observer) => observer(this.markdown)); + } + + getLine(i: number): string { + const lines = this.markdown.split(/\r?\n/); + if (lines[i] === undefined) { + throw new Error(`Unknown line ${i}`); + } + return lines[i]; + } + + replaceText(elt: cheerio.Cheerio, newText: string): void { + 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: cheerio.Cheerio): void { + 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: number, newLine: string): void { + 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: number, end?: number): void { + 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: number, newLine: string): void { + 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: cheerio.Cheerio, newLine: string): void { + 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: string): void { + 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: cheerio.Cheerio, newItem: string): void { + 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); + } +} diff --git a/packages/core/src/lib/cheerio-markdown/CheerioMarkdownElement.ts b/packages/core/src/lib/cheerio-markdown/CheerioMarkdownElement.ts new file mode 100644 index 00000000..5e22aaa5 --- /dev/null +++ b/packages/core/src/lib/cheerio-markdown/CheerioMarkdownElement.ts @@ -0,0 +1,25 @@ +import cheerio from "cheerio"; + +export class CheerioMarkdownElement { + constructor(private readonly cheerioElt: cheerio.Cheerio) {} + + get startLine(): number | undefined { + const data = this.cheerioElt.data("sourceLineStart") as string | undefined; + return data !== undefined ? parseInt(data, 10) : undefined; + } + + get endLine(): number | undefined { + const data = this.cheerioElt.data("sourceLineEnd") as string | undefined; + return data !== undefined ? parseInt(data, 10) : undefined; + } + + get markup(): string | undefined { + const data = this.cheerioElt.data("sourceMarkup") as string | undefined; + return data !== undefined ? data : undefined; + } + + get level(): number | undefined { + const data = this.cheerioElt.data("sourceLevel") as string | undefined; + return data !== undefined ? parseInt(data, 10) : undefined; + } +} diff --git a/packages/core/src/lib/cheerio-markdown/cheerioToMarkdown.ts b/packages/core/src/lib/cheerio-markdown/cheerioToMarkdown.ts new file mode 100644 index 00000000..166a8e41 --- /dev/null +++ b/packages/core/src/lib/cheerio-markdown/cheerioToMarkdown.ts @@ -0,0 +1,22 @@ +import cheerio from "cheerio"; + +export function cheerioToMarkdown( + elt: cheerio.Cheerio, + keepLinks = true +): string { + 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(); +} diff --git a/packages/core/src/lib/cheerio-markdown/index.ts b/packages/core/src/lib/cheerio-markdown/index.ts new file mode 100644 index 00000000..88d477cf --- /dev/null +++ b/packages/core/src/lib/cheerio-markdown/index.ts @@ -0,0 +1,2 @@ +export * from "./CheerioMarkdown"; +export * from "./cheerioToMarkdown"; diff --git a/packages/core/src/lib/cheerio-markdown/markdown-it-source-map-plugin.ts b/packages/core/src/lib/cheerio-markdown/markdown-it-source-map-plugin.ts new file mode 100644 index 00000000..650d6d98 --- /dev/null +++ b/packages/core/src/lib/cheerio-markdown/markdown-it-source-map-plugin.ts @@ -0,0 +1,27 @@ +/* eslint-disable func-names */ +/* eslint-disable no-param-reassign */ +import MarkdownIt from "markdown-it"; + +// Source: https://github.com/tylingsoft/markdown-it-source-map +// Thanks! ;) +// Had to fork it to add additional information + +export function markdownItSourceMap(md: MarkdownIt): void { + 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); + }; +} diff --git a/packages/core/src/lib/paths.ts b/packages/core/src/lib/paths.ts new file mode 100644 index 00000000..2ea7dbcb --- /dev/null +++ b/packages/core/src/lib/paths.ts @@ -0,0 +1,3 @@ +export function forceUnixPath(p: string): string { + return p.replace(/\\/g, "/"); +} diff --git a/packages/core/src/polyfills.ts b/packages/core/src/polyfills.ts new file mode 100644 index 00000000..a96f1b88 --- /dev/null +++ b/packages/core/src/polyfills.ts @@ -0,0 +1 @@ +import "core-js/features/array/flat"; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 00000000..0fef3336 --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,23 @@ +/* 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: any): any => { + 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. + */ +export const deepFreeze = <T>(obj: T): T => { + return deepFreezeRecur(obj); +}; diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 00000000..215794bb --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "integration-tests", "**/*.test.ts"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..bcfc6b06 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2019.Array"], + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "integration-tests/**/*.ts"], + "exclude": ["node_modules"], + "ts-node": { "files": true } +} diff --git a/packages/init/.eslintrc.js b/packages/init/.eslintrc.js new file mode 100644 index 00000000..1f6675ff --- /dev/null +++ b/packages/init/.eslintrc.js @@ -0,0 +1,11 @@ +const path = require("path"); + +module.exports = { + env: { + node: true + }, + parserOptions: { + project: path.join(__dirname, "tsconfig.json") + }, + extends: ["../../.eslintrc"] +}; diff --git a/packages/init/README.md b/packages/init/README.md new file mode 100644 index 00000000..dc353000 --- /dev/null +++ b/packages/init/README.md @@ -0,0 +1,24 @@ +# init-log4brains + +This interactive CLI lets you install and configure the [Log4brains](https://github.com/thomvaill/log4brains) architecture knowledge base in your project. + +## Usage + +You should have a look at the main [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) to get more context. + +Start the interactive CLI by running this command in your project root directory: + +```bash +npx init-log4brains +``` + +It will: + +- Install `@log4brains/cli` and `@log4brains/web` as development dependencies in your project (it detects automatically whether you use npm or yarn) +- Add some entries in your `package.json`'s scripts: `adr`, `log4brains-preview`, `log4brains-build` +- Prompt you some questions in order to create the `.log4brains.yml` config file for you +- Import your existing Architecture Decision Records (ADR), or create a first one if you don't have any yet + +## Documentation + +- [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) diff --git a/packages/init/assets/README.md b/packages/init/assets/README.md new file mode 100644 index 00000000..8e229b20 --- /dev/null +++ b/packages/init/assets/README.md @@ -0,0 +1,33 @@ +# Architecture Decision Records + +ADRs are automatically published to our Log4brains architecture knowledge base: + +🔗 **<http://INSERT-YOUR-LOG4BRAINS-URL>** + +Please use this link to browse them. + +## Development + +To preview the knowledge base locally, run: + +```bash +npm run log4brains-preview +# OR +yarn log4brains-preview +``` + +In preview mode, the Hot Reload feature is enabled: any change you make to a markdown file is applied live in the UI. + +To create a new ADR interactively, run: + +```bash +npm run adr new +# OR +yarn adr new +``` + +## More information + +- [Log4brains documentation](https://github.com/thomvaill/log4brains/tree/master#readme) +- [What is an ADR and why should you use them](https://github.com/thomvaill/log4brains/tree/master#-what-is-an-adr-and-why-should-you-use-them) +- [ADR GitHub organization](https://adr.github.io/) diff --git a/packages/init/assets/index.md b/packages/init/assets/index.md new file mode 100644 index 00000000..7b5e225f --- /dev/null +++ b/packages/init/assets/index.md @@ -0,0 +1,36 @@ +<!-- This file is the homepage of your Log4brains knowledge base. You are free to edit it as you want --> + +# Architecture knowledge base + +Welcome 👋 to the architecture knowledge base of {PROJECT_NAME}. +You will find here all the Architecture Decision Records (ADR) of the project. + +## Definition and purpose + +> An Architectural Decision (AD) is a software design choice that addresses a functional or non-functional requirement that is architecturally significant. +> An Architectural Decision Record (ADR) captures a single AD, such as often done when writing personal notes or meeting minutes; the collection of ADRs created and maintained in a project constitutes its decision log. + +An ADR is immutable: only its status can change (i.e., become deprecated or superseded). That way, you can become familiar with the whole project history just by reading its decision log in chronological order. +Moreover, maintaining this documentation aims at: + +- 🚀 Improving and speeding up the onboarding of a new team member +- 🔭 Avoiding blind acceptance/reversal of a past decision (cf [Michael Nygard's famous article on ADRs](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.html)) +- 🤝 Formalizing the decision process of the team + +## Usage + +This website is automatically updated after a change on the `master` branch of the project's Git repository. +In fact, the developers manage this documentation directly with markdown files located next to their code, so it is more convenient for them to keep it up-to-date. +You can browse the ADRs by using the left menu or the search bar. + +The typical workflow of an ADR is the following: + +![ADR workflow](/l4b-static/adr-workflow.png) + +The decision process is entirely collaborative and backed by pull requests. + +## More information + +- [Log4brains documentation](https://github.com/thomvaill/log4brains/tree/master#readme) +- [What is an ADR and why should you use them](https://github.com/thomvaill/log4brains/tree/master#-what-is-an-adr-and-why-should-you-use-them) +- [ADR GitHub organization](https://adr.github.io/) diff --git a/packages/init/assets/template.md b/packages/init/assets/template.md new file mode 100644 index 00000000..35479fbc --- /dev/null +++ b/packages/init/assets/template.md @@ -0,0 +1,73 @@ +# [short title of solved problem and solution] + +- Status: [draft | proposed | rejected | accepted | deprecated | … | superseded by [xxx](yyyymmdd-xxx.md)] <!-- optional --> +- Deciders: [list everyone involved in the decision] <!-- optional --> +- Date: [YYYY-MM-DD when the decision was last updated] <!-- optional. To customize the ordering without relying on last edit dates --> +- Tags: [space and/or comma separated list of tags] <!-- optional --> + +Technical Story: [description | ticket/issue URL] <!-- optional --> + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +## Decision Drivers <!-- optional --> + +- [driver 1, e.g., a force, facing concern, …] +- [driver 2, e.g., a force, facing concern, …] +- … <!-- numbers of drivers can vary --> + +## Considered Options + +- [option 1] +- [option 2] +- [option 3] +- … <!-- numbers of options can vary --> + +## Decision Outcome + +Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +### Positive Consequences <!-- optional --> + +- [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …] +- … + +### Negative Consequences <!-- optional --> + +- [e.g., compromising quality attribute, follow-up decisions required, …] +- … + +## Pros and Cons of the Options <!-- optional --> + +### [option 1] + +[example | description | pointer to more information | …] <!-- optional --> + +- Good, because [argument a] +- Good, because [argument b] +- Bad, because [argument c] +- … <!-- numbers of pros and cons can vary --> + +### [option 2] + +[example | description | pointer to more information | …] <!-- optional --> + +- Good, because [argument a] +- Good, because [argument b] +- Bad, because [argument c] +- … <!-- numbers of pros and cons can vary --> + +### [option 3] + +[example | description | pointer to more information | …] <!-- optional --> + +- Good, because [argument a] +- Good, because [argument b] +- Bad, because [argument c] +- … <!-- numbers of pros and cons can vary --> + +## Links <!-- optional --> + +- [Link type][link to adr] <!-- example: Refined by [xxx](yyyymmdd-xxx.md) --> +- … <!-- numbers of links can vary --> diff --git a/packages/init/assets/use-log4brains-to-manage-the-adrs.md b/packages/init/assets/use-log4brains-to-manage-the-adrs.md new file mode 100644 index 00000000..41b40be7 --- /dev/null +++ b/packages/init/assets/use-log4brains-to-manage-the-adrs.md @@ -0,0 +1,21 @@ +# Use Log4brains to manage the ADRs + +- Status: accepted +- Date: {DATE} + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which tool(s) should we use to manage these records? + +## Considered Options + +- [Log4brains](https://github.com/thomvaill/log4brains): architecture knowledge base (command-line + static site generator) +- [ADR Tools](https://github.com/npryce/adr-tools): command-line to create ADRs +- [ADR Tools Python](https://bitbucket.org/tinkerer_/adr-tools-python/src/master/): command-line to create ADRs +- [adr-viewer](https://github.com/mrwilson/adr-viewer): static site generator +- [adr-log](https://adr.github.io/adr-log/): command-line to create a TOC of ADRs + +## Decision Outcome + +Chosen option: "Log4brains", because it includes the features of all the other tools, and even more. diff --git a/packages/init/assets/use-markdown-architectural-decision-records.md b/packages/init/assets/use-markdown-architectural-decision-records.md new file mode 100644 index 00000000..af462a4d --- /dev/null +++ b/packages/init/assets/use-markdown-architectural-decision-records.md @@ -0,0 +1,41 @@ +# Use Markdown Architectural Decision Records + +- Status: accepted +- Date: {DATE} + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 2.1.2 with Log4brains patch +- [MADR](https://adr.github.io/madr/) 2.1.2 – The original Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.2 with Log4brains patch", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. +- Version 2.1.2 is the latest one available when starting to document ADRs. +- The Log4brains patch adds more features, like tags. + +The "Log4brains patch" performs the following modifications to the original template: + +- Change the ADR filenames format (`NNN-adr-name` becomes `YYYYMMDD-adr-name`), to avoid conflicts during Git merges. +- Add a `draft` status, to enable collaborative writing. +- Add a `Tags` field. + +## Links + +- Relates to [Use Log4brains to manage the ADRs]({LOG4BRAINS_ADR_SLUG}.md) diff --git a/packages/init/integration-tests/init.test.ts b/packages/init/integration-tests/init.test.ts new file mode 100644 index 00000000..5388e2ec --- /dev/null +++ b/packages/init/integration-tests/init.test.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable import/no-dynamic-require */ +/* eslint-disable global-require */ +/* eslint-disable @typescript-eslint/prefer-regexp-exec */ +/* eslint-disable jest/no-conditional-expect */ +/* eslint-disable jest/no-try-expect */ +import execa, { ExecaError } from "execa"; +import path from "path"; +import os from "os"; +import fs, { promises as fsP } from "fs"; +import rimraf from "rimraf"; + +type PackageJson = Record<string, unknown> & { + scripts: Record<string, string>; +}; + +// Source: https://shift.infinite.red/integration-testing-interactive-clis-93af3cc0d56f. Thanks! +const keys = { + up: "\x1B\x5B\x41", + down: "\x1B\x5B\x42", + enter: "\x0D", + space: "\x20" +}; + +// Inspired by Next.js's test/integration/create-next-app/index.test.js. Thank you! +const cliPath = path.join(__dirname, "../src/main"); +const run = (cwd: string) => + execa("node", ["-r", "esm", "-r", "ts-node/register", cliPath, cwd]); + +jest.setTimeout(1000 * 60); + +async function usingTempDir(fn: (cwd: string) => void | Promise<void>) { + const folder = await fsP.mkdtemp( + path.join(os.tmpdir(), "log4brains-init-tests-") + ); + try { + return await fn(folder); + } finally { + rimraf.sync(folder); + } +} + +type PackageAnswer = { + name: string; + path: string; + adrFolder: string; +}; +// eslint-disable-next-line sonarjs/cognitive-complexity +function bindAnswers( + cli: execa.ExecaChildProcess<string>, + packageAnswer?: PackageAnswer +): execa.ExecaChildProcess<string> { + if (!cli.stdout) { + throw new Error("CLI must have an stdout"); + } + + let name = false; + let type = false; + let adrFolder = false; + let packageName = false; + let packagePath = false; + let packageAdrFolder = false; + let packageDone = false; + cli.stdout.on("data", (data: Buffer) => { + const line = data.toString(); + if (!cli.stdin) { + throw new Error("CLI must have an stdin"); + } + + if (!name && line.match(/What is the name of your project\?/)) { + cli.stdin.write("\n"); + name = true; + } + if ( + !type && + line.match(/Which statement describes the best your project\?/) + ) { + if (packageAnswer) { + cli.stdin.write(keys.down); + } + cli.stdin.write("\n"); + type = true; + } + if ( + !adrFolder && + line.match( + /In which directory do you plan to store your( global)? ADRs\?/ + ) + ) { + cli.stdin.write("\n"); + adrFolder = true; + } + + // Multi only: + if (packageAnswer) { + if (!packageName && line.match(/Name\?/)) { + cli.stdin.write(`${packageAnswer.name}\n`); + packageName = true; + } + if ( + !packagePath && + line.match(/Where is located the source code of this package\?/) + ) { + cli.stdin.write(`${packageAnswer.path}\n`); + packagePath = true; + } + if ( + !packageAdrFolder && + line.match( + /In which directory do you plan to store the ADRs of this package\?/ + ) + ) { + cli.stdin.write(`${packageAnswer.adrFolder}\n`); + packageAdrFolder = true; + } + if (!packageDone && line.match(/Do you want to add another one\?/)) { + cli.stdin.write(`N\n`); + packageDone = true; + } + } + }); + return cli; +} + +describe("Init", () => { + test("empty directory", async () => { + await usingTempDir(async (cwd) => { + expect.assertions(1); + + try { + await run(cwd); + } catch (e) { + expect((e as ExecaError).stderr).toMatch( + /Impossible to find package\.json/ + ); + } + }); + }); + + test("fresh NPM mono project", async () => { + await usingTempDir(async (cwd) => { + await execa("npm", ["init", "--yes"], { cwd }); + await execa("npm", ["install"], { cwd }); + + await bindAnswers(run(cwd)); + + const pkgJson = require(path.join(cwd, "package.json")) as PackageJson; + expect(pkgJson.scripts.adr).toEqual("log4brains adr"); + expect(pkgJson.scripts["log4brains-preview"]).toEqual( + "log4brains-web preview" + ); + expect(pkgJson.scripts["log4brains-build"]).toEqual( + "log4brains-web build" + ); + + expect(fs.existsSync(path.join(cwd, ".log4brains.yml"))).toBeTruthy(); // TODO: test its content + expect(fs.existsSync(path.join(cwd, "docs/adr"))).toBeTruthy(); + expect( + fs.existsSync(path.join(cwd, "docs/adr/template.md")) + ).toBeTruthy(); + expect(fs.existsSync(path.join(cwd, "docs/adr/index.md"))).toBeTruthy(); + expect(fs.existsSync(path.join(cwd, "docs/adr/README.md"))).toBeTruthy(); + }); + }); + + test("fresh yarn mono project", async () => { + await usingTempDir(async (cwd) => { + await execa("npm", ["init", "--yes"], { cwd }); + await execa("yarn", ["install"], { cwd }); + + await bindAnswers(run(cwd)); + + const pkgJson = require(path.join(cwd, "package.json")) as PackageJson; + expect(pkgJson.scripts.adr).toEqual("log4brains adr"); + expect(pkgJson.scripts["log4brains-preview"]).toEqual( + "log4brains-web preview" + ); + expect(pkgJson.scripts["log4brains-build"]).toEqual( + "log4brains-web build" + ); + }); + }); + + test("fresh NPM multi project", async () => { + await usingTempDir(async (cwd) => { + await execa("npm", ["init", "--yes"], { cwd }); + await execa("npm", ["install"], { cwd }); + await execa("mkdir", ["-p", "packages/package1"], { cwd }); + + await bindAnswers(run(cwd), { + name: "package1", + path: "packages/package1", + adrFolder: "packages/package1/docs/adr" + }); + + expect(fs.existsSync(path.join(cwd, ".log4brains.yml"))).toBeTruthy(); // TODO: test its content + expect(fs.existsSync(path.join(cwd, "docs/adr"))).toBeTruthy(); + expect( + fs.existsSync(path.join(cwd, "packages/package1/docs/adr")) + ).toBeTruthy(); + }); + }); +}); diff --git a/packages/init/jest.config.js b/packages/init/jest.config.js new file mode 100644 index 00000000..03224ca2 --- /dev/null +++ b/packages/init/jest.config.js @@ -0,0 +1,13 @@ +const base = require("../../jest.config.base"); +const { pathsToModuleNameMapper } = require("ts-jest/utils"); +const { compilerOptions } = require("./tsconfig"); +const packageJson = require("./package"); + +module.exports = { + ...base, + name: packageJson.name, + displayName: packageJson.name, + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "<rootDir>/" + }) +}; diff --git a/packages/init/nodemon.json b/packages/init/nodemon.json new file mode 100644 index 00000000..e9329fd9 --- /dev/null +++ b/packages/init/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts", + "exec": "yarn build" +} diff --git a/packages/init/package.json b/packages/init/package.json new file mode 100644 index 00000000..3e1b31e5 --- /dev/null +++ b/packages/init/package.json @@ -0,0 +1,67 @@ +{ + "name": "init-log4brains", + "version": "1.0.0-beta.0", + "description": "Install and configure the Log4brains architecture knowledge base in your project", + "keywords": [ + "log4brains", + "architecture decision records", + "architecture", + "knowledge base", + "documentation", + "docs-as-code", + "markdown", + "static site generator", + "documentation generator", + "tooling" + ], + "author": "Thomas Vaillant <thomvaill@bluebricks.dev>", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/thomvaill/log4brains", + "repository": { + "type": "git", + "url": "https://github.com/thomvaill/log4brains", + "directory": "packages/init" + }, + "engines": { + "node": ">=10.23.0" + }, + "files": [ + "assets", + "dist" + ], + "bin": "./dist/log4brains-init", + "scripts": { + "dev": "nodemon", + "build": "tsc --build tsconfig.build.json && copyfiles -u 1 src/log4brains-init dist", + "clean": "rimraf ./dist", + "typescript": "tsc --noEmit", + "test": "jest", + "test-watch": "jest --watch", + "lint": "eslint . --max-warnings=0", + "prepublishOnly": "yarn build", + "link": "npm link @log4brains/cli-common && npm link && rm -f ./package-lock.json" + }, + "dependencies": { + "@log4brains/cli-common": "^1.0.0-beta.0", + "chalk": "^4.1.0", + "commander": "^6.1.0", + "edit-json-file": "^1.5.0", + "esm": "^3.2.25", + "execa": "^4.1.0", + "has-yarn": "^2.1.0", + "mkdirp": "^1.0.4", + "moment-timezone": "^0.5.32", + "terminal-link": "^2.1.1", + "yaml": "^1.10.0" + }, + "devDependencies": { + "@types/edit-json-file": "^1.4.0", + "copyfiles": "^2.4.0", + "esm": "^3.2.25", + "ts-node": "^9.0.0" + } +} diff --git a/packages/init/src/cli.ts b/packages/init/src/cli.ts new file mode 100644 index 00000000..2a219eee --- /dev/null +++ b/packages/init/src/cli.ts @@ -0,0 +1,32 @@ +import commander from "commander"; +import type { AppConsole } from "@log4brains/cli-common"; +import { InitCommand, InitCommandOpts } from "./commands"; + +type Deps = { + appConsole: AppConsole; + version: string; + name: string; +}; + +export function createCli({ + appConsole, + name, + version +}: Deps): commander.Command { + return new commander.Command(name) + .version(version) + .arguments("[path]") + .description("Installs and configures Log4brains for your project", { + path: "Path of your project. Default: current directory" + }) + .option( + "-d, --defaults", + "Run in non-interactive mode and use the common default options", + false + ) + .action( + (path: string | undefined, options: InitCommandOpts): Promise<void> => { + return new InitCommand({ appConsole }).execute(options, path); + } + ); +} diff --git a/packages/init/src/commands/FailureExit.ts b/packages/init/src/commands/FailureExit.ts new file mode 100644 index 00000000..235b4894 --- /dev/null +++ b/packages/init/src/commands/FailureExit.ts @@ -0,0 +1,5 @@ +export class FailureExit extends Error { + constructor() { + super("The CLI exited with an error"); + } +} diff --git a/packages/init/src/commands/InitCommand.ts b/packages/init/src/commands/InitCommand.ts new file mode 100644 index 00000000..657ac1c3 --- /dev/null +++ b/packages/init/src/commands/InitCommand.ts @@ -0,0 +1,483 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +import fs, { promises as fsP } from "fs"; +import terminalLink from "terminal-link"; +import chalk from "chalk"; +import hasYarn from "has-yarn"; +import execa from "execa"; +import mkdirp from "mkdirp"; +import yaml from "yaml"; +import path from "path"; +import editJsonFile from "edit-json-file"; +import moment from "moment-timezone"; +import type { AppConsole } from "@log4brains/cli-common"; +import { FailureExit } from "./FailureExit"; + +const assetsPath = path.resolve(path.join(__dirname, "../../assets")); +const docLink = "https://github.com/thomvaill/log4brains"; +const cliBinPath = "@log4brains/cli/dist/log4brains"; +const webBinPath = "@log4brains/web/dist/bin/log4brains-web"; + +function forceUnixPath(p: string): string { + return p.replace(/\\/g, "/"); +} + +export type InitCommandOpts = { + defaults: boolean; +}; + +type L4bYmlPackageConfig = { + name: string; + path: string; + adrFolder: string; +}; +type L4bYmlConfig = { + project: { + name: string; + tz: string; + adrFolder: string; + packages?: L4bYmlPackageConfig[]; + }; +}; + +type Deps = { + appConsole: AppConsole; +}; + +export class InitCommand { + private readonly console: AppConsole; + + private hasYarnValue?: boolean; + + constructor({ appConsole }: Deps) { + this.console = appConsole; + } + + private hasYarn(): boolean { + if (!this.hasYarnValue) { + this.hasYarnValue = hasYarn(); + } + return this.hasYarnValue; + } + + private isDev(): boolean { + return ( + process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" + ); + } + + private async installNpmPackages(cwd: string): Promise<void> { + const packages = ["@log4brains/cli", "@log4brains/web"]; + + if (this.isDev()) { + await execa("yarn", ["link", ...packages], { cwd }); + + // ... but unfortunately `yarn link` does not create the bin symlinks (https://github.com/yarnpkg/yarn/issues/5713) + // we have to do it ourselves: + await mkdirp(path.join(cwd, "node_modules/.bin")); + await execa( + "ln", + ["-s", "--force", `../${cliBinPath}`, "node_modules/.bin/log4brains"], + { cwd } + ); + await execa( + "ln", + [ + "-s", + "--force", + `../${webBinPath}`, + "node_modules/.bin/log4brains-web" + ], + { cwd } + ); + + this.console.println(); + this.console.println( + `${chalk.bgBlue.white.bold(" DEV ")} ${chalk.blue( + "Local packages are linked!" + )}` + ); + this.console.println(); + } else if (this.hasYarn()) { + await execa( + "yarn", + ["add", "--dev", "--ignore-workspace-root-check", ...packages], + { cwd } + ); + } else { + await execa("npm", ["install", "--save-dev", ...packages], { cwd }); + } + } + + private setupPackageJsonScripts(packageJsonPath: string): void { + const pkgJson = editJsonFile(packageJsonPath); + pkgJson.set("scripts.adr", "log4brains adr"); + pkgJson.set("scripts.log4brains-preview", "log4brains-web preview"); + pkgJson.set("scripts.log4brains-build", "log4brains-web build"); + pkgJson.save(); + } + + private guessMainAdrFolderPath(cwd: string): string | undefined { + const usualPaths = [ + "./docs/adr", + "./docs/adrs", + "./docs/architecture-decisions", + "./doc/adr", + "./doc/adrs", + "./doc/architecture-decisions", + "./adr", + "./adrs", + "./architecture-decisions" + ]; + // eslint-disable-next-line no-restricted-syntax + for (const possiblePath of usualPaths) { + if (fs.existsSync(path.join(cwd, possiblePath))) { + return possiblePath; + } + } + return undefined; + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + private async buildLog4brainsConfigInteractively( + cwd: string, + noInteraction: boolean + ): Promise<L4bYmlConfig> { + this.console.println( + `We will now help you to create your ${chalk.cyan(".log4brains.yml")}...` + ); + this.console.println(); + + // Name + let name; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,global-require,import/no-dynamic-require,@typescript-eslint/no-var-requires + name = require(path.join(cwd, "package.json")).name as string; + if (!name) { + throw Error("Empty name"); + } + } catch (e) { + this.console.warn( + `Impossible to get the project name from your ${chalk.cyan( + "package.json" + )}` + ); + } + name = noInteraction + ? name || "untitled" + : await this.console.askInputQuestion( + "What is the name of your project?", + name + ); + + // Project type + const type = noInteraction + ? "mono" + : await this.console.askListQuestion( + "Which statement describes the best your project?", + [ + { + name: "Simple project (only one ADR folder)", + value: "mono", + short: "Mono-package project" + }, + { + name: + "Multi-package project (one ADR folder per package + a global one)", + value: "multi", + short: "Multi-package project" + } + ] + ); + + // Main ADR folder location + let adrFolder = this.guessMainAdrFolderPath(cwd); + if (adrFolder) { + this.console.println( + `We have detected a possible existing ADR folder: ${chalk.cyan( + adrFolder + )}` + ); + adrFolder = + noInteraction || + (await this.console.askYesNoQuestion("Do you confirm?", true)) + ? adrFolder + : undefined; + } + if (!adrFolder) { + adrFolder = noInteraction + ? "./docs/adr" + : await this.console.askInputQuestion( + `In which directory do you plan to store your ${ + type === "multi" ? "global " : "" + }ADRs? (will be automatically created)`, + "./docs/adr" + ); + } + await mkdirp(path.join(cwd, adrFolder)); + this.console.println(); + + // Packages + const packages = []; + if (type === "multi") { + this.console.println("We will now define your packages..."); + this.console.println(); + + let oneMorePackage = false; + let packageNumber = 1; + do { + this.console.println(); + this.console.println( + ` ${chalk.underline(`Package #${packageNumber}`)}:` + ); + const pkgName = await this.console.askInputQuestion( + "Name? (short, lowercase, without special characters, nor spaces)" + ); + const pkgCodeFolder = await this.askPathWhileNotFound( + "Where is located the source code of this package?", + cwd, + `./packages/${pkgName}` + ); + const pkgAdrFolder = await this.console.askInputQuestion( + `In which directory do you plan to store the ADRs of this package? (will be automatically created)`, + `${pkgCodeFolder}/docs/adr` + ); + await mkdirp(path.join(cwd, pkgAdrFolder)); + packages.push({ + name: pkgName, + path: forceUnixPath(pkgCodeFolder), + adrFolder: forceUnixPath(pkgAdrFolder) + }); + oneMorePackage = await this.console.askYesNoQuestion( + `We are done with package #${packageNumber}. Do you want to add another one?`, + false + ); + packageNumber += 1; + } while (oneMorePackage); + } + + return { + project: { + name, + tz: moment.tz.guess(), + adrFolder: forceUnixPath(adrFolder), + packages + } + }; + } + + private async createAdr( + cwd: string, + adrFolder: string, + title: string, + source: string, + replacements: string[][] = [] + ): Promise<string> { + const slug = ( + await execa( + path.join(cwd, `node_modules/${cliBinPath}`), + [ + "adr", + "new", + "--quiet", + "--from", + forceUnixPath(path.join(assetsPath, source)), + `"${title}"` + ], + { cwd } + ) + ).stdout; + + // eslint-disable-next-line no-restricted-syntax + for (const replacement of [ + ["{DATE}", moment().format("YYYY-MM-DD")], + ...replacements + ]) { + await execa( + "sed", + [ + "-i", + `s/${replacement[0]}/${replacement[1]}/g`, + forceUnixPath(path.join(cwd, adrFolder, `${slug}.md`)) + ], + { + cwd + } + ); + } + + return slug; + } + + private async copyFileIfAbsent( + cwd: string, + adrFolder: string, + filename: string, + contentCb?: (content: string) => string + ): Promise<void> { + const outPath = path.join(cwd, adrFolder, filename); + if (!fs.existsSync(outPath)) { + let content = await fsP.readFile( + path.join(assetsPath, filename), + "utf-8" + ); + if (contentCb) { + content = contentCb(content); + } + await fsP.writeFile(outPath, content); + } + } + + private printSuccess(): void { + const runCmd = this.hasYarn() ? "yarn" : "npm run"; + const l4bCliCmdName = "adr"; + + this.console.success("Log4brains is installed and configured! 🎉🎉🎉"); + this.console.println(); + this.console.println("You can now use the CLI to create a new ADR:"); + this.console.println(` ${chalk.cyan(`${runCmd} ${l4bCliCmdName} new`)}`); + this.console.println(""); + this.console.println( + "And start the web UI to preview your architecture knowledge base:" + ); + this.console.println(` ${chalk.cyan(`${runCmd} log4brains-preview`)}`); + this.console.println(); + this.console.println( + "Do not forget to set up your CI/CD to automatically publish your knowledge base" + ); + this.console.println( + `Check out the ${terminalLink( + "documentation", + docLink + )} to see some examples` + ); + } + + private async askPathWhileNotFound( + question: string, + cwd: string, + defaultValue?: string + ): Promise<string> { + const p = await this.console.askInputQuestion(question, defaultValue); + if (!p.trim() || !fs.existsSync(path.join(cwd, p))) { + this.console.warn("This path does not exist. Please try again..."); + return this.askPathWhileNotFound(question, cwd, defaultValue); + } + return p; + } + + /** + * Command flow. + * + * @param options + * @param customCwd + */ + async execute(options: InitCommandOpts, customCwd?: string): Promise<void> { + const noInteraction = options.defaults; + + const cwd = customCwd ? path.resolve(customCwd) : process.cwd(); + if (!fs.existsSync(cwd)) { + this.console.fatal(`The given path does not exist: ${chalk.cyan(cwd)}`); + throw new FailureExit(); + } + + // Check package.json existence + const packageJsonPath = path.join(cwd, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + this.console.fatal(`Impossible to find ${chalk.cyan("package.json")}`); + this.console.printlnErr( + "Are you sure to execute the command inside your project root directory?" + ); + this.console.printlnErr( + `Please refer to the ${terminalLink( + "documentation", + docLink + )} if you want to use Log4brains in a non-JS project or globally` + ); + throw new FailureExit(); + } + + // Install NPM packages + this.console.startSpinner("Install Log4brains packages..."); + await this.installNpmPackages(cwd); + this.console.stopSpinner(); + + // Setup package.json scripts + this.setupPackageJsonScripts(packageJsonPath); + this.console.println( + `We have added the following scripts to your ${chalk.cyan( + "package.json" + )}:` + ); + this.console.println(" - adr"); + this.console.println(" - log4brains-preview"); + this.console.println(" - log4brains-init"); + this.console.println(); + + // Terminate now if already configured + if (fs.existsSync(path.join(cwd, ".log4brains.yml"))) { + this.console.warn( + `${chalk.bold(".log4brains.yml")} already exists. We won't override it` + ); + this.console.warn( + "Please remove it and execute this command again if you want to configure it interactively" + ); + this.console.println(); + this.printSuccess(); + return; + } + + // Create .log4brains.yml interactively + const config = await this.buildLog4brainsConfigInteractively( + cwd, + noInteraction + ); + + this.console.startSpinner("Write config file..."); + const { adrFolder } = config.project; + await fsP.writeFile( + path.join(cwd, ".log4brains.yml"), + yaml.stringify(config), + "utf-8" + ); + + // Copy template, index and README if not already created + this.console.updateSpinner("Copy template files..."); + await this.copyFileIfAbsent(cwd, adrFolder, "template.md"); + await this.copyFileIfAbsent(cwd, adrFolder, "index.md", (content) => + content.replace(/{PROJECT_NAME}/g, config.project.name) + ); + await this.copyFileIfAbsent(cwd, adrFolder, "README.md"); + + // List existing ADRs + this.console.updateSpinner("Create your first ADR..."); + const adrListRes = await execa( + path.join(cwd, `node_modules/${cliBinPath}`), + ["adr", "list", "--raw"], + { cwd } + ); + + // Create Log4brains ADR + const l4bAdrSlug = await this.createAdr( + cwd, + adrFolder, + "Use Log4brains to manage the ADRs", + "use-log4brains-to-manage-the-adrs.md" + ); + + // Create MADR ADR if there was no ADR in the repository + if (!adrListRes.stdout) { + await this.createAdr( + cwd, + adrFolder, + "Use Markdown Architectural Decision Records", + "use-markdown-architectural-decision-records.md", + [["{LOG4BRAINS_ADR_SLUG}", l4bAdrSlug]] + ); + } + + // End + this.console.stopSpinner(); + this.printSuccess(); + } +} diff --git a/packages/init/src/commands/index.ts b/packages/init/src/commands/index.ts new file mode 100644 index 00000000..6de2c1e3 --- /dev/null +++ b/packages/init/src/commands/index.ts @@ -0,0 +1,2 @@ +export * from "./FailureExit"; +export * from "./InitCommand"; diff --git a/packages/init/src/log4brains-init b/packages/init/src/log4brains-init new file mode 100755 index 00000000..05abe630 --- /dev/null +++ b/packages/init/src/log4brains-init @@ -0,0 +1,5 @@ +#!/usr/bin/env node +require = require("esm")(module, { + mainFields: ["module", "main"] +}); +module.exports = require("./main"); diff --git a/packages/init/src/main.ts b/packages/init/src/main.ts new file mode 100644 index 00000000..1ef0ce72 --- /dev/null +++ b/packages/init/src/main.ts @@ -0,0 +1,29 @@ +import { AppConsole } from "@log4brains/cli-common"; +import { createCli } from "./cli"; +import { FailureExit } from "./commands"; + +const debug = !!process.env.DEBUG; +const dev = process.env.NODE_ENV === "development"; +const appConsole = new AppConsole({ debug, traces: debug || dev }); + +try { + // eslint-disable-next-line + const { name, version } = require("../package.json") as Record< + string, + string + >; + + const cli = createCli({ name, version, appConsole }); + cli.parseAsync(process.argv).catch((err) => { + if (!(err instanceof FailureExit)) { + if (appConsole.isSpinning()) { + appConsole.stopSpinner(true); + } + appConsole.fatal(err); + } + process.exit(1); + }); +} catch (e) { + appConsole.fatal(e); + process.exit(1); +} diff --git a/packages/init/tsconfig.build.json b/packages/init/tsconfig.build.json new file mode 100644 index 00000000..215794bb --- /dev/null +++ b/packages/init/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "integration-tests", "**/*.test.ts"] +} diff --git a/packages/init/tsconfig.json b/packages/init/tsconfig.json new file mode 100644 index 00000000..e98473ef --- /dev/null +++ b/packages/init/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "integration-tests/**/*.ts"], + "exclude": ["node_modules"], + "ts-node": { "files": true } +} diff --git a/packages/web/.babelrc b/packages/web/.babelrc new file mode 100644 index 00000000..e000594e --- /dev/null +++ b/packages/web/.babelrc @@ -0,0 +1,24 @@ +{ + "presets": ["next/babel"], + "plugins": [ + // Source: https://material-ui.com/guides/minimizing-bundle-size/ + [ + "babel-plugin-import", + { + "libraryName": "@material-ui/core", + "libraryDirectory": "", + "camel2DashComponentName": false + }, + "mui-core" + ], + [ + "babel-plugin-import", + { + "libraryName": "@material-ui/icons", + "libraryDirectory": "", + "camel2DashComponentName": false + }, + "mui-icons" + ] + ] +} diff --git a/packages/web/.eslintrc.js b/packages/web/.eslintrc.js new file mode 100644 index 00000000..f28fb5d2 --- /dev/null +++ b/packages/web/.eslintrc.js @@ -0,0 +1,23 @@ +const path = require("path"); + +module.exports = { + env: { + browser: true, + node: true + }, + parserOptions: { + ecmaFeatures: { + jsx: true + }, + project: path.join(__dirname, "tsconfig.dev.json") + }, + extends: ["../../.eslintrc"], + overrides: [ + { + files: ["src/pages/**/*.tsx", "src/pages/api/**/*.ts"], // Next.js pages and api routes + rules: { + "import/no-default-export": "off" + } + } + ] +}; diff --git a/packages/web/.gitignore b/packages/web/.gitignore new file mode 100644 index 00000000..1437c53f --- /dev/null +++ b/packages/web/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/packages/web/.storybook/main.js b/packages/web/.storybook/main.js new file mode 100644 index 00000000..20b31f2a --- /dev/null +++ b/packages/web/.storybook/main.js @@ -0,0 +1,22 @@ +const webpack = require("webpack"); + +module.exports = { + stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], + addons: ["@storybook/addon-links", "@storybook/addon-essentials"], + + // Fix to make next/image work in Storybook (thanks https://stackoverflow.com/questions/64622746/how-to-mock-next-js-image-component-in-storybook) + webpackFinal: (config) => { + config.plugins.push( + new webpack.DefinePlugin({ + "process.env.__NEXT_IMAGE_OPTS": JSON.stringify({ + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + domains: [], + path: "/", + loader: "default" + }) + }) + ); + return config; + } +}; diff --git a/packages/web/.storybook/mocks/adrs.ts b/packages/web/.storybook/mocks/adrs.ts new file mode 100644 index 00000000..316dc6bc --- /dev/null +++ b/packages/web/.storybook/mocks/adrs.ts @@ -0,0 +1,204 @@ +import { AdrDtoStatus } from "@log4brains/core"; +import { Adr } from "../../src/types"; + +export const adrMocks = [ + { + slug: "20200101-use-markdown-architectural-decision-records", + package: null, + title: "Use Markdown Architectural Decision Records", + status: "accepted" as AdrDtoStatus, + supersededBy: null, + tags: [], + deciders: [], + body: { + enhancedMdx: ` +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 2.1.2 with log4brains patch +- [MADR](https://adr.github.io/madr/) 2.1.2 – The original Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.2 with log4brains patch", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. +- Version 2.1.2 is the latest one available when starting to document ADRs. + +The "log4brains patch" performs the following modifications to the original template: + +- Add a draft status, to enable collaborative writing. +- Remove the Date field, because this metadata is already available in Git. +` + }, + creationDate: new Date(2020, 1, 1).toJSON(), + lastEditDate: new Date(2020, 10, 26).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: new Date(2020, 1, 1).toJSON(), + repository: { + provider: "gitlab", + viewUrl: + "https://gitlab.com/foo/bar/-/blob/master/docs/adr/20200101-use-markdown-architectural-decision-records.md" + } + }, + { + slug: "frontend/20200102-use-nextjs-for-static-site-generation", + package: "frontend", + title: "Use Next.js for Static Site Generation", + status: "proposed" as AdrDtoStatus, + supersededBy: null, + tags: [], + deciders: [], + body: { enhancedMdx: "" }, + creationDate: new Date(2020, 1, 2).toJSON(), + lastEditDate: new Date(2020, 10, 26).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: null, + repository: { + provider: "github", + viewUrl: + "https://github.com/foo/bar/blob/master/docs/adr/20200102-use-nextjs-for-static-site-generation.md" + } + }, + { + slug: "20200106-an-old-decision", + package: null, + title: "An old decision", + status: "superseded" as AdrDtoStatus, + supersededBy: "20200404-a-new-decision", + tags: [], + deciders: [], + body: { enhancedMdx: "Test" }, + creationDate: new Date(2020, 1, 6).toJSON(), + lastEditDate: new Date(2020, 1, 7).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: new Date(2020, 1, 8).toJSON(), + repository: { + provider: "bitbucket", + viewUrl: + "https://bitbucket.org/foo/bar/src/master/docs/adr/20200106-an-old-decision.md" + } + }, + { + slug: "20200404-a-new-decision", + package: null, + title: "An new decision", + status: "accepted" as AdrDtoStatus, + supersededBy: null, + tags: [], + deciders: [], + body: { + enhancedMdx: `## Lorem Ipsum + +Ipsum Dolor + +<AdrLink slug="20200106-an-old-decision" status="superseded" title="An old decision" customLabel="This is a link with a custom label" /> + +## Links + +- Supersedes <AdrLink slug="20200106-an-old-decision" status="superseded" title="An old decision" /> +` + }, + creationDate: new Date(2020, 4, 4).toJSON(), + lastEditDate: new Date(2020, 10, 26).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: null, + repository: { + provider: "github", + viewUrl: + "https://github.com/foo/bar/blob/master/docs/adr/20200404-a-new-decision.md" + } + }, + { + slug: "backend/20200404-untitled-draft", + package: "backend", + title: "Untitled Draft", + status: "draft" as AdrDtoStatus, + supersededBy: null, + tags: [], + deciders: [], + body: { enhancedMdx: "" }, + creationDate: new Date(2020, 4, 4).toJSON(), + lastEditDate: new Date(2020, 10, 26).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: null, + repository: { + provider: "github", + viewUrl: + "https://github.com/foo/bar/blob/master/docs/adr/20200404-untitled-draft.md" + } + }, + { + slug: "backend/20200405-lot-of-deciders", + package: "backend", + title: "Lot of deciders", + status: "draft" as AdrDtoStatus, + supersededBy: null, + tags: [], + deciders: [ + "John Doe", + "Lorem Ipsum", + "Ipsum Dolor", + "Foo Bar", + "John Doe", + "Lorem Ipsum", + "Ipsum Dolor", + "Foo Bar", + "John Doe", + "Lorem Ipsum", + "Ipsum Dolor", + "Foo Bar" + ], + body: { enhancedMdx: "" }, + creationDate: new Date(2020, 4, 5).toJSON(), + lastEditDate: new Date(2020, 10, 26).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: null, + repository: { + provider: "generic", + viewUrl: + "https://custom.com/foo/bar/blob/master/docs/adr/20200405-lot-of-deciders.md" + } + }, + { + slug: "backend/20200405-untitled-draft2", + package: "backend", + title: + "This is a very long title for an ADR which should span on multiple lines but it does not matter", + status: "draft" as AdrDtoStatus, + supersededBy: null, + tags: [], + deciders: ["John Doe", "Lorem Ipsum", "Ipsum Dolor"], + body: { + enhancedMdx: `Hello World +` + }, + creationDate: new Date(2020, 4, 5).toJSON(), + lastEditDate: new Date(2020, 10, 26).toJSON(), + lastEditAuthor: "John Doe", + publicationDate: null, + repository: { + provider: "github", + viewUrl: + "https://github.com/foo/bar/blob/master/docs/adr/20200405-untitled-draft2.md" + } + } +]; +adrMocks.reverse(); + +export function getMockedAdrBySlug(slug: string): Adr | undefined { + return adrMocks.filter((adr) => adr.slug === slug).pop(); +} diff --git a/packages/web/.storybook/mocks/index.ts b/packages/web/.storybook/mocks/index.ts new file mode 100644 index 00000000..816340cf --- /dev/null +++ b/packages/web/.storybook/mocks/index.ts @@ -0,0 +1 @@ +export * from "./adrs"; diff --git a/packages/web/.storybook/preview-head.html b/packages/web/.storybook/preview-head.html new file mode 100644 index 00000000..05a8a741 --- /dev/null +++ b/packages/web/.storybook/preview-head.html @@ -0,0 +1,9 @@ +<link rel="preconnect" href="https://fonts.gstatic.com" /> +<link + href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" + rel="stylesheet" +/> +<link + rel="stylesheet" + href="https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@300;400;500;700&display=swap" +/> diff --git a/packages/web/.storybook/preview.js b/packages/web/.storybook/preview.js new file mode 100644 index 00000000..96b1cf7f --- /dev/null +++ b/packages/web/.storybook/preview.js @@ -0,0 +1,21 @@ +import React from "react"; +import { MuiDecorator } from "../src/mui"; +import * as nextImage from "next/image"; +import "highlight.js/styles/github.css"; +import "../src/components/Markdown/hljs.css" + +// Fix to make next/image work in Storybook (thanks https://stackoverflow.com/questions/64622746/how-to-mock-next-js-image-component-in-storybook) +Object.defineProperty(nextImage, "default", { + configurable: true, + value: (props) => { + return <img {...props} />; + } +}); + +export const decorators = [ + (Story) => ( + <MuiDecorator> + <Story /> + </MuiDecorator> + ) +]; diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 00000000..fd0228b6 --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,73 @@ +# @log4brains/web + +This package provides the web UI of the [Log4brains](https://github.com/thomvaill/log4brains) architecture knowledge base and its static site generation capabilities. + +## Installation + +You should use `npx init-log4brains` as described in the [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md), which will install all the required dependencies in your project, including this one, and set up the right scripts in your `package.json`. + +You can also install this package manually via npm or yarn: + +```bash +npm install --save-dev @log4brains/web +``` + +or + +```bash +yarn add --dev @log4brains/web +``` + +And add these scripts to your `package.json`: + +```json +{ + [...] + "scripts": { + [...] + "log4brains-preview": "log4brains-web preview", + "log4brains-build": "log4brains-web build", + } +} +``` + +## Usage + +You should have a look at the main [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) to get more context. + +### Preview + +This command will start the web UI in preview mode locally on <http://localhost:4004/>. +You can define another port with the `-p` option. +In this mode, the Hot Reload feature is enabled: any changes you make to the markdown files are applied live in the UI. + +```bash +npm run log4brains-preview +``` + +or + +```bash +yarn log4brains-preview +``` + +### Build + +This command should be used in your CI/CD pipeline. It creates a static version of your knowledge base, ready to be deployed +on a static website hosting service like GitHub or GitLab pages. + +```bash +npm run log4brains-build +``` + +or + +```bash +yarn log4brains-build +``` + +The default output directory is `.log4brains/out`. You can change it with the `-o` option. + +## Documentation + +- [Log4brains README](https://github.com/thomvaill/log4brains/blob/master/README.md) diff --git a/packages/web/docs/adr/20200925-use-nextjs-for-static-site-generation.md b/packages/web/docs/adr/20200925-use-nextjs-for-static-site-generation.md new file mode 100644 index 00000000..771c2707 --- /dev/null +++ b/packages/web/docs/adr/20200925-use-nextjs-for-static-site-generation.md @@ -0,0 +1,138 @@ +# Use Next.js for Static Site Generation + +- Status: accepted +- Date: 2020-09-25 +- Tags: frontend, frameworks + +## Context and Problem Statement + +Log4brains has two main features: + +- `edit mode` which lets the developer edit the ADRs from a web UI, which is served locally by running `npm run log4brains` + - The developer is also able to edit the markdown files directly from the IDE, which triggers a live-reload of the web UI +- `build mode` which generates a static site ready to deploy on a Github-pages-like service, so that the ADRs are easily browsable. Usually run by the CI with `npm run log4brains-build` + +We need to find the best way to develop these two modes. + +## Decision Drivers <!-- optional --> + +- Maximize code reusability between the two modes (ie. do not have to develop everything twice) +- Time to Market: + - @Thomvaill (first contributor) is a Node/React/PHP developer + - @Thomvaill has 3 weeks available to develop and ship the first version of log4brains +- Balance between completeness/readyness of the chosen solution and future customization + - This first version will have limited features for now but the chosen solution must be customizable enough to be able to implement the future features (ie. we can't choose a 100% opinionated and closed to modifications solution) + +## Considered Options + +- Option 1: MkDocs +- Option 2: Docsify +- Option 3: Docusaurus 2 +- Option 4: Gatsby +- Option 5: Next.js + +Other SSG like Nuxt or Hugo were not considered, because similar to Gatsby and Next.js in terms of features, but developed with other technologies than React. + +## Decision Outcome + +Chosen option: "Option 5: Next.js", because + +- Markdown powered solutions are not enough customizable for our needs +- Gatsby and Next.js are quite similar, so it was hard to choose, but + - Gatsby is more opinionated, because of GraphQL + - I was influenced by this article: [Which To Choose in 2020: NextJS or Gatsby?](https://medium.com/frontend-digest/which-to-choose-in-2020-nextjs-vs-gatsby-1aa7ca279d8a) + +### Positive Consequences <!-- optional --> + +- We will use Typescript because Next.js supports it well + +## Pros and Cons of the Options <!-- optional --> + +### Option 1: MkDocs + +<https://www.mkdocs.org/> + +#### Pros + +- Already powered by Markdown +- Very popular and actively maintained (10.8k stars on Github on 2020-09-22) +- Extendable with plugins and themes +- Live-reload already implemented + +#### Cons + +- `edit mode` can't be developed with a MkDoc plugin, so it has to be developed separately +- Some manual config in `mkdocs.yml` is required for the navigation and/or some [Front Matter](https://jekyllrb.com/docs/front-matter/) config in each markdown file +- Not 100% customizable, even with plugins +- Python + +### Option 2: Docsify + +<https://docsify.js.org/> + +#### Pros + +- Already powered by Markdown +- Very popular and actively maintained (15.1k stars on Github on 2020-09-22) +- Extendable with plugins and themes +- Live-reload already implemented +- No need to generate static pages (lib served over a CDN, which reads directly the markdown files from the repo) -> CI setup simplified + +#### Cons + +- `edit mode` has to be developed separately +- Some manual config in `_nav.yml` is required for the navigation +- Not 100% customizable, even with plugins +- No static pages generation (lib served over a CDN, which reads directly the markdown files from the repo) -> impossible to setup on private projects + +### Option 3: Docusaurus 2 + +<https://v2.docusaurus.io/> + +#### Pros + +- Already powered by Markdown +- Possible to create React pages as well -> good for extensibility +- Very popular and actively maintained (19.1k stars on Github on 2020-09-22), even if the V2 is still in beta +- Live-reload already implemented + +#### Cons + +- No obvious way to develop the `edit mode` without some hacks +- Every markdown file require a [Front Matter](https://jekyllrb.com/docs/front-matter/) header + +### Option 4: Gatsby + +<https://www.gatsbyjs.com/> + +#### Pros + +- Easily extensible SSG framework +- Very popular and actively maintained (47k stars on Github on 2020-09-22) +- `edit mode` can be developed on top of the `gatsby develop` command +- Typescript support + +#### Cons + +- Have to use GraphQL (opinionated framework) +- Need more development to parse markdown files than an already Markdown powered solution + +### Option 5: Next.js + +<https://nextjs.org/> + +#### Pros + +- Easily extensible, non-opinionated SSG framework +- Very popular and actively maintained (53.5k stars on Github on 2020-09-22) +- `edit mode` can be developed on top of the `npm run dev` command +- Typescript support + +#### Cons + +- Need more development to parse markdown files than an already Markdown powered solution + +## Links <!-- optional --> + +- [Curated list of Static Site Generators](https://www.staticgen.com/) used to compare them +- [Which To Choose in 2020: NextJS or Gatsby?](https://medium.com/frontend-digest/which-to-choose-in-2020-nextjs-vs-gatsby-1aa7ca279d8a) diff --git a/packages/web/docs/adr/20200926-react-file-structure-organized-by-feature.md b/packages/web/docs/adr/20200926-react-file-structure-organized-by-feature.md new file mode 100644 index 00000000..33eb6711 --- /dev/null +++ b/packages/web/docs/adr/20200926-react-file-structure-organized-by-feature.md @@ -0,0 +1,8 @@ +# React file structure organized by feature + +- Status: accepted +- Date: 2020-09-26 + +## Decision + +We will follow the structure described in this article: [How to better organize your React applications?](https://medium.com/@alexmngn/how-to-better-organize-your-react-applications-2fd3ea1920f1) by Alexis Mangin. diff --git a/packages/web/docs/adr/20200927-avoid-react-fc-type.md b/packages/web/docs/adr/20200927-avoid-react-fc-type.md new file mode 100644 index 00000000..5d12a395 --- /dev/null +++ b/packages/web/docs/adr/20200927-avoid-react-fc-type.md @@ -0,0 +1,46 @@ +# Avoid React.FC type + +- Status: accepted +- Date: 2020-09-27 +- Source: <https://github.com/spotify/backstage/blob/master/docs/architecture-decisions/adr006-avoid-react-fc.md> <!-- TODO: maybe a new feature? --> + +## Context + +Facebook has removed `React.FC` from their base template for a Typescript +project. The reason for this was that it was found to be an unnecessary feature +with next to no benefits in combination with a few downsides. + +The main reasons were: + +- **children props** were implicitly added +- **Generic Type** was not supported on children + +Read more about the removal in +[this PR](https://github.com/facebook/create-react-app/pull/8177). + +## Decision + +To keep our codebase up to date, we have decided that `React.FC` and `React.SFC` +should be avoided in our codebase when adding new code. + +Here is an example: + +```typescript +/* Avoid this: */ +type BadProps = { text: string }; +const BadComponent: FC<BadProps> = ({ text, children }) => ( + <div> + <div>{text}</div> + {children} + </div> +); + +/* Do this instead: */ +type GoodProps = { text: string; children?: React.ReactNode }; +const GoodComponent = ({ text, children }: GoodProps) => ( + <div> + <div>{text}</div> + {children} + </div> +); +``` diff --git a/packages/web/docs/adr/20200927-use-react-hooks.md b/packages/web/docs/adr/20200927-use-react-hooks.md new file mode 100644 index 00000000..a21fcc9b --- /dev/null +++ b/packages/web/docs/adr/20200927-use-react-hooks.md @@ -0,0 +1,12 @@ +# Use React hooks + +- Status: accepted +- Date: 2020-09-27 + +## Decision + +We will use React hooks and avoid class components. + +## Links + +- [Why We Switched to React Hooks](https://blog.bitsrc.io/why-we-switched-to-react-hooks-48798c42c7f) diff --git a/packages/web/docs/adr/20201007-next-js-persistent-layout-pattern.md b/packages/web/docs/adr/20201007-next-js-persistent-layout-pattern.md new file mode 100644 index 00000000..b8aaed4e --- /dev/null +++ b/packages/web/docs/adr/20201007-next-js-persistent-layout-pattern.md @@ -0,0 +1,12 @@ +# Next.js persistent layout pattern + +- Status: accepted +- Date: 2020-10-07 + +## Context and Problem Statement + +We don't want the menu scroll position to be changed when we navigate from one page to another. + +## Decision + +We will use the [Next.js persistent layout pattern](https://adamwathan.me/2019/10/17/persistent-layout-patterns-in-nextjs/). diff --git a/packages/web/jest.config.js b/packages/web/jest.config.js new file mode 100644 index 00000000..31c317c8 --- /dev/null +++ b/packages/web/jest.config.js @@ -0,0 +1,11 @@ +const base = require("../../jest.config.base"); +const packageJson = require("./package"); + +module.exports = { + ...base, + name: packageJson.name, + displayName: packageJson.name, + transform: { + "^.+\\.(js|jsx|ts|tsx)$": "babel-jest" + } +}; diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts new file mode 100644 index 00000000..7b7aa2c7 --- /dev/null +++ b/packages/web/next-env.d.ts @@ -0,0 +1,2 @@ +/// <reference types="next" /> +/// <reference types="next/types/global" /> diff --git a/packages/web/next.config.js b/packages/web/next.config.js new file mode 100644 index 00000000..4b21667a --- /dev/null +++ b/packages/web/next.config.js @@ -0,0 +1,44 @@ +const path = require("path"); +const fs = require("fs"); + +const withBundleAnalyzer = require("@next/bundle-analyzer")({ + enabled: process.env.ANALYZE === "true" +}); + +const packageJson = require(`${ + fs.existsSync(path.join(__dirname, "package.json")) ? "./" : "../" +}package.json`); + +module.exports = withBundleAnalyzer({ + reactStrictMode: true, + target: "serverless", + poweredByHeader: false, + serverRuntimeConfig: { + PROJECT_ROOT: __dirname, // https://github.com/vercel/next.js/issues/8251 + VERSION: process.env.HIDE_LOG4BRAINS_VERSION ? "" : packageJson.version + }, + webpack: function (config, { webpack, buildId }) { + // For cache invalidation purpose (thanks https://github.com/vercel/next.js/discussions/14743) + config.plugins.push( + new webpack.DefinePlugin({ + "process.env.NEXT_BUILD_ID": JSON.stringify(buildId) + }) + ); + + // #NEXTJS-HACK + // Fix when the app is running inside `node_modules` (https://github.com/vercel/next.js/issues/19739) + // TODO: remove this fix when this PR is merged: https://github.com/vercel/next.js/pull/19749 + const originalExcludeMethod = config.module.rules[0].exclude; + config.module.rules[0].exclude = (excludePath) => { + if (!originalExcludeMethod(excludePath)) { + return false; + } + return /node_modules/.test(excludePath.replace(config.context, "")); + }; + + return config; + }, + future: { + excludeDefaultMomentLocales: true + } +}); diff --git a/packages/web/nodemon.json b/packages/web/nodemon.json new file mode 100644 index 00000000..b4171b6d --- /dev/null +++ b/packages/web/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts,tsx", + "exec": "yarn build:ts" +} diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 00000000..f95d0a97 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,94 @@ +{ + "name": "@log4brains/web", + "version": "1.0.0-beta.0", + "description": "Log4brains architecture knowledge base web UI and static site generator", + "keywords": [ + "log4brains" + ], + "author": "Thomas Vaillant <thomvaill@bluebricks.dev>", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/thomvaill/log4brains", + "repository": { + "type": "git", + "url": "https://github.com/thomvaill/log4brains", + "directory": "packages/web" + }, + "engines": { + "node": ">=10.23.0" + }, + "files": [ + "dist" + ], + "bin": { + "log4brains-web": "./dist/bin/log4brains-web" + }, + "scripts": { + "dev": "yarn build && nodemon", + "dev-old": "cross-env NODE_ENV=development LOG4BRAINS_CWD=../.. ESM_DISABLE_CACHE=true node -r esm -r ts-node/register ./src/bin/main.ts", + "next": "cross-env LOG4BRAINS_CWD=../.. next", + "build:ts": "tsc --noEmit false", + "build": "yarn clean && yarn build:ts && copyfiles --all next.config.js .babelrc 'public/**/*' dist && copyfiles --all --up 1 src/bin/log4brains-web 'src/lib/core-api/noop/**/*' src/components/Markdown/hljs.css dist && cross-env LOG4BRAINS_PHASE=initial-build next build dist", + "clean": "rimraf ./dist", + "serve": "serve .log4brains/out", + "typescript": "tsc", + "test": "jest", + "lint": "eslint . --max-warnings=0", + "storybook": "start-storybook -p 6006", + "prepublishOnly": "yarn build", + "link": "yarn link" + }, + "dependencies": { + "@log4brains/cli-common": "^1.0.0-beta.0", + "@log4brains/core": "^1.0.0-beta.0", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.56", + "@next/bundle-analyzer": "^10.0.1", + "babel-plugin-import": "^1.13.1", + "bufferutil": "^4.0.2", + "chalk": "^4.1.0", + "clsx": "^1.1.1", + "commander": "^6.1.0", + "copy-text-to-clipboard": "^2.2.0", + "esm": "^3.2.25", + "highlight.js": "^10.4.0", + "lunr": "^2.3.9", + "markdown-to-jsx": "^7.0.1", + "mkdirp": "^1.0.4", + "moment": "^2.29.1", + "next": "10.0.1", + "open": "^7.3.0", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-icons": "^3.11.0", + "slugify": "^1.4.5", + "socket.io": "^2.3.0", + "socket.io-client": "^2.3.1", + "utf-8-validate": "^5.0.3" + }, + "devDependencies": { + "@babel/core": "^7.11.6", + "@storybook/addon-actions": "^6.1.9", + "@storybook/addon-essentials": "^6.1.9", + "@storybook/addon-links": "^6.1.9", + "@storybook/react": "^6.1.9", + "@types/lunr": "^2.3.3", + "@types/mkdirp": "^1.0.1", + "@types/react": "^16.9.49", + "@types/react-test-renderer": "^16.9.3", + "@types/signale": "^1.4.1", + "@types/socket.io": "^2.1.11", + "@types/socket.io-client": "^1.4.34", + "@types/url-parse": "^1.4.3", + "babel-jest": "^26.6.0", + "babel-loader": "^8.1.0", + "copyfiles": "^2.4.0", + "react-is": "^16.13.1", + "react-test-renderer": "^17.0.1", + "ts-node": "^9.0.0" + } +} diff --git a/packages/web/public/favicon.ico b/packages/web/public/favicon.ico new file mode 100644 index 00000000..af5b7353 Binary files /dev/null and b/packages/web/public/favicon.ico differ diff --git a/packages/web/public/l4b-static/Log4brains-logo-dark.png b/packages/web/public/l4b-static/Log4brains-logo-dark.png new file mode 100644 index 00000000..13db2655 Binary files /dev/null and b/packages/web/public/l4b-static/Log4brains-logo-dark.png differ diff --git a/packages/web/public/l4b-static/Log4brains-logo.png b/packages/web/public/l4b-static/Log4brains-logo.png new file mode 100644 index 00000000..7b2a7c1f Binary files /dev/null and b/packages/web/public/l4b-static/Log4brains-logo.png differ diff --git a/packages/web/public/l4b-static/Log4brains-og.png b/packages/web/public/l4b-static/Log4brains-og.png new file mode 100644 index 00000000..92f5457a Binary files /dev/null and b/packages/web/public/l4b-static/Log4brains-og.png differ diff --git a/packages/web/public/l4b-static/adr-workflow.png b/packages/web/public/l4b-static/adr-workflow.png new file mode 100644 index 00000000..582a0205 Binary files /dev/null and b/packages/web/public/l4b-static/adr-workflow.png differ diff --git a/packages/web/src/bin/log4brains-web b/packages/web/src/bin/log4brains-web new file mode 100755 index 00000000..8bfb3dd2 --- /dev/null +++ b/packages/web/src/bin/log4brains-web @@ -0,0 +1,3 @@ +#!/usr/bin/env node +require = require("esm")(module); +module.exports = require("./main"); diff --git a/packages/web/src/bin/main.ts b/packages/web/src/bin/main.ts new file mode 100644 index 00000000..16062def --- /dev/null +++ b/packages/web/src/bin/main.ts @@ -0,0 +1,58 @@ +import commander from "commander"; +import { previewCommand, buildCommand } from "../cli"; +import { appConsole } from "../lib/console"; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,global-require,@typescript-eslint/no-var-requires +const pkgVersion = require("../../package.json").version as string; + +type StartEditorCommandOpts = { + port: string; + open: boolean; +}; +type BuildCommandOpts = { + out: string; + basePath: string; +}; + +function createCli(version: string): commander.Command { + const program = new commander.Command(); + program.version(version); + + program + .command("preview [adr]") + .description("Start log4brains locally to preview your changes", { + adr: + "If provided, will automatically open your browser to this specific ADR" + }) + .option("-p, --port <port>", "Port to listen on", "4004") + .option("--no-open", "Do not open the browser automatically", false) + .action( + (adr: string, opts: StartEditorCommandOpts): Promise<void> => { + return previewCommand(parseInt(opts.port, 10), opts.open, adr); + } + ); + + program + .command("build") + .description("Build the deployable static website") + .option("-o, --out <path>", "Output path", ".log4brains/out") + .option("--basePath <path>", "Custom base path", "") + .action( + (opts: BuildCommandOpts): Promise<void> => { + return buildCommand(opts.out, opts.basePath); + } + ); + + return program; +} + +const cli = createCli(pkgVersion); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +cli.parseAsync(process.argv).catch((err) => { + if (appConsole.isSpinning()) { + appConsole.stopSpinner(true); + } + appConsole.fatal(err); + process.exit(1); +}); diff --git a/packages/web/src/cli/build.ts b/packages/web/src/cli/build.ts new file mode 100644 index 00000000..3d54c4b0 --- /dev/null +++ b/packages/web/src/cli/build.ts @@ -0,0 +1,117 @@ +import build from "next/dist/build"; +import exportApp from "next/dist/export"; +import loadConfig from "next/dist/next-server/server/config"; +import { PHASE_EXPORT } from "next/dist/next-server/lib/constants"; +import path from "path"; +import mkdirp from "mkdirp"; +import { promises as fsP } from "fs"; +import { getLog4brainsInstance } from "../lib/core-api"; +import { getNextJsDir } from "../lib/next"; +import { appConsole, execNext } from "../lib/console"; +import { Search } from "../lib/search"; +import { toAdrLight } from "../types"; + +export async function buildCommand( + outPath: string, + basePath: string +): Promise<void> { + process.env.NEXT_TELEMETRY_DISABLED = "1"; + appConsole.println("Building Log4brains..."); + + const nextDir = getNextJsDir(); + // eslint-disable-next-line global-require,import/no-dynamic-require,@typescript-eslint/no-var-requires + const nextConfig = require(path.join(nextDir, "next.config.js")) as Record< + string, + unknown + >; + + // We use a different distDir than the preview mode + // because getStaticPath()'s `fallback` config is somehow cached + const distDir = ".next-export"; + const nextCustomConfig = { + ...nextConfig, + distDir, + basePath, + env: { + ...(nextConfig.env && typeof nextConfig.env === "object" + ? nextConfig.env + : {}), + NEXT_PUBLIC_LOG4BRAINS_STATIC: "1" + } + }; + + appConsole.debug("Run `next build`..."); + await execNext(async () => { + // #NEXTJS-HACK: build() is not meant to be called from the outside of Next.js + // And there is an error in their typings: `conf?` is typed as `null`, so we have to use @ts-ignore + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await build(nextDir, nextCustomConfig); + }); + + appConsole.debug("Run `next export`..."); + await execNext(async () => { + await exportApp( + nextDir, + { + outdir: outPath + }, + loadConfig(PHASE_EXPORT, nextDir, nextCustomConfig) // Configuration is not handled like in build() here + ); + }); + + appConsole.startSpinner("Generating ADR JSON data..."); + const buildId = await fsP.readFile( + path.join(nextDir, distDir, "BUILD_ID"), + "utf-8" + ); + + // TODO: move to a dedicated module + await mkdirp(path.join(outPath, "data", buildId)); + const adrs = await getLog4brainsInstance().searchAdrs(); + + // TODO: remove this dead code when we are sure we don't need a JSON file per ADR + + // const packages = new Set<string>(); + // adrs.forEach((adr) => adr.package && packages.add(adr.package)); + // const mkdirpPromises = Array.from(packages).map((pkg) => + // mkdirp(path.join(outPath, `data/adr/${pkg}`)) + // ); + // await Promise.all(mkdirpPromises); + + const promises = [ + // ...adrs.map((adr) => + // fsP.writeFile( + // path.join(outPath, "data", buildId, "adr", `${adr.slug}.json`), + // JSON.stringify( + // toAdr( + // adr, + // adr.supersededBy ? getAdrBySlug(adr.supersededBy, adrs) : undefined + // ) + // ), + // "utf-8" + // ) + // ), + fsP.writeFile( + path.join(outPath, "data", buildId, "adrs.json"), + JSON.stringify(adrs.map(toAdrLight)), + "utf-8" + ) + ]; + await Promise.all(promises); + + appConsole.updateSpinner("Generating search index..."); + await fsP.writeFile( + path.join(outPath, "data", buildId, "search-index.json"), + JSON.stringify(Search.createFromAdrs(adrs).serializeIndex()), + "utf-8" + ); + + appConsole.stopSpinner(); + appConsole.success( + `Your Log4brains static website was successfully built in ${outPath}` + ); + appConsole.println(); + process.exit(0); // otherwise Next.js's spinner keeps running +} diff --git a/packages/web/src/cli/index.ts b/packages/web/src/cli/index.ts new file mode 100644 index 00000000..bcfa55a7 --- /dev/null +++ b/packages/web/src/cli/index.ts @@ -0,0 +1,2 @@ +export * from "./preview"; +export * from "./build"; diff --git a/packages/web/src/cli/preview.ts b/packages/web/src/cli/preview.ts new file mode 100644 index 00000000..9f68c15f --- /dev/null +++ b/packages/web/src/cli/preview.ts @@ -0,0 +1,118 @@ +import next from "next"; +import { createServer } from "http"; +import SocketIO from "socket.io"; +import chalk from "chalk"; +import open from "open"; +import { getLog4brainsInstance } from "../lib/core-api"; +import { getNextJsDir } from "../lib/next"; +import { appConsole, execNext } from "../lib/console"; + +export async function previewCommand( + port: number, + openBrowser: boolean, + adrSlug?: string +): Promise<void> { + process.env.NEXT_TELEMETRY_DISABLED = "1"; + const dev = process.env.NODE_ENV === "development"; + + appConsole.startSpinner("Log4brains is starting..."); + appConsole.debug(`Run \`next ${dev ? "dev" : "start"}\`...`); + + const app = next({ + dev, + dir: getNextJsDir() + }); + + /** + * #NEXTJS-HACK + * We override this private property to set the incrementalCache in "dev" mode (ie. it disables it) + * to make our Hot Reload feature work. + * In fact, we trigger a page re-render every time an ADR changes and we absolutely need up-to-date data on every render. + * The "serve stale data while revalidating" Next.JS policy is not suitable for us. + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + app.incrementalCache.incrementalOptions.dev = true; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + + await execNext(async () => { + await app.prepare(); + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const srv = createServer(app.getRequestHandler()); + + // FileWatcher with Socket.io + const io = SocketIO(srv); + + const { fileWatcher } = getLog4brainsInstance(); + getLog4brainsInstance().fileWatcher.subscribe((event) => { + appConsole.debug(`[FileWatcher] ${event.type} - ${event.relativePath}`); + io.emit("FileWatcher", event); + }); + fileWatcher.start(); + + try { + await execNext( + () => + new Promise((resolve, reject) => { + // This code catches EADDRINUSE error if the port is already in use + srv.on("error", reject); + srv.on("listening", () => resolve()); + srv.listen(port); + }) + ); + } catch (err) { + appConsole.stopSpinner(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (err.code === "EADDRINUSE") { + if (openBrowser && adrSlug) { + appConsole.println( + chalk.dim( + "Log4brains is already started. We open the browser and exit" + ) + ); + await open(`http://localhost:${port}/adr/${adrSlug}`); + process.exit(0); + } + + appConsole.fatal( + `Port ${port} is already in use. Use the -p <PORT> option to select another one.` + ); + process.exit(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (err.code === "EACCES") { + appConsole.fatal( + `Impossible to use port ${port} (permission denied). Use the -p <PORT> option to select another one.` + ); + process.exit(1); + } else { + throw err; + } + } + + appConsole.stopSpinner(); + appConsole.println( + `Your Log4brains preview is 🚀 on ${chalk.underline.blueBright( + `http://localhost:${port}/` + )}` + ); + appConsole.println( + chalk.dim( + "Hot Reload is enabled: any change you make to a markdown file is applied live" + ) + ); + + if (dev) { + appConsole.println(); + appConsole.println( + `${chalk.bgBlue.white.bold(" DEV ")} ${chalk.blue( + "Next.js' Fast Refresh is enabled" + )}` + ); + appConsole.println(); + } + + if (openBrowser) { + await open(`http://localhost:${port}/${adrSlug ? `adr/${adrSlug}` : ""}`); + } +} diff --git a/packages/web/src/components/AdrStatusChip/AdrStatusChip.stories.tsx b/packages/web/src/components/AdrStatusChip/AdrStatusChip.stories.tsx new file mode 100644 index 00000000..f9b3c204 --- /dev/null +++ b/packages/web/src/components/AdrStatusChip/AdrStatusChip.stories.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Meta, Story } from "@storybook/react"; +import { AdrStatusChip, AdrStatusChipProps } from "./AdrStatusChip"; + +const Template: Story<AdrStatusChipProps> = (args) => ( + <AdrStatusChip {...args} /> +); + +export default { + title: "AdrStatusChip", + component: AdrStatusChip +} as Meta; + +export const Draft = Template.bind({}); +Draft.args = { status: "draft" }; + +export const Proposed = Template.bind({}); +Proposed.args = { status: "proposed" }; + +export const Rejected = Template.bind({}); +Rejected.args = { status: "rejected" }; + +export const Accepted = Template.bind({}); +Accepted.args = { status: "accepted" }; + +export const Deprecated = Template.bind({}); +Deprecated.args = { status: "deprecated" }; + +export const Superseded = Template.bind({}); +Superseded.args = { status: "superseded" }; diff --git a/packages/web/src/components/AdrStatusChip/AdrStatusChip.tsx b/packages/web/src/components/AdrStatusChip/AdrStatusChip.tsx new file mode 100644 index 00000000..46d2721d --- /dev/null +++ b/packages/web/src/components/AdrStatusChip/AdrStatusChip.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Chip } from "@material-ui/core"; +import { + grey, + indigo, + deepOrange, + lightGreen, + brown +} from "@material-ui/core/colors"; +import { createStyles, Theme, makeStyles } from "@material-ui/core/styles"; +import type { AdrDtoStatus } from "@log4brains/core"; +import clsx from "clsx"; + +// Styles are inspired by the MUI "Badge" styles +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + fontSize: "0.74rem", + fontWeight: theme.typography.fontWeightMedium, + height: "18px", + verticalAlign: "text-bottom" + }, + label: { + padding: "0 6px" + }, + draft: { + color: grey[800] + }, + proposed: { + color: indigo[800] + }, + rejected: { + color: deepOrange[800] + }, + accepted: { + color: lightGreen[800] + }, + deprecated: { + color: brown[600] + }, + superseded: { + color: brown[600] + } + }) +); + +export type AdrStatusChipProps = { + className?: string; + status: AdrDtoStatus; +}; + +export function AdrStatusChip({ className, status }: AdrStatusChipProps) { + const classes = useStyles(); + return ( + <Chip + variant="outlined" + size="small" + label={status.toUpperCase()} + className={clsx(className, classes.root, classes[status])} + classes={{ labelSmall: classes.label }} + /> + ); +} diff --git a/packages/web/src/components/AdrStatusChip/index.ts b/packages/web/src/components/AdrStatusChip/index.ts new file mode 100644 index 00000000..5f6eee42 --- /dev/null +++ b/packages/web/src/components/AdrStatusChip/index.ts @@ -0,0 +1 @@ +export * from "./AdrStatusChip"; diff --git a/packages/web/src/components/Markdown/Markdown.stories.tsx b/packages/web/src/components/Markdown/Markdown.stories.tsx new file mode 100644 index 00000000..45e817d6 --- /dev/null +++ b/packages/web/src/components/Markdown/Markdown.stories.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { Meta } from "@storybook/react"; +import { Markdown } from "./Markdown"; + +export default { + title: "Markdown", + component: Markdown, + decorators: [ + (DecoratedStory) => ( + <div style={{ width: 750, margin: "auto" }}> + <DecoratedStory /> + </div> + ) + ] +} as Meta; + +export function Default() { + return ( + <Markdown> + {`# Header 1 + +## Header 2 + +### Header 3 + +#### Header 4 + +# Two Paragraphs + +Lorem ipsum dolor [sit amet](#), consectetur adipiscing elit. \`Aenean convallis lorem eu volutpat congue\`. Cras rutrum porta nisi, vel hendrerit ante. Quisque imperdiet semper lectus ut luctus. Mauris eu sollicitudin erat, sit amet consequat tortor. Nam quis placerat nisi. Proin ornare dui vel quam luctus, rutrum mollis lectus tincidunt. Maecenas commodo maximus nisi, quis gravida urna lobortis ac. In ac eleifend felis, vel tincidunt elit. Donec consectetur sapien quis lacus congue congue. Fusce sagittis aliquam ex. Etiam euismod, orci sit amet vulputate rhoncus, lacus tellus blandit sem, id tristique lectus massa commodo nibh. Nunc varius lorem mattis enim laoreet imperdiet laoreet at mi. + +Donec felis ex, auctor a ligula quis, mollis sollicitudin libero. Aliquam vitae egestas quam. Vivamus porta suscipit eros, a hendrerit mi finibus vel. Sed vestibulum ante vel lacinia pellentesque. Integer elementum ultricies ante, vel gravida arcu faucibus et. Nunc tristique egestas pellentesque. Morbi volutpat dictum mi, quis ultricies leo. Morbi rhoncus malesuada lectus ut egestas. Phasellus finibus sodales nunc vel blandit. In tellus augue, posuere non libero eget, rhoncus tempus dui. Proin consequat libero ac felis volutpat, nec tempor ipsum dapibus. In vitae arcu efficitur, venenatis ipsum sit amet, tempus massa. + +# Code + +## Raw + +\`\`\` +Donec felis ex, auctor a ligula quis, mollis sollicitudin libero. +Aliquam vitae egestas quam. Vivamus porta suscipit eros, a hendrerit mi finibus vel. + +Sed vestibulum ante vel lacinia pellentesque. Integer elementum ultricies ante, vel gravida arcu faucibus et. +Nunc tristique egestas pellentesque. Morbi volutpat dictum mi, quis ultricies leo. +Morbi rhoncus malesuada lectus ut egestas. Phasellus finibus sodales nunc vel blandit. +In tellus augue, posuere non libero eget, rhoncus tempus dui. +Proin consequat libero ac felis volutpat, nec tempor ipsum dapibus. In vitae arcu efficitur, venenatis ipsum sit amet, tempus massa. +\`\`\` + +## JS + +\`\`\`javascript +const express = require("express"); +const app = express(); +const port = 3000; + +app.get("/", (req, res) => { + res.send("Hello World!"); +}); + +app.listen(port, () => { + console.log(\`Example app listening at http://localhost:\${port}\`); +}); +\`\`\` + +## TS + +\`\`\`typescript +interface User { + name: string; + id: number; +} + +class UserAccount { + name: string; + id: number; + + constructor(name: string, id: number) { + this.name = name; + this.id = id; + } +} + +const user: User = new UserAccount("Murphy", 1); +\`\`\` + +## JSON + +\`\`\`json +{ + "compilerOptions": { + "target": "ES2018", + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "preserve" + } +} +\`\`\` + +## PHP + +\`\`\`php +<html> + <head> + <title>Test PHP + + + Bonjour le monde

'; ?> + + +\`\`\` + +# Lists + +## Unordered + +- Item +- Item +- Item + +## Ordered + +1. Item +2. Item +3. Item + +## Long items + +- Lorem ipsum dolor [sit amet](#), consectetur adipiscing elit. Aenean convallis lorem eu volutpat congue. Cras rutrum porta nisi, vel hendrerit ante. Quisque imperdiet semper lectus ut luctus. Mauris eu sollicitudin erat, sit amet consequat tortor. Nam quis placerat nisi. Proin ornare dui vel quam luctus, rutrum mollis lectus tincidunt. +- Maecenas commodo maximus nisi, quis gravida urna lobortis ac. In ac eleifend felis, vel tincidunt elit. Donec consectetur sapien quis lacus congue congue. Fusce sagittis aliquam ex. Etiam euismod, orci sit amet vulputate rhoncus, lacus tellus blandit sem, id tristique lectus massa commodo nibh. Nunc varius lorem mattis enim laoreet imperdiet laoreet at mi. +- Donec felis ex, auctor a ligula quis, mollis sollicitudin libero. Aliquam vitae egestas quam. Vivamus porta suscipit eros, a hendrerit mi finibus vel. Sed vestibulum ante vel lacinia pellentesque. Integer elementum ultricies ante, vel gravida arcu faucibus et. Nunc tristique egestas pellentesque. Morbi volutpat dictum mi, quis ultricies leo. +- Morbi rhoncus malesuada lectus ut egestas. Phasellus finibus sodales nunc vel blandit. In tellus augue, posuere non libero eget, rhoncus tempus dui. Proin consequat libero ac felis volutpat, nec tempor ipsum dapibus. In vitae arcu efficitur, venenatis ipsum sit amet, tempus massa. + +`} + + ); +} diff --git a/packages/web/src/components/Markdown/Markdown.tsx b/packages/web/src/components/Markdown/Markdown.tsx new file mode 100644 index 00000000..66e9befe --- /dev/null +++ b/packages/web/src/components/Markdown/Markdown.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useMemo } from "react"; +import { compiler as mdCompiler } from "markdown-to-jsx"; +import { useRouter } from "next/router"; +import hljs from "highlight.js"; +import { makeStyles, createStyles } from "@material-ui/core/styles"; +import { + Typography, + Link as MuiLink, + TypographyProps +} from "@material-ui/core"; +import { CustomTheme } from "../../mui"; +import { AdrLink } from "./components"; +import { MarkdownHeading } from "../MarkdownHeading"; +import { slugify } from "../../lib/slugify"; + +const useStyles = makeStyles((theme: CustomTheme) => + createStyles({ + code: { + backgroundColor: "#F8F8F8", + borderRadius: theme.shape.borderRadius, + padding: 3 + }, + listItem: {} + }) +); + +function Li(props: TypographyProps) { + const classes = useStyles(); + return ( +
  • + +
  • + ); +} + +function Code(props: { children: React.ReactNode }) { + const classes = useStyles(); + const { children } = props; + return {children}; +} + +const options = { + overrides: { + h1: { + component: Typography, + props: { variant: "h3", component: "h1", gutterBottom: true } + }, + h2: { + component: MarkdownHeading, + props: { variant: "h2" } + }, + h3: { + component: MarkdownHeading, + props: { variant: "h3" } + }, + h4: { + component: MarkdownHeading, + props: { variant: "h4" } + }, + p: { component: Typography, props: { paragraph: true } }, + a: { component: MuiLink }, + li: { component: Li }, + AdrLink: { component: AdrLink }, + code: { component: Code } + }, + slugify +}; + +type MarkdownProps = { + children: string; + onCompiled?: (content: React.ReactElement) => void; +}; + +function isReactElementWithChildren( + obj: JSX.Element +): obj is React.ReactElement<{ children: React.ReactElement }> { + return "children" in obj.props; // TODO: improve tests here +} + +export function Markdown({ children, onCompiled }: MarkdownProps) { + const rootRef = React.useRef(null); + + const router = useRouter(); + + const renderedMarkdown = useMemo( + () => + mdCompiler( + children.replace( + // Fix for `index.md`'s adr-workflow.png image path + // TODO: support local images (https://github.com/thomvaill/log4brains/issues/4) + /\((\/l4b-static\/[^)]+)\)/g, + `(${router?.basePath}$1)` + ), + options + ), + [children, router] + ); + + useEffect(() => { + if (onCompiled && isReactElementWithChildren(renderedMarkdown)) { + onCompiled(renderedMarkdown.props.children); + } + }, [children, renderedMarkdown, onCompiled]); + + useEffect(() => { + if (isReactElementWithChildren(renderedMarkdown)) { + rootRef.current + ?.querySelectorAll("pre code") + .forEach((block) => { + hljs.highlightBlock(block); + }); + } + }, [children, renderedMarkdown]); + + return
    {renderedMarkdown}
    ; +} diff --git a/packages/web/src/components/Markdown/components/AdrLink/AdrLink.tsx b/packages/web/src/components/Markdown/components/AdrLink/AdrLink.tsx new file mode 100644 index 00000000..1dbc7af8 --- /dev/null +++ b/packages/web/src/components/Markdown/components/AdrLink/AdrLink.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { AdrDtoStatus } from "@log4brains/core"; +import { makeStyles, createStyles } from "@material-ui/core/styles"; +import { Link as MuiLink } from "@material-ui/core"; +import Link from "next/link"; +import clsx from "clsx"; + +const useStyles = makeStyles(() => + createStyles({ + // TODO: refactor with AdrMenu.tsx + draftLink: {}, + proposedLink: {}, + acceptedLink: {}, + rejectedLink: { + textDecoration: "line-through" + }, + deprecatedLink: { + textDecoration: "line-through" + }, + supersededLink: { + textDecoration: "line-through" + } + }) +); + +type AdrLinkProps = { + slug: string; + status: AdrDtoStatus; + // eslint-disable-next-line react/no-unused-prop-types + package?: string; + title?: string; + customLabel?: string; +}; + +export function AdrLink({ slug, status, title, customLabel }: AdrLinkProps) { + const classes = useStyles(); + + return ( + + + {customLabel || title || "Untitled"} + + + ); +} diff --git a/packages/web/src/components/Markdown/components/AdrLink/index.ts b/packages/web/src/components/Markdown/components/AdrLink/index.ts new file mode 100644 index 00000000..9c2406e9 --- /dev/null +++ b/packages/web/src/components/Markdown/components/AdrLink/index.ts @@ -0,0 +1 @@ +export * from "./AdrLink"; diff --git a/packages/web/src/components/Markdown/components/index.ts b/packages/web/src/components/Markdown/components/index.ts new file mode 100644 index 00000000..9c2406e9 --- /dev/null +++ b/packages/web/src/components/Markdown/components/index.ts @@ -0,0 +1 @@ +export * from "./AdrLink"; diff --git a/packages/web/src/components/Markdown/hljs.css b/packages/web/src/components/Markdown/hljs.css new file mode 100644 index 00000000..cc313157 --- /dev/null +++ b/packages/web/src/components/Markdown/hljs.css @@ -0,0 +1,3 @@ +.hljs { + padding: 14px !important; +} diff --git a/packages/web/src/components/Markdown/index.ts b/packages/web/src/components/Markdown/index.ts new file mode 100644 index 00000000..3306b090 --- /dev/null +++ b/packages/web/src/components/Markdown/index.ts @@ -0,0 +1 @@ +export * from "./Markdown"; diff --git a/packages/web/src/components/MarkdownHeading/MarkdownHeading.tsx b/packages/web/src/components/MarkdownHeading/MarkdownHeading.tsx new file mode 100644 index 00000000..068b77a8 --- /dev/null +++ b/packages/web/src/components/MarkdownHeading/MarkdownHeading.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { makeStyles, createStyles, Theme } from "@material-ui/core/styles"; +import { + Typography, + TypographyClassKey, + Link as MuiLink +} from "@material-ui/core"; +import clsx from "clsx"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + "&:hover": { + "& $link": { + visibility: "visible" + } + } + }, + link: { + marginLeft: "0.3ch", + color: "inherit", + "&:hover": { + color: theme.palette.primary.main + }, + visibility: "hidden" + } + }) +); + +export type MarkdownHeadingProps = { + children: string; + id: string; + variant: "h1" | "h2" | "h3" | "h4"; + className?: string; +}; + +export function MarkdownHeading({ + id, + children, + variant, + className +}: MarkdownHeadingProps) { + const classes = useStyles(); + + let typographyVariant: TypographyClassKey; + switch (variant) { + case "h1": + typographyVariant = "h3"; + break; + case "h2": + typographyVariant = "h4"; + break; + case "h3": + typographyVariant = "h5"; + break; + case "h4": + typographyVariant = "h6"; + break; + default: + typographyVariant = "h6"; + break; + } + + return ( + + {children} + + ¶ + + + ); +} diff --git a/packages/web/src/components/MarkdownHeading/index.ts b/packages/web/src/components/MarkdownHeading/index.ts new file mode 100644 index 00000000..331f6474 --- /dev/null +++ b/packages/web/src/components/MarkdownHeading/index.ts @@ -0,0 +1 @@ +export * from "./MarkdownHeading"; diff --git a/packages/web/src/components/MarkdownToc/MarkdownToc.stories.tsx b/packages/web/src/components/MarkdownToc/MarkdownToc.stories.tsx new file mode 100644 index 00000000..fcbdbaa4 --- /dev/null +++ b/packages/web/src/components/MarkdownToc/MarkdownToc.stories.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Meta } from "@storybook/react"; +import { compiler as mdCompiler } from "markdown-to-jsx"; +import { MarkdownHeading } from "../MarkdownHeading"; +import { MarkdownToc } from "./MarkdownToc"; + +const markdown = `# Header 1 +Lorem Ipsum + +## Header 1.1 + +### Header 1.1.1 + +### Header 1.1.2 + +## Header 1.2 + +#### Subtitle without direct parent + +test + +# Header 2 + +hello`; + +const options = { + overrides: { + h1: { + component: MarkdownHeading, + props: { variant: "h1" } + }, + h2: { + component: MarkdownHeading, + props: { variant: "h2" } + }, + h3: { + component: MarkdownHeading, + props: { variant: "h3" } + }, + h4: { + component: MarkdownHeading, + props: { variant: "h4" } + } + } +}; + +const content = mdCompiler(markdown, options) as React.ReactElement<{ + children: React.ReactElement; +}>; + +export default { + title: "MarkdownToc", + component: MarkdownToc +} as Meta; + +export function Default() { + return ; +} diff --git a/packages/web/src/components/MarkdownToc/MarkdownToc.test.tsx b/packages/web/src/components/MarkdownToc/MarkdownToc.test.tsx new file mode 100644 index 00000000..6286f309 --- /dev/null +++ b/packages/web/src/components/MarkdownToc/MarkdownToc.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { compiler as mdCompiler } from "markdown-to-jsx"; +import TestRenderer from "react-test-renderer"; +import { MarkdownHeading } from "../MarkdownHeading"; +import { MarkdownToc } from "./MarkdownToc"; + +const markdown = `# Header 1 +Lorem Ipsum + +## Header 1.1 + +### Header 1.1.1 + +### Header 1.1.2 + +## Header 1.2 + +#### Subtitle without direct parent + +test + +# Header 2 + +hello`; + +const options = { + overrides: { + h1: { + component: MarkdownHeading, + props: { variant: "h1" } + }, + h2: { + component: MarkdownHeading, + props: { variant: "h2" } + }, + h3: { + component: MarkdownHeading, + props: { variant: "h3" } + }, + h4: { + component: MarkdownHeading, + props: { variant: "h4" } + } + } +}; + +describe("Toc", () => { + const content = mdCompiler(markdown, options) as React.ReactElement<{ + children: React.ReactElement; + }>; + + it("renders correctly", () => { + const tree = TestRenderer.create( + + ); + expect(tree.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/packages/web/src/components/MarkdownToc/MarkdownToc.tsx b/packages/web/src/components/MarkdownToc/MarkdownToc.tsx new file mode 100644 index 00000000..1551908f --- /dev/null +++ b/packages/web/src/components/MarkdownToc/MarkdownToc.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { makeStyles, createStyles, Theme } from "@material-ui/core/styles"; +import { Link as MuiLink, Typography } from "@material-ui/core"; +import clsx from "clsx"; +import { + Toc as TocModel, + TocBuilder as TocModelBuilder +} from "../../lib/toc-utils"; +import { MarkdownHeading, MarkdownHeadingProps } from "../MarkdownHeading"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + "& > ul": { + padding: "0 !important" + } + }, + title: { + fontWeight: theme.typography.fontWeightBold, + paddingBottom: theme.spacing(1) + }, + tocUl: { + listStyleType: "none", + paddingLeft: "1rem" + } + }) +); + +function variantToLevel(variant: string): number { + return parseInt(variant.replace("h", ""), 10); +} + +function isMarkdownHeadingElement( + element: JSX.Element +): element is React.ReactElement { + return typeof element.type === "function" && element.type === MarkdownHeading; +} + +function buildTocModelFromContent( + content: React.ReactElement, + levelStart = 1 +): TocModel { + const builder = new TocModelBuilder(); + React.Children.forEach(content, (element) => { + if (isMarkdownHeadingElement(element)) { + builder.addSection( + variantToLevel(element.props.variant) - levelStart + 1, + element.props.children, + element.props.id + ); + } + }); + + return builder.getToc(); +} + +type TocSectionProps = { + children: React.ReactNode; + title: string; + id: string; +}; + +function TocSection({ title, id, children }: TocSectionProps) { + const classes = useStyles(); + + return ( +
  • + {title} + {children ?
      {children}
    : null} +
  • + ); +} + +export type MarkdownTocProps = { + className?: string; + content?: React.ReactElement; + levelStart?: number; +}; + +export function MarkdownToc({ + className, + content, + levelStart = 1 +}: MarkdownTocProps) { + const classes = useStyles(); + + if (!content) { + return null; + } + + const model = buildTocModelFromContent(content, levelStart); + if (model.children.length === 0) { + return null; + } + + return ( +
    + + Table of contents + +
      + {model.render((title: string, id: string, children: JSX.Element[]) => ( + + {children} + + ))} +
    +
    + ); +} diff --git a/packages/web/src/components/MarkdownToc/__snapshots__/MarkdownToc.test.tsx.snap b/packages/web/src/components/MarkdownToc/__snapshots__/MarkdownToc.test.tsx.snap new file mode 100644 index 00000000..befbd168 --- /dev/null +++ b/packages/web/src/components/MarkdownToc/__snapshots__/MarkdownToc.test.tsx.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Toc renders correctly 1`] = ` +
    +
    + Table of contents +
    +
    +`; diff --git a/packages/web/src/components/MarkdownToc/index.ts b/packages/web/src/components/MarkdownToc/index.ts new file mode 100644 index 00000000..3ce98894 --- /dev/null +++ b/packages/web/src/components/MarkdownToc/index.ts @@ -0,0 +1 @@ +export * from "./MarkdownToc"; diff --git a/packages/web/src/components/SearchBox/SearchBox.stories.tsx b/packages/web/src/components/SearchBox/SearchBox.stories.tsx new file mode 100644 index 00000000..6c45a434 --- /dev/null +++ b/packages/web/src/components/SearchBox/SearchBox.stories.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Meta, Story } from "@storybook/react"; +import { AppBar, Toolbar } from "@material-ui/core"; +import { SearchBox, SearchBoxProps } from "./SearchBox"; +import { adrMocks } from "../../../.storybook/mocks"; + +const Template: Story = (args) => ; + +export default { + title: "SearchBox", + component: SearchBox, + decorators: [ + (DecoratedStory) => ( + + +
    + +
    +
    +
    + ) + ] +} as Meta; + +export const Closed = Template.bind({}); +Closed.args = {}; + +export const Open = Template.bind({}); +Open.args = { + open: true +}; + +export const OpenWithResults = Template.bind({}); +OpenWithResults.args = { + open: true, + query: "Test", + results: adrMocks.map((adr) => ({ + title: adr.title, + href: `/adr/${adr.slug}` + })) +}; + +export const OpenLoading = Template.bind({}); +OpenLoading.args = { + open: true, + query: "test", + results: [], + loading: true +}; + +export const OpenWithoutResults = Template.bind({}); +OpenWithoutResults.args = { + open: true, + query: "cdlifsdilhfsd", + results: [] +}; diff --git a/packages/web/src/components/SearchBox/SearchBox.tsx b/packages/web/src/components/SearchBox/SearchBox.tsx new file mode 100644 index 00000000..65f3f65b --- /dev/null +++ b/packages/web/src/components/SearchBox/SearchBox.tsx @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import React from "react"; +import { + Autocomplete, + AutocompleteCloseReason, + AutocompleteInputChangeReason, + AutocompleteProps +} from "@material-ui/lab"; +import { CircularProgress, SvgIcon, Typography } from "@material-ui/core"; +import { useControlled } from "@material-ui/core/utils"; +import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; +import { GrDocumentText as AdrIcon } from "react-icons/gr"; +import { useRouter } from "next/router"; +import { SearchBar } from "./components/SearchBar"; +import { SearchResult } from "../../lib/search"; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + searchBar: { + zIndex: "inherit" + }, + resultTitle: { + marginLeft: "0.5ch" + }, + acPaper: { + borderRadius: `0 0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px`, + marginTop: 0 + } + }); +}); + +export type SearchBoxProps = Omit< + AutocompleteProps, + "results" | "renderInput" | "options" +> & { + /** + * Callback fired when the search box requests to be opened. + * Used in controlled mode (see open). + * + * @param event The event source of the callback. + */ + onOpen?: (event: React.ChangeEvent<{}>) => void; + + /** + * Callback fired when the popup requests to be closed. + * Used in controlled mode (see open). + * + * @param event The event source of the callback. + * @param reason Can be: `"toggleInput"`, `"escape"`, `"select-option"`, `"blur"`. + */ + onClose?: ( + event: React.ChangeEvent<{}>, + reason: AutocompleteCloseReason + ) => void; + + /** + * Control the popup open state. + * Set -> controlled mode, unset -> uncontrolled mode. + */ + open?: boolean; + + /** + * Callback fired when the search query changes. + * Controlled mode only. + * + * @param event The event source of the callback. + * @param query The new value of the search query. + * @param reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`. + */ + onQueryChange?: ( + event: React.ChangeEvent<{}>, + query: string, + reason: AutocompleteInputChangeReason + ) => void; + + /** + * The search query. + * Controlled mode only. + */ + query?: string; + + /** + * The search results. + * Controlled mode only. + */ + results?: SearchResult[]; + + /** + * To display a spinner. + */ + loading?: boolean; +}; + +export function SearchBox(props: SearchBoxProps) { + const classes = useStyles(); + + const { + onOpen, + onClose, + open: openProp, + onQueryChange, + query, + results, + loading = false, + ...otherProps + } = props; + + const [open, setOpenState] = useControlled({ + controlled: openProp, + default: false, + name: "SearchBox", + state: "open" + }); + + const handleOpen = (event: React.ChangeEvent<{}>) => { + if (open) { + return; + } + setOpenState(true); + if (onOpen) { + onOpen(event); + } + }; + + const router = useRouter(); + + const handleClose = ( + event: React.ChangeEvent<{}>, + reason: AutocompleteCloseReason + ) => { + if (!open) { + return; + } + setOpenState(false); + if (onClose) { + onClose(event, reason); + } + }; + + let noOptionsText: React.ReactNode = "Type to start searching"; + if (loading) { + noOptionsText = ( +
    + +
    + ); + } else if (query) { + noOptionsText = "No matching documents"; + } + + return ( + result.title} + renderInput={(params) => ( + + onQueryChange && onQueryChange(event, "", "clear") + } + className={classes.searchBar} + /> + )} + inputValue={query} + onInputChange={(event, value, reason) => { + // We don't want to replace the inputValue by the selected value + if (reason !== "reset" && onQueryChange) { + onQueryChange(event, value, reason); + } + }} + open={open} + onOpen={handleOpen} + onClose={handleClose} + filterOptions={(r) => r} // We hijack Autocomplete's behavior to display search results as options + renderOption={(result) => ( + <> + + + + + {result.title} + + + )} + noOptionsText={noOptionsText} + onChange={async (_, result) => { + if (result) { + await router.push(result.href); + } + }} + /> + ); +} diff --git a/packages/web/src/components/SearchBox/components/SearchBar/SearchBar.tsx b/packages/web/src/components/SearchBox/components/SearchBar/SearchBar.tsx new file mode 100644 index 00000000..c704e1bd --- /dev/null +++ b/packages/web/src/components/SearchBox/components/SearchBar/SearchBar.tsx @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import React from "react"; +import { + InputBase, + InputAdornment, + InputBaseProps, + IconButton, + Fade +} from "@material-ui/core"; +import { + createStyles, + makeStyles, + Theme, + fade +} from "@material-ui/core/styles"; +import { Search as SearchIcon, Close as ClearIcon } from "@material-ui/icons"; +import { AutocompleteRenderInputParams } from "@material-ui/lab"; + +export type SearchBarProps = InputBaseProps & + AutocompleteRenderInputParams & { + open: boolean; + onClear: (event: React.ChangeEvent<{}>) => void; + }; + +// Inspired by https://material-ui.com/components/app-bar/#app-bar-with-search-field +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + inputRoot: ({ open }: SearchBarProps) => ({ + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + borderRadius: open + ? `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0 0` + : `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px`, + color: open + ? theme.palette.getContrastText(theme.palette.common.white) + : "inherit", + backgroundColor: open + ? theme.palette.common.white + : fade(theme.palette.common.white, 0.15), + "&:hover": { + backgroundColor: open + ? theme.palette.common.white + : fade(theme.palette.common.white, 0.25) + } + }), + inputInput: { + padding: theme.spacing(1, 1, 1, 0) + }, + clearIcon: { + color: "inherit" + } + }); +}); + +export function SearchBar(props: SearchBarProps) { + const { InputProps, InputLabelProps, open, onClear, ...params } = props; + const classes = useStyles(props); + return ( + + + + } + endAdornment={ + // eslint-disable-next-line react/destructuring-assignment + + + onClear(event)} + size="small" + title="Clear" + className={classes.clearIcon} + > + + + + + } + ref={InputProps.ref} + {...params} + /> + ); +} diff --git a/packages/web/src/components/SearchBox/components/SearchBar/index.ts b/packages/web/src/components/SearchBox/components/SearchBar/index.ts new file mode 100644 index 00000000..f9dfce51 --- /dev/null +++ b/packages/web/src/components/SearchBox/components/SearchBar/index.ts @@ -0,0 +1 @@ +export * from "./SearchBar"; diff --git a/packages/web/src/components/SearchBox/index.ts b/packages/web/src/components/SearchBox/index.ts new file mode 100644 index 00000000..3e0944e0 --- /dev/null +++ b/packages/web/src/components/SearchBox/index.ts @@ -0,0 +1 @@ +export * from "./SearchBox"; diff --git a/packages/web/src/components/TwoColContent/TwoColContent.stories.tsx b/packages/web/src/components/TwoColContent/TwoColContent.stories.tsx new file mode 100644 index 00000000..54dbb3b4 --- /dev/null +++ b/packages/web/src/components/TwoColContent/TwoColContent.stories.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { Meta } from "@storybook/react"; +import { Typography } from "@material-ui/core"; +import { TwoColContent } from "./TwoColContent"; + +export default { + title: "TwoColContent", + component: TwoColContent +} as Meta; + +export function OneColumn() { + return ( + + + Lorem Ipsum + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean + convallis lorem eu volutpat congue. Cras rutrum porta nisi, vel + hendrerit ante. Quisque imperdiet semper lectus ut luctus. Mauris eu + sollicitudin erat, sit amet consequat tortor. Nam quis placerat nisi. + Proin ornare dui vel quam luctus, rutrum mollis lectus tincidunt. + Maecenas commodo maximus nisi, quis gravida urna lobortis ac. In ac + eleifend felis, vel tincidunt elit. Donec consectetur sapien quis lacus + congue congue. Fusce sagittis aliquam ex. Etiam euismod, orci sit amet + vulputate rhoncus, lacus tellus blandit sem, id tristique lectus massa + commodo nibh. Nunc varius lorem mattis enim laoreet imperdiet laoreet at + mi. + + + Donec felis ex, auctor a ligula quis, mollis sollicitudin libero. + Aliquam vitae egestas quam. Vivamus porta suscipit eros, a hendrerit mi + finibus vel. Sed vestibulum ante vel lacinia pellentesque. Integer + elementum ultricies ante, vel gravida arcu faucibus et. Nunc tristique + egestas pellentesque. Morbi volutpat dictum mi, quis ultricies leo. + Morbi rhoncus malesuada lectus ut egestas. Phasellus finibus sodales + nunc vel blandit. In tellus augue, posuere non libero eget, rhoncus + tempus dui. Proin consequat libero ac felis volutpat, nec tempor ipsum + dapibus. In vitae arcu efficitur, venenatis ipsum sit amet, tempus + massa. + + + ); +} + +export function TwoColumns() { + return ( + Some content}> + + Lorem Ipsum + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean + convallis lorem eu volutpat congue. Cras rutrum porta nisi, vel + hendrerit ante. Quisque imperdiet semper lectus ut luctus. Mauris eu + sollicitudin erat, sit amet consequat tortor. Nam quis placerat nisi. + Proin ornare dui vel quam luctus, rutrum mollis lectus tincidunt. + Maecenas commodo maximus nisi, quis gravida urna lobortis ac. In ac + eleifend felis, vel tincidunt elit. Donec consectetur sapien quis lacus + congue congue. Fusce sagittis aliquam ex. Etiam euismod, orci sit amet + vulputate rhoncus, lacus tellus blandit sem, id tristique lectus massa + commodo nibh. Nunc varius lorem mattis enim laoreet imperdiet laoreet at + mi. + + + Donec felis ex, auctor a ligula quis, mollis sollicitudin libero. + Aliquam vitae egestas quam. Vivamus porta suscipit eros, a hendrerit mi + finibus vel. Sed vestibulum ante vel lacinia pellentesque. Integer + elementum ultricies ante, vel gravida arcu faucibus et. Nunc tristique + egestas pellentesque. Morbi volutpat dictum mi, quis ultricies leo. + Morbi rhoncus malesuada lectus ut egestas. Phasellus finibus sodales + nunc vel blandit. In tellus augue, posuere non libero eget, rhoncus + tempus dui. Proin consequat libero ac felis volutpat, nec tempor ipsum + dapibus. In vitae arcu efficitur, venenatis ipsum sit amet, tempus + massa. + + + ); +} + +export function TwoColumnsWithTitle() { + return ( + Some content} + rightColTitle="Column title" + > + + Lorem Ipsum + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean + convallis lorem eu volutpat congue. Cras rutrum porta nisi, vel + hendrerit ante. Quisque imperdiet semper lectus ut luctus. Mauris eu + sollicitudin erat, sit amet consequat tortor. Nam quis placerat nisi. + Proin ornare dui vel quam luctus, rutrum mollis lectus tincidunt. + Maecenas commodo maximus nisi, quis gravida urna lobortis ac. In ac + eleifend felis, vel tincidunt elit. Donec consectetur sapien quis lacus + congue congue. Fusce sagittis aliquam ex. Etiam euismod, orci sit amet + vulputate rhoncus, lacus tellus blandit sem, id tristique lectus massa + commodo nibh. Nunc varius lorem mattis enim laoreet imperdiet laoreet at + mi. + + + Donec felis ex, auctor a ligula quis, mollis sollicitudin libero. + Aliquam vitae egestas quam. Vivamus porta suscipit eros, a hendrerit mi + finibus vel. Sed vestibulum ante vel lacinia pellentesque. Integer + elementum ultricies ante, vel gravida arcu faucibus et. Nunc tristique + egestas pellentesque. Morbi volutpat dictum mi, quis ultricies leo. + Morbi rhoncus malesuada lectus ut egestas. Phasellus finibus sodales + nunc vel blandit. In tellus augue, posuere non libero eget, rhoncus + tempus dui. Proin consequat libero ac felis volutpat, nec tempor ipsum + dapibus. In vitae arcu efficitur, venenatis ipsum sit amet, tempus + massa. + + + ); +} diff --git a/packages/web/src/components/TwoColContent/TwoColContent.tsx b/packages/web/src/components/TwoColContent/TwoColContent.tsx new file mode 100644 index 00000000..bef7dbea --- /dev/null +++ b/packages/web/src/components/TwoColContent/TwoColContent.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { makeStyles, createStyles } from "@material-ui/core/styles"; +import clsx from "clsx"; +import { CustomTheme } from "../../mui"; + +const useStyles = makeStyles((theme: CustomTheme) => + createStyles({ + root: { + display: "flex" + }, + layoutLeftCol: { + flexGrow: 0.5, + [theme.breakpoints.down("md")]: { + display: "none" + } + }, + layoutCenterCol: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + flexGrow: 1, + overflowWrap: "anywhere", + [theme.breakpoints.up("md")]: { + flexGrow: 0, + flexShrink: 0, + flexBasis: theme.custom.layout.centerColBasis, + paddingLeft: theme.custom.layout.centerColPadding, + paddingRight: theme.custom.layout.centerColPadding + }, + "& img": { + maxWidth: "100%" + } + }, + layoutRightCol: { + flexGrow: 1, + flexBasis: theme.custom.layout.rightColBasis, + [theme.breakpoints.down("md")]: { + display: "none" + } + }, + rightCol: { + position: "sticky", + top: theme.spacing(14), // TODO: calculate it based on AdrBrowserLayout's topSpace var + alignSelf: "flex-start", + paddingLeft: theme.spacing(2), + minWidth: "20ch" + } + }) +); + +type TwoColContentProps = { + className?: string; + children: React.ReactNode; + rightColContent?: React.ReactNode; +}; + +export function TwoColContent({ + className, + children, + rightColContent +}: TwoColContentProps) { + const classes = useStyles(); + + return ( +
    +
    +
    {children}
    +
    + {rightColContent} +
    +
    + ); +} diff --git a/packages/web/src/components/TwoColContent/index.ts b/packages/web/src/components/TwoColContent/index.ts new file mode 100644 index 00000000..4f0a6258 --- /dev/null +++ b/packages/web/src/components/TwoColContent/index.ts @@ -0,0 +1 @@ +export * from "./TwoColContent"; diff --git a/packages/web/src/components/index.ts b/packages/web/src/components/index.ts new file mode 100644 index 00000000..149e4f4d --- /dev/null +++ b/packages/web/src/components/index.ts @@ -0,0 +1,6 @@ +export * from "./AdrStatusChip"; +export * from "./Markdown"; +export * from "./MarkdownHeading"; +export * from "./MarkdownToc"; +export * from "./SearchBox"; +export * from "./TwoColContent"; diff --git a/packages/web/src/contexts/AdrNavContext/AdrNavContext.ts b/packages/web/src/contexts/AdrNavContext/AdrNavContext.ts new file mode 100644 index 00000000..fb75195c --- /dev/null +++ b/packages/web/src/contexts/AdrNavContext/AdrNavContext.ts @@ -0,0 +1,9 @@ +import React from "react"; +import { AdrLight } from "../../types"; + +export type AdrNav = { + previousAdr?: AdrLight; + nextAdr?: AdrLight; +}; + +export const AdrNavContext = React.createContext({}); diff --git a/packages/web/src/contexts/AdrNavContext/index.ts b/packages/web/src/contexts/AdrNavContext/index.ts new file mode 100644 index 00000000..7d5f6258 --- /dev/null +++ b/packages/web/src/contexts/AdrNavContext/index.ts @@ -0,0 +1 @@ +export * from "./AdrNavContext"; diff --git a/packages/web/src/contexts/Log4brainsModeContext/Log4brainsModeContext.ts b/packages/web/src/contexts/Log4brainsModeContext/Log4brainsModeContext.ts new file mode 100644 index 00000000..d927b1aa --- /dev/null +++ b/packages/web/src/contexts/Log4brainsModeContext/Log4brainsModeContext.ts @@ -0,0 +1,8 @@ +import React from "react"; + +export enum Log4brainsMode { + preview = "preview", + static = "static" +} + +export const Log4brainsModeContext = React.createContext(Log4brainsMode.static); diff --git a/packages/web/src/contexts/Log4brainsModeContext/index.ts b/packages/web/src/contexts/Log4brainsModeContext/index.ts new file mode 100644 index 00000000..5a98bf0e --- /dev/null +++ b/packages/web/src/contexts/Log4brainsModeContext/index.ts @@ -0,0 +1 @@ +export * from "./Log4brainsModeContext"; diff --git a/packages/web/src/contexts/index.ts b/packages/web/src/contexts/index.ts new file mode 100644 index 00000000..68dcf830 --- /dev/null +++ b/packages/web/src/contexts/index.ts @@ -0,0 +1,2 @@ +export * from "./AdrNavContext"; +export * from "./Log4brainsModeContext"; diff --git a/packages/web/src/layouts/AdrBrowserLayout/AdrBrowserLayout.stories.tsx b/packages/web/src/layouts/AdrBrowserLayout/AdrBrowserLayout.stories.tsx new file mode 100644 index 00000000..917f992f --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/AdrBrowserLayout.stories.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Meta, Story } from "@storybook/react"; +import { AdrBrowserLayout, AdrBrowserLayoutProps } from ".."; +import { adrMocks } from "../../../.storybook/mocks"; +import { toAdrLight } from "../../types"; + +const Template: Story = (args) => ( + +); + +export default { + title: "Layouts/AdrBrowser", + component: AdrBrowserLayout +} as Meta; + +export const Default = Template.bind({}); +Default.args = { adrs: adrMocks.map(toAdrLight) }; + +export const LoadingMenu = Template.bind({}); +LoadingMenu.args = {}; + +export const ReloadingMenu = Template.bind({}); +ReloadingMenu.args = { adrs: adrMocks.map(toAdrLight), adrsReloading: true }; + +export const EmptyMenu = Template.bind({}); +EmptyMenu.args = { adrs: [] }; + +export const RoutingProgressBar = Template.bind({}); +RoutingProgressBar.args = { adrs: adrMocks.map(toAdrLight), routing: true }; diff --git a/packages/web/src/layouts/AdrBrowserLayout/AdrBrowserLayout.tsx b/packages/web/src/layouts/AdrBrowserLayout/AdrBrowserLayout.tsx new file mode 100644 index 00000000..96275b16 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/AdrBrowserLayout.tsx @@ -0,0 +1,485 @@ +import React from "react"; +import { + AppBar, + // Divider, + Drawer, + List, + // ListItem, + // ListItemIcon, + // ListItemText, + Toolbar, + Link as MuiLink, + Typography, + Backdrop, + NoSsr, + CircularProgress, + Grow, + Fade, + Hidden, + IconButton +} from "@material-ui/core"; +import { Menu as MenuIcon, Close as CloseIcon } from "@material-ui/icons"; +import { createStyles, makeStyles } from "@material-ui/core/styles"; +// import { +// ChevronRight as ChevronRightIcon, +// PlaylistAddCheck as PlaylistAddCheckIcon +// } from "@material-ui/icons"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import clsx from "clsx"; +import { AdrMenu } from "./components/AdrMenu"; +import { CustomTheme } from "../../mui"; +import { ConnectedSearchBox } from "./components/ConnectedSearchBox/ConnectedSearchBox"; +import { AdrLight } from "../../types"; +import { AdrNav, AdrNavContext } from "../../contexts"; +import { RoutingProgress } from "./components/RoutingProgress"; + +const drawerWidth = 380; +const searchTransitionDuration = 300; + +const useStyles = makeStyles((theme: CustomTheme) => { + const topSpace = theme.spacing(6); + return createStyles({ + root: { + display: "flex" + }, + layoutLeftCol: { + flexGrow: 0.5, + [theme.breakpoints.down("md")]: { + display: "none" + } + }, + layoutCenterCol: { + paddingLeft: theme.custom.layout.centerColPadding, + paddingRight: theme.custom.layout.centerColPadding, + flexGrow: 1, + [theme.breakpoints.up("md")]: { + flexGrow: 0, + flexShrink: 0, + flexBasis: theme.custom.layout.centerColBasis + } + }, + layoutRightCol: { + flexGrow: 1, + flexBasis: theme.custom.layout.rightColBasis, + [theme.breakpoints.down("md")]: { + display: "none" + } + }, + appBar: { + zIndex: theme.zIndex.drawer + 1 + }, + appBarMenuButton: { + [theme.breakpoints.up("sm")]: { + display: "none" + } + }, + appBarTitle: { + display: "none", + [theme.breakpoints.up("sm")]: { + display: "flex", + alignItems: "center", + width: drawerWidth - theme.spacing(3), + flexGrow: 0, + flexShrink: 0, + cursor: "pointer" + } + }, + appBarTitleLink: { + display: "block", + color: "inherit", + "&:hover": { + color: "inherit" + }, + marginLeft: theme.spacing(2) + }, + searchBackdrop: { + zIndex: theme.zIndex.modal - 2 + }, + searchBox: { + zIndex: theme.zIndex.modal - 1, + width: "100%", + [theme.breakpoints.up("md")]: { + width: "70%" + }, + transition: theme.transitions.create("width", { + duration: searchTransitionDuration + }) + }, + searchBoxOpen: { + width: "100%" + }, + drawer: { + [theme.breakpoints.up("sm")]: { + width: drawerWidth, + flexShrink: 0 + } + }, + drawerPaper: { + width: drawerWidth + }, + drawerContainer: { + height: "100%", + display: "flex", + flexDirection: "column", + [theme.breakpoints.up("sm")]: { + paddingTop: topSpace + } + }, + drawerToolbar: { + visibility: "visible", + [theme.breakpoints.up("sm")]: { + visibility: "hidden" + }, + justifyContent: "space-between" + }, + adrMenu: { + flexGrow: 1, + flexShrink: 1, + overflow: "auto", + "&::-webkit-scrollbar": { + width: 6, + backgroundColor: theme.palette.background + }, + "&::-webkit-scrollbar-thumb": { + borderRadius: 10, + "-webkit-box-shadow": "inset 0 0 2px rgba(0,0,0,.3)", + backgroundColor: theme.palette.grey[400] + } + }, + bottomMenuList: { + flexGrow: 0, + flexShrink: 0 + }, + adlTitleAndSpinner: { + display: "flex", + justifyContent: "space-between", + paddingLeft: theme.spacing(2), + [theme.breakpoints.up("sm")]: { + paddingLeft: theme.spacing(3) + }, + paddingBottom: theme.spacing(0.5), + paddingRight: theme.spacing(3) + }, + adlTitle: { + fontWeight: theme.typography.fontWeightBold + }, + adrMenuSpinner: { + alignSelf: "center", + marginTop: "30vh" + }, + container: { + flexGrow: 1, + paddingTop: theme.spacing(2), + [theme.breakpoints.up("sm")]: { + paddingTop: topSpace + } + }, + content: { + minHeight: `calc(100vh - 35px - ${ + theme.spacing(1) + theme.spacing(8) + }px)`, // TODO: calc AppBar height more precisely + [theme.breakpoints.up("sm")]: { + minHeight: `calc(100vh - 35px - ${topSpace + theme.spacing(8)}px)` // TODO: calc AppBar height more precisely + } + }, + footer: { + backgroundColor: theme.palette.grey[100], + color: theme.palette.grey[500], + height: 35, + display: "flex", + marginTop: theme.spacing(6) + }, + footerText: { + fontSize: "0.77rem" + }, + footerLink: { + color: theme.palette.grey[600], + fontSize: "0.8rem", + "&:hover": { + color: theme.palette.grey[800] + } + }, + footerContent: { + display: "flex", + flexDirection: "column", + justifyContent: "center" + } + }); +}); + +function buildAdrNav(currentAdr: AdrLight, adrs: AdrLight[]): AdrNav { + const currentIndex = adrs + .map((adr, index) => (adr.slug === currentAdr.slug ? index : undefined)) + .filter((adr) => adr !== undefined) + .pop(); + const previousAdr = + currentIndex !== undefined && currentIndex < adrs.length - 1 + ? adrs[currentIndex + 1] + : undefined; + const nextAdr = + currentIndex !== undefined && currentIndex > 0 + ? adrs[currentIndex - 1] + : undefined; + return { + previousAdr, + nextAdr + }; +} + +export type AdrBrowserLayoutProps = { + projectName: string; + adrs?: AdrLight[]; // undefined -> loading, empty -> empty + adrsReloading?: boolean; + currentAdr?: AdrLight; + children: React.ReactNode; + routing?: boolean; + l4bVersion: string; +}; + +export function AdrBrowserLayout({ + projectName, + adrs, + adrsReloading = false, + currentAdr, + children, + routing = false, + l4bVersion +}: AdrBrowserLayoutProps) { + const classes = useStyles(); + const router = useRouter(); + + const [mobileDrawerOpen, setMobileDrawerOpen] = React.useState(false); + + const handleMobileDrawerToggle = () => { + setMobileDrawerOpen(!mobileDrawerOpen); + }; + + React.useEffect(() => { + const closeMobileDrawer = () => setMobileDrawerOpen(false); + router?.events.on("routeChangeStart", closeMobileDrawer); + return () => { + router?.events.off("routeChangeStart", closeMobileDrawer); + }; + }, [router]); + + const [searchOpen, setSearchOpenState] = React.useState(false); + const [searchReallyOpen, setSearchReallyOpenState] = React.useState(false); + + const drawer = ( +
    + +
    + + + Log4brains logo + + + + + + + +
    + + Decision log + + + + + +
    + + + + + + {adrs === undefined && ( + + )} + + + {/* + + + + + + + Filters + + + */} + {/* + + + + + + + + */} + +
    + ); + + return ( +
    + + {routing && } + + + + + +
    +
    + Log4brains logo +
    +
    + + + {projectName} + + + + + Architecture knowledge base + + +
    +
    + +
    +
    + + + { + setSearchOpenState(true); + // Delayed real opening because otherwise the dropdown width is bugged + setTimeout( + () => setSearchReallyOpenState(true), + searchTransitionDuration + 100 + ); + }} + onClose={() => { + setSearchOpenState(false); + setSearchReallyOpenState(false); + }} + open={searchReallyOpen} + className={clsx(classes.searchBox, { + [classes.searchBoxOpen]: searchOpen + })} + /> + +
    +
    + + + + + +
    + +
    + + {children} + +
    +
    +
    +
    + + Powered by{" "} + + Log4brains + {" "} + + {l4bVersion ? `(v${l4bVersion})` : null} + + +
    +
    +
    +
    +
    + ); +} diff --git a/packages/web/src/layouts/AdrBrowserLayout/ConnectedAdrBrowserLayout.tsx b/packages/web/src/layouts/AdrBrowserLayout/ConnectedAdrBrowserLayout.tsx new file mode 100644 index 00000000..4014dbb4 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/ConnectedAdrBrowserLayout.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import Router, { useRouter } from "next/router"; +// import io from "socket.io-client"; // loaded by _document.tsx so that we don't add this lib in the static mode bundle +import type { FileWatcherEvent } from "@log4brains/core"; +import { Adr, AdrLight } from "../../types"; +import { Log4brainsMode, Log4brainsModeContext } from "../../contexts"; +import { AdrBrowserLayout, AdrBrowserLayoutProps } from "./AdrBrowserLayout"; +// eslint-disable-next-line import/no-cycle +import { + AdrScene, + AdrSceneProps, + IndexScene, + IndexSceneProps +} from "../../scenes"; +import { debug } from "../../lib/debug"; + +function isReactElement( + component: React.ReactNode +): component is React.ReactElement { + return ( + !!component && + typeof component === "object" && + "type" in component && + "props" in component + ); +} + +function isAdrSceneChild( + component: React.ReactNode +): component is React.ReactElement { + return isReactElement(component) && component.type === AdrScene; +} + +function isIndexSceneChild( + component: React.ReactNode +): component is React.ReactElement { + return isReactElement(component) && component.type === IndexScene; +} + +function hasAdrMetadataChanged(previous: Adr, current: Adr): boolean { + return ( + previous.title !== current.title || + previous.status !== current.status || + previous.package !== current.package || + previous.publicationDate !== current.publicationDate + ); +} + +async function hotReloadCurrentPage(): Promise { + /** + * #NEXTJS-HACK + * We clear Next.JS Router's "static data cache" to make our Hot Reload feature work. + * In fact, we trigger a page re-render every time an ADR changes and we absolutely need up-to-date data on every render. + * So we force a new request to the server. + */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + Router.router.sdc = {}; + + await Router.replace(window.location.href); +} + +type ConnectedAdrBrowserLayoutProps = Omit< + AdrBrowserLayoutProps, + "adrs" | "adrsReloading" | "routing" +> & { + // Defined for IndexScene to speed up the 1st load and for SEO. Not defined for AdrScenes to avoid a full-rebuild for each change. + // Will load them asynchronously if undefined + adrs?: AdrLight[]; +}; + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function ConnectedAdrBrowserLayout( + props: ConnectedAdrBrowserLayoutProps +) { + const { adrs: preloadedAdrs } = props; + + const router = useRouter(); + const mode = React.useContext(Log4brainsModeContext); + const [adrs, setAdrsState] = React.useState( + preloadedAdrs ? [...preloadedAdrs].reverse() : preloadedAdrs + ); + const [adrsLoading, setAdrsLoadingState] = React.useState(false); + const [routing, setRoutingState] = React.useState(false); + + const previousProps = React.useRef( + null + ); + const latestProps = React.useRef(props); + React.useEffect(() => { + previousProps.current = latestProps.current; + latestProps.current = props; + }); + + // ADRs list for the navigation + const updateAdrsList = React.useCallback(async () => { + setAdrsLoadingState(true); + const adrsRes = (await ( + await fetch( + mode === Log4brainsMode.preview + ? `/api/adr` + : `${router.basePath}/data/${process.env.NEXT_BUILD_ID}/adrs.json` + ) + ).json()) as AdrLight[]; + adrsRes.reverse(); // @see Log4brains.searchAdrs(): they are returned by chronological order ASC. We display them DESC in the UI + setAdrsState(adrsRes); + setAdrsLoadingState(false); + }, [mode, router.basePath]); + + React.useEffect(() => { + if (!adrs) { + void updateAdrsList(); + } + }, [updateAdrsList, adrs]); + + // Routing progress bar + Router.events.on("routeChangeStart", () => setRoutingState(true)); + Router.events.on("routeChangeComplete", () => setRoutingState(false)); + Router.events.on("routeChangeError", () => setRoutingState(false)); // TODO: show a modal? + + // Hot Reload + React.useEffect(() => { + if (mode !== Log4brainsMode.preview || window.io === undefined) { + return () => {}; + } + + const socket = io(); + socket.on("FileWatcher", async (event: FileWatcherEvent) => { + debug(`[FileWatcher] ${event.type} - ${event.relativePath}`); + + const child = React.Children.only(latestProps.current.children); + const isMdFile = event.relativePath.toLowerCase().endsWith(".md"); + const isIndexFile = event.relativePath.toLowerCase().endsWith("index.md"); + + // * HOT RELOAD + // - ADR page && current ADR file changed + // - Index page && index.md changed + const needsHotReload = + (isAdrSceneChild(child) && + child.props.currentAdr.file.relativePath.toLowerCase() === + event.relativePath.toLowerCase()) || + (isIndexSceneChild(child) && isIndexFile); + if (needsHotReload) { + await hotReloadCurrentPage(); + } + + // * ADR LIST UPDATE (for menu and nav) + // - If any .md file changed, except: + // - If it's index.md + // - If the current ADR changed (ie a Hot Reload was triggered) BUT not its metadata (title, status, date...) [for perf. reasons] + const previousChild = previousProps.current + ? React.Children.only(previousProps.current.children) + : undefined; + const currentMetadataChanged = + isAdrSceneChild(child) && + previousChild && + isAdrSceneChild(previousChild) && + hasAdrMetadataChanged( + child.props.currentAdr, + previousChild.props.currentAdr + ); + if ( + isMdFile && + !isIndexFile && + (!needsHotReload || currentMetadataChanged) + ) { + await updateAdrsList(); + } + }); + + return () => { + socket.disconnect(); + }; + }, [mode, updateAdrsList]); + + return ( + + ); +} diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/AdrMenu/AdrMenu.tsx b/packages/web/src/layouts/AdrBrowserLayout/components/AdrMenu/AdrMenu.tsx new file mode 100644 index 00000000..42516b79 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/AdrMenu/AdrMenu.tsx @@ -0,0 +1,214 @@ +import React from "react"; +import moment from "moment"; +import { Typography, Link as MuiLink } from "@material-ui/core"; +import { + Timeline, + TimelineConnector, + TimelineContent, + TimelineDot, + TimelineItem, + TimelineOppositeContent, + TimelineSeparator +} from "@material-ui/lab"; +import { createStyles, Theme, makeStyles } from "@material-ui/core/styles"; +import { + EmojiFlags as EmojiFlagsIcon, + CropFree as CropFreeIcon +} from "@material-ui/icons"; +import Link from "next/link"; +import clsx from "clsx"; +import { AdrStatusChip } from "../../../../components"; +import { AdrLight } from "../../../../types"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: {}, + emptyLabel: { + color: theme.palette.grey[500], + marginTop: theme.spacing(3), + marginLeft: "6ch" + }, + timeline: { + padding: 0 + }, + adrLink: { + display: "block" + }, + timelineItem: { + "&:hover": { + "& $timelineConnector": { + backgroundColor: theme.palette.primary.main + } + } + }, + selectedTimelineItem: { + "&:hover": { + "& $timelineConnector": { + backgroundColor: theme.palette.secondary.main + } + }, + "& $timelineConnector": { + backgroundColor: theme.palette.secondary.main + }, + "& $adrLink": { + color: theme.palette.secondary.main, + "&:hover": { + color: theme.palette.secondary.main + } + } + }, + timelineOppositeContentRoot: { + flex: "0 0 12ch" + }, + date: { + fontSize: "0.8rem", + color: theme.palette.grey[500] + }, + adrStatusChip: { + marginLeft: "-1ch" + }, + icon: { + verticalAlign: "middle" + }, + adrTitle: { + marginRight: "0.5ch" + }, + package: { + fontSize: "0.8rem", + verticalAlign: "text-top", + whiteSpace: "pre", + color: theme.palette.grey[700] + }, + timelineStartOppositeContentRoot: { + flex: "0 0 calc(12ch - 12px)" + }, + timelineContentContainer: { + paddingBottom: theme.spacing(2) + }, + timelineConnector: {}, + currentAdrTimelineConnector: { + backgroundColor: theme.palette.secondary.main + }, + // TODO: refactor with AdrLink.tsx + draftLink: {}, + proposedLink: {}, + acceptedLink: {}, + rejectedLink: { + textDecoration: "line-through" + }, + deprecatedLink: { + textDecoration: "line-through" + }, + supersededLink: { + textDecoration: "line-through" + } + }) +); + +type Props = { + adrs?: AdrLight[]; + currentAdrSlug?: string; + className?: string; +}; + +export function AdrMenu({ adrs, currentAdrSlug, className, ...props }: Props) { + const classes = useStyles(); + + if (adrs === undefined) { + return null; // Because inside a + } + + let lastDateString = ""; + + return ( +
    + {adrs.length === 0 && ( + + No ADR found :-( + + )} + + + {adrs.map((adr) => { + const currentDateString = moment( + adr.publicationDate || adr.creationDate + ).format("MMMM|YYYY"); + const dateString = + currentDateString === lastDateString ? "" : currentDateString; + lastDateString = currentDateString; + const [month, year] = dateString.split("|"); + + return ( + + + + {month} + + + {year} + + + + + + + +
    + + + + {adr.title || "Untitled"} + + {adr.package ? ( + + {" "} + {adr.package} + + ) : null} + + +
    + +
    +
    +
    +
    + ); + })} + + + + + + + + + + + +
    +
    + ); +} diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/AdrMenu/index.ts b/packages/web/src/layouts/AdrBrowserLayout/components/AdrMenu/index.ts new file mode 100644 index 00000000..f655fa76 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/AdrMenu/index.ts @@ -0,0 +1 @@ +export * from "./AdrMenu"; diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/ConnectedSearchBox/ConnectedSearchBox.tsx b/packages/web/src/layouts/AdrBrowserLayout/components/ConnectedSearchBox/ConnectedSearchBox.tsx new file mode 100644 index 00000000..a35cb041 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/ConnectedSearchBox/ConnectedSearchBox.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { SearchBox, SearchBoxProps } from "../../../../components/SearchBox"; +import { + createSearchInstance, + Search, + SearchResult +} from "../../../../lib/search"; +import { Log4brainsMode, Log4brainsModeContext } from "../../../../contexts"; + +export type ConnectedSearchBoxProps = Omit< + SearchBoxProps, + "onQueryChange" | "query" | "results" | "onFocus" +>; + +export function ConnectedSearchBox(props: ConnectedSearchBoxProps) { + const mode = React.useContext(Log4brainsModeContext); + + const [searchInstance, setSearchInstance] = React.useState(); + const [pendingSearch, setPendingSearchState] = React.useState(false); + const [searchQuery, setSearchQueryState] = React.useState(""); + const [searchResults, setSearchResultsState] = React.useState( + [] + ); + + const handleSearchQueryChange = (query: string): void => { + setSearchQueryState(query); + + if (query.trim() === "") { + setSearchResultsState([]); + return; + } + + if (searchInstance) { + setSearchResultsState(searchInstance.search(query)); + if (pendingSearch) { + setPendingSearchState(false); + } + } else { + setPendingSearchState(true); + } + }; + + const handleFocus = async () => { + // We re-create the search instance on each focus in preview mode + if (!searchInstance || mode === Log4brainsMode.preview) { + setSearchInstance(await createSearchInstance(mode)); + } + }; + + // Trigger a possible pending search after setting the search instance + if (pendingSearch && searchInstance) { + handleSearchQueryChange(searchQuery); + } + + return ( + handleSearchQueryChange(query)} + query={searchQuery} + results={searchResults} + onFocus={handleFocus} + loading={pendingSearch} + /> + ); +} diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/ConnectedSearchBox/index.ts b/packages/web/src/layouts/AdrBrowserLayout/components/ConnectedSearchBox/index.ts new file mode 100644 index 00000000..369d8e14 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/ConnectedSearchBox/index.ts @@ -0,0 +1 @@ +export * from "./ConnectedSearchBox"; diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/RoutingProgress/RoutingProgress.tsx b/packages/web/src/layouts/AdrBrowserLayout/components/RoutingProgress/RoutingProgress.tsx new file mode 100644 index 00000000..97dcb544 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/RoutingProgress/RoutingProgress.tsx @@ -0,0 +1,44 @@ +import { LinearProgress } from "@material-ui/core"; +import { makeStyles, createStyles } from "@material-ui/core/styles"; +import React from "react"; + +const useStyles = makeStyles(() => + createStyles({ + root: { + top: 0, + width: "100%", + height: 2, + position: "absolute" + } + }) +); + +export function RoutingProgress() { + const classes = useStyles(); + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + const timer = setInterval(() => { + setProgress((oldProgress) => { + if (oldProgress > 99.999) { + clearInterval(timer); + return 100; + } + return oldProgress + (100 - oldProgress) / 8; + }); + }, 100); + + return () => { + clearInterval(timer); + }; + }, []); + + return ( + + ); +} diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/RoutingProgress/index.ts b/packages/web/src/layouts/AdrBrowserLayout/components/RoutingProgress/index.ts new file mode 100644 index 00000000..59ac133f --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/RoutingProgress/index.ts @@ -0,0 +1 @@ +export * from "./RoutingProgress"; diff --git a/packages/web/src/layouts/AdrBrowserLayout/components/index.ts b/packages/web/src/layouts/AdrBrowserLayout/components/index.ts new file mode 100644 index 00000000..85cd5c30 --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/components/index.ts @@ -0,0 +1,2 @@ +export * from "./AdrMenu"; +export * from "./ConnectedSearchBox"; diff --git a/packages/web/src/layouts/AdrBrowserLayout/index.ts b/packages/web/src/layouts/AdrBrowserLayout/index.ts new file mode 100644 index 00000000..f3dde27b --- /dev/null +++ b/packages/web/src/layouts/AdrBrowserLayout/index.ts @@ -0,0 +1,3 @@ +export * from "./AdrBrowserLayout"; +// eslint-disable-next-line import/no-cycle +export * from "./ConnectedAdrBrowserLayout"; diff --git a/packages/web/src/layouts/index.ts b/packages/web/src/layouts/index.ts new file mode 100644 index 00000000..b2bffe93 --- /dev/null +++ b/packages/web/src/layouts/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/no-cycle +export * from "./AdrBrowserLayout"; diff --git a/packages/web/src/lib/adr-utils.ts b/packages/web/src/lib/adr-utils.ts new file mode 100644 index 00000000..2218f256 --- /dev/null +++ b/packages/web/src/lib/adr-utils.ts @@ -0,0 +1,12 @@ +import { Adr, AdrLight } from "../types"; + +export function getAdrBySlug( + slug: string, + adrs: AdrLight[] +): AdrLight | undefined { + return adrs.filter((a) => a.slug === slug).pop(); +} + +export function buildAdrUrl(adr: AdrLight | Adr): string { + return `/adr/${adr.slug}`; +} diff --git a/packages/web/src/lib/console.ts b/packages/web/src/lib/console.ts new file mode 100644 index 00000000..35fb56f4 --- /dev/null +++ b/packages/web/src/lib/console.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import chalk from "chalk"; +import { AppConsole, ConsoleCapturer } from "@log4brains/cli-common"; + +const debug = !!process.env.DEBUG; +const dev = process.env.NODE_ENV === "development"; + +export const appConsole = new AppConsole({ debug, traces: debug || dev }); + +/** + * #NEXTJS-HACK + * We want to hide the output of Next.js when we execute CLI commands. + * + * @param fn The code which calls Next.js methods for which we want to capture the output + */ +export async function execNext(fn: () => Promise): Promise { + const capturer = new ConsoleCapturer(); + if (debug) { + capturer.onLog = (method, args) => { + capturer.doPrintln(...["[Next] ", ...args].map((a) => chalk.dim(a))); + }; + } + capturer.start(); + await fn(); + capturer.stop(); +} diff --git a/packages/web/src/lib/core-api/getIndexPageMarkdown.ts b/packages/web/src/lib/core-api/getIndexPageMarkdown.ts new file mode 100644 index 00000000..2a3040f9 --- /dev/null +++ b/packages/web/src/lib/core-api/getIndexPageMarkdown.ts @@ -0,0 +1,23 @@ +import path from "path"; +import { promises as fsP } from "fs"; +import { getLog4brainsInstance } from "./instance"; + +export async function getIndexPageMarkdown(): Promise { + const instance = getLog4brainsInstance(); + const indexPath = path.join( + instance.workdir, + instance.config.project.adrFolder, + "index.md" + ); + + try { + return await fsP.readFile(indexPath, { + encoding: "utf8" + }); + } catch (e) { + return `# Architecture knowledge base + +Please create \`${instance.config.project.adrFolder}/index.md\` to customize this homepage. +`; + } +} diff --git a/packages/web/src/lib/core-api/index.ts b/packages/web/src/lib/core-api/index.ts new file mode 100644 index 00000000..4e049273 --- /dev/null +++ b/packages/web/src/lib/core-api/index.ts @@ -0,0 +1,2 @@ +export * from "./instance"; +export * from "./getIndexPageMarkdown"; diff --git a/packages/web/src/lib/core-api/instance.ts b/packages/web/src/lib/core-api/instance.ts new file mode 100644 index 00000000..e6d93498 --- /dev/null +++ b/packages/web/src/lib/core-api/instance.ts @@ -0,0 +1,22 @@ +import path from "path"; +import { Log4brains } from "@log4brains/core"; +import { getConfig } from "../next"; + +let instance: Log4brains; + +export function getLog4brainsInstance(): Log4brains { + if (!instance) { + if (process.env.LOG4BRAINS_PHASE === "initial-build") { + // Noop instance during "next build" phase + instance = Log4brains.create( + path.join( + getConfig().serverRuntimeConfig.PROJECT_ROOT, + "lib/core-api/noop" + ) + ); + } else { + instance = Log4brains.create(process.env.LOG4BRAINS_CWD || "."); + } + } + return instance; +} diff --git a/packages/web/src/lib/core-api/noop/.log4brains.yml b/packages/web/src/lib/core-api/noop/.log4brains.yml new file mode 100644 index 00000000..52d0f439 --- /dev/null +++ b/packages/web/src/lib/core-api/noop/.log4brains.yml @@ -0,0 +1,7 @@ +--- +# This config file is used by instance.ts during Next.js build phase, +# When we want to create a noop instance of Log4brains +project: + name: noop + tz: Etc/UTC + adrFolder: ./noop-adrs diff --git a/packages/web/src/lib/core-api/noop/noop-adrs/.gitignore b/packages/web/src/lib/core-api/noop/noop-adrs/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/packages/web/src/lib/debug.ts b/packages/web/src/lib/debug.ts new file mode 100644 index 00000000..09a79caa --- /dev/null +++ b/packages/web/src/lib/debug.ts @@ -0,0 +1,7 @@ +/* eslint-disable no-console */ + +export function debug(message: string): void { + if (process.env.NODE_ENV === "development") { + console.log(message); + } +} diff --git a/packages/web/src/lib/next.ts b/packages/web/src/lib/next.ts new file mode 100644 index 00000000..a1a1a153 --- /dev/null +++ b/packages/web/src/lib/next.ts @@ -0,0 +1,40 @@ +import path from "path"; +import getNextConfig from "next/config"; + +export function getNextJsDir(): string { + // When built, there is no more "src/" directory + return path.resolve(path.join(__dirname, "..")); +} + +export type L4bNextConfig = { + serverRuntimeConfig: { + PROJECT_ROOT: string; + VERSION: string; + }; +}; + +function isObjectWithGivenProperties( + obj: unknown, + properties: K[] +): obj is Record { + return ( + typeof obj === "object" && + obj !== null && + properties.every((property) => property in obj) + ); +} + +function isL4bNextConfig(config: unknown): config is L4bNextConfig { + return ( + isObjectWithGivenProperties(config, ["serverRuntimeConfig"]) && + isObjectWithGivenProperties(config.serverRuntimeConfig, ["PROJECT_ROOT"]) + ); +} + +export function getConfig(): L4bNextConfig { + const config = getNextConfig() as unknown; + if (!isL4bNextConfig(config)) { + throw new Error(`Invalid Next.js config object: ${config}`); + } + return config; +} diff --git a/packages/web/src/lib/search/Search.ts b/packages/web/src/lib/search/Search.ts new file mode 100644 index 00000000..176849dd --- /dev/null +++ b/packages/web/src/lib/search/Search.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { AdrDto } from "@log4brains/core"; +import lunr from "lunr"; + +type AdrForSearch = { + title: string; + verbatim: string; // body without Markdown or HTML tags, without recurring headers +}; + +export type SerializedIndex = { + lunr: object; + adrs: [string, AdrForSearch][]; +}; + +export type SearchResult = { + slug: string; + href: string; + title: string; + + /** + * A number between 0 and 1 representing how similar this document is to the query. + * @see lunr.Index.Result + */ + score: number; + + // TODO: add highlighted verbatim (https://github.com/thomvaill/log4brains/issues/5) +}; + +function mapToJson(map: Map): [K, V][] { + return Array.from(map.entries()); +} + +function mapFromJson(entries: [K, V][]): Map { + return new Map(entries); +} + +/** + * Inspired by https://github.com/squidfunk/mkdocs-material/tree/master/src/assets/javascripts/integrations/search + */ +export class Search { + private constructor( + private readonly index: lunr.Index, + private readonly adrs: Map + ) {} + + search(query: string): SearchResult[] { + return this.index.search(`${query}*`).map((result) => { + const adr = this.adrs.get(result.ref); + if (!adr) { + throw new Error(`Invalid Search instance: missing ADR "${result.ref}"`); + } + return { + slug: result.ref, + href: `/adr/${result.ref}`, + title: adr.title, + score: result.score + }; + }); + } + + serializeIndex(): SerializedIndex { + return { lunr: this.index.toJSON(), adrs: mapToJson(this.adrs) }; + } + + static createFromAdrs(adrs: AdrDto[]): Search { + const adrsForSearch = new Map( + adrs.map((adr) => [ + adr.slug, + { + title: adr.title || "Untitled", + verbatim: adr.body.enhancedMdx // TODO: remove tags (https://github.com/thomvaill/log4brains/issues/5) + } + ]) + ); + + const index = lunr((builder) => { + builder.ref("slug"); + builder.field("title", { boost: 1000 }); + builder.field("verbatim"); + // eslint-disable-next-line no-param-reassign + builder.metadataWhitelist = ["position"]; + + adrsForSearch.forEach((adr, slug) => { + builder.add({ + slug, + title: adr.title, + verbatim: adr.verbatim + }); + }); + }); + return new Search(index, adrsForSearch); + } + + static createFromSerializedIndex(serializedIndex: SerializedIndex): Search { + return new Search( + lunr.Index.load(serializedIndex.lunr), + mapFromJson(serializedIndex.adrs) + ); + } +} diff --git a/packages/web/src/lib/search/index.ts b/packages/web/src/lib/search/index.ts new file mode 100644 index 00000000..70434468 --- /dev/null +++ b/packages/web/src/lib/search/index.ts @@ -0,0 +1,2 @@ +export * from "./Search"; +export * from "./instance"; diff --git a/packages/web/src/lib/search/instance.ts b/packages/web/src/lib/search/instance.ts new file mode 100644 index 00000000..d006bfa4 --- /dev/null +++ b/packages/web/src/lib/search/instance.ts @@ -0,0 +1,29 @@ +import Router from "next/router"; +import { Log4brainsMode } from "../../contexts"; +import { Search, SerializedIndex } from "./Search"; + +function isSerializedIndex(obj: unknown): obj is SerializedIndex { + return ( + typeof obj === "object" && + obj !== null && + "lunr" in obj && + "adrs" in obj && + Array.isArray((obj as SerializedIndex).adrs) + ); +} + +export async function createSearchInstance( + mode: Log4brainsMode +): Promise { + const index = (await ( + await fetch( + mode === Log4brainsMode.preview + ? `/api/search-index` + : `${Router.basePath}/data/${process.env.NEXT_BUILD_ID}/search-index.json` + ) + ).json()) as unknown; + if (!isSerializedIndex(index)) { + throw new Error(`Invalid Search SerializedIndex: ${index}`); + } + return Search.createFromSerializedIndex(index); +} diff --git a/packages/web/src/lib/slugify.ts b/packages/web/src/lib/slugify.ts new file mode 100644 index 00000000..48732120 --- /dev/null +++ b/packages/web/src/lib/slugify.ts @@ -0,0 +1,9 @@ +import slugifyFn from "slugify"; + +// used to slugify markdown paragraph IDs +export function slugify(string: string): string { + return slugifyFn(string, { + lower: true, + strict: true + }); +} diff --git a/packages/web/src/lib/toc-utils/Toc.ts b/packages/web/src/lib/toc-utils/Toc.ts new file mode 100644 index 00000000..e513c80f --- /dev/null +++ b/packages/web/src/lib/toc-utils/Toc.ts @@ -0,0 +1,23 @@ +import { TocContainer } from "./TocContainer"; +import { TocSection } from "./TocSection"; + +export class Toc implements TocContainer { + public readonly parent = null; + + readonly children: TocSection[] = []; + + createChild(title: string, id: string): TocSection { + const child = new TocSection(this, title, id); + this.children.push(child); + return child; + } + + // eslint-disable-next-line class-methods-use-this + getLevel(): number { + return 0; + } + + render(renderer: (title: string, id: string, children: T[]) => T): T[] { + return this.children.map((child) => child.render(renderer)); + } +} diff --git a/packages/web/src/lib/toc-utils/TocBuilder.test.ts b/packages/web/src/lib/toc-utils/TocBuilder.test.ts new file mode 100644 index 00000000..d6801988 --- /dev/null +++ b/packages/web/src/lib/toc-utils/TocBuilder.test.ts @@ -0,0 +1,121 @@ +import { Toc } from "./Toc"; +import { TocSection } from "./TocSection"; +import { TocBuilder } from "./TocBuilder"; + +type TocRep = { + level: number; + title: string; + children: TocRep[]; +}; +const tocToArray = (section: TocSection | Toc): TocRep[] => { + return section.children.map((child) => { + return { + level: child.getLevel(), + title: child.title, + children: tocToArray(child) + }; + }); +}; + +describe("TocBuilder", () => { + it("should add sections to the TOC correctly", () => { + const builder = new TocBuilder(); + builder.addSection(1, "Header 1", "Header1"); + builder.addSection(2, "Header 1.1", "Header1.1"); + builder.addSection(3, "Header 1.1.1", "Header1.1.1"); + builder.addSection(4, "Header 1.1.1.1", "Header1.1.1.1"); + builder.addSection(5, "Header 1.1.1.1.1", "Header1.1.1.1.1"); + builder.addSection(6, "Header 1.1.1.1.1.1", "Header1.1.1.1.1.1"); + builder.addSection(2, "Header 1.2", "Header1.2"); + builder.addSection(3, "Header 1.2.1", "Header1.2.1"); + builder.addSection(3, "Header 1.2.2", "Header1.2.2"); + builder.addSection(3, "Header 1.2.3", "Header1.2.3"); + builder.addSection(1, "Header 2", "Header2"); + builder.addSection(1, "Header 3", "Header3"); + + const toc = builder.getToc(); + expect(tocToArray(toc)).toEqual([ + { + level: 1, + title: "Header 1", + children: [ + { + level: 2, + title: "Header 1.1", + children: [ + { + level: 3, + title: "Header 1.1.1", + children: [ + { + level: 4, + title: "Header 1.1.1.1", + children: [ + { + level: 5, + title: "Header 1.1.1.1.1", + children: [ + { + level: 6, + title: "Header 1.1.1.1.1.1", + children: [] + } + ] + } + ] + } + ] + } + ] + }, + { + level: 2, + title: "Header 1.2", + children: [ + { + level: 3, + title: "Header 1.2.1", + children: [] + }, + { + level: 3, + title: "Header 1.2.2", + children: [] + }, + { + level: 3, + title: "Header 1.2.3", + children: [] + } + ] + } + ] + }, + { + level: 1, + title: "Header 2", + children: [] + }, + { + level: 1, + title: "Header 3", + children: [] + } + ]); + }); + + it("debug", () => { + const builder = new TocBuilder(); + builder.addSection(1, "Header 1", "Header1"); + builder.addSection(2, "Header 1.1", "Header1.1"); + builder.addSection(3, "Header 1.1.1", "Header1.1.1"); + builder.addSection(4, "Header 1.1.1.1", "Header1.1.1.1"); + builder.addSection(1, "Header 2", "Header2"); + builder.addSection(1, "Header 3", "Header3"); + builder.addSection(2, "Header 3.1", "Header3.1"); + builder.addSection(2, "Header 3.2", "Header3.2"); + + const toc = builder.getToc(); + expect(toc.children.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/web/src/lib/toc-utils/TocBuilder.ts b/packages/web/src/lib/toc-utils/TocBuilder.ts new file mode 100644 index 00000000..72a7d23d --- /dev/null +++ b/packages/web/src/lib/toc-utils/TocBuilder.ts @@ -0,0 +1,40 @@ +import { Toc } from "./Toc"; +import { TocContainer } from "./TocContainer"; + +export class TocBuilder { + private readonly root: Toc; + + private current: TocContainer; + + constructor() { + this.root = new Toc(); + this.current = this.root; + } + + addSection(level: number, title: string, id: string): void { + if (level <= 0) { + throw new Error("Level must be > 0"); + } + + if (level < this.current.getLevel() + 1) { + // eg: section to add = H2, current section = H2 -> we have to step back from one level + if (!this.current.parent) { + throw new Error("Never happens thanks to recursion"); + } + this.current = this.current.parent; + this.addSection(level, title, id); + } else if (level > this.current.getLevel() + 1) { + // eg: section to add = H4, current section = H2 -> we have to create an empty intermediate section + this.current = this.current.createChild("", ""); + this.addSection(level, title, id); + } else if (level === this.current.getLevel() + 1) { + // recursion stop condition + // eg: section to add = H2, current section = H1 + this.current = this.current.createChild(title, id); + } + } + + getToc(): Toc { + return this.root; + } +} diff --git a/packages/web/src/lib/toc-utils/TocContainer.ts b/packages/web/src/lib/toc-utils/TocContainer.ts new file mode 100644 index 00000000..33df586d --- /dev/null +++ b/packages/web/src/lib/toc-utils/TocContainer.ts @@ -0,0 +1,5 @@ +export interface TocContainer { + parent: TocContainer | null; + getLevel(): number; + createChild(title: string, id: string): TocContainer; +} diff --git a/packages/web/src/lib/toc-utils/TocSection.ts b/packages/web/src/lib/toc-utils/TocSection.ts new file mode 100644 index 00000000..7c02e21b --- /dev/null +++ b/packages/web/src/lib/toc-utils/TocSection.ts @@ -0,0 +1,34 @@ +import { TocContainer } from "./TocContainer"; + +export class TocSection { + readonly children: TocSection[] = []; + + readonly parent: TocContainer; + + readonly title: string; + + readonly id: string; + + // Typescript parameter properties are not supported by Storybook for now! :-( + // https://github.com/storybookjs/storybook/issues/12019 + constructor(parent: TocContainer, title: string, id: string) { + this.parent = parent; + this.title = title; + this.id = id; + } + + createChild(title: string, id: string): TocSection { + const child = new TocSection(this, title, id); + this.children.push(child); + return child; + } + + getLevel(): number { + return this.parent.getLevel() + 1; + } + + render(renderer: (title: string, id: string, children: T[]) => T): T { + const c = this.children.map((child) => child.render(renderer)); + return renderer(this.title, this.id, c); + } +} diff --git a/packages/web/src/lib/toc-utils/index.ts b/packages/web/src/lib/toc-utils/index.ts new file mode 100644 index 00000000..1b7b0ff6 --- /dev/null +++ b/packages/web/src/lib/toc-utils/index.ts @@ -0,0 +1,4 @@ +export * from "./Toc"; +export * from "./TocBuilder"; +export * from "./TocContainer"; +export * from "./TocSection"; diff --git a/packages/web/src/mui/MuiDecorator.tsx b/packages/web/src/mui/MuiDecorator.tsx new file mode 100644 index 00000000..1f17c6a5 --- /dev/null +++ b/packages/web/src/mui/MuiDecorator.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { ThemeProvider } from "@material-ui/core/styles"; +import { CssBaseline } from "@material-ui/core"; +import { theme } from "./theme"; + +type Props = { + children: React.ReactNode; +}; + +export function MuiDecorator({ children }: Props) { + return ( + + + {children} + + ); +} diff --git a/packages/web/src/mui/index.ts b/packages/web/src/mui/index.ts new file mode 100644 index 00000000..63bf6bdd --- /dev/null +++ b/packages/web/src/mui/index.ts @@ -0,0 +1,2 @@ +export * from "./MuiDecorator"; +export * from "./theme"; diff --git a/packages/web/src/mui/theme.ts b/packages/web/src/mui/theme.ts new file mode 100644 index 00000000..74dc5d8d --- /dev/null +++ b/packages/web/src/mui/theme.ts @@ -0,0 +1,110 @@ +import { + createMuiTheme, + darken, + Theme, + responsiveFontSizes +} from "@material-ui/core/styles"; +import { red } from "@material-ui/core/colors"; + +export type CustomTheme = Theme & { + custom: { + layout: { + centerColBasis: number; + centerColPadding: number; + rightColBasis: number; + }; + }; +}; + +const primary = "#2176AE"; +const titleFontFamily = '"Roboto Slab", "Noto Serif", "Times New Roman", serif'; + +export const theme: CustomTheme = { + ...responsiveFontSizes( + createMuiTheme({ + palette: { + primary: { + main: primary + }, + secondary: { + main: "#FF007B" + }, + error: { + main: red.A400 + }, + background: { + default: "#fff" + } + }, + typography: { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + h1: { + fontFamily: titleFontFamily + }, + h2: { + fontFamily: titleFontFamily + }, + h3: { + fontFamily: titleFontFamily, + lineHeight: 1.1 + }, + h4: { + fontFamily: titleFontFamily + }, + h5: { + fontFamily: titleFontFamily + }, + h6: { + fontFamily: titleFontFamily + } + }, + props: { + MuiLink: { + underline: "none" + } + }, + overrides: { + MuiCssBaseline: { + "@global": { + html: { + maxWidth: "100%" + }, + body: { + padding: "0 !important", // for storybook + maxWidth: "100%" + }, + blockquote: { + margin: 0, + padding: "0 1em", + borderLeft: "0.25em solid #F8F8F8", + color: "#9e9e9e" + } + } + }, + MuiLink: { + root: { + "&:hover": { + color: darken(primary, 0.3) + } + } + } + }, + breakpoints: { + values: { + xs: 0, + sm: 900, + md: 1060, + lg: 1280, + xl: 1920 + } + } + }) + ), + custom: { + layout: { + centerColBasis: 750 + 4 * 8, + centerColPadding: 4 * 8, + rightColBasis: 180 + } + } +}; diff --git a/packages/web/src/pages/_app.tsx b/packages/web/src/pages/_app.tsx new file mode 100644 index 00000000..6f4eeed3 --- /dev/null +++ b/packages/web/src/pages/_app.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import type { AppProps } from "next/app"; +import { useRouter } from "next/router"; +import "highlight.js/styles/github.css"; +import "../components/Markdown/hljs.css"; +import { NextComponentType, NextPageContext } from "next"; +import { MuiDecorator } from "../mui"; +import { Log4brainsMode, Log4brainsModeContext } from "../contexts"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ComponentWithLayout

    = NextComponentType & { + getLayout?: ( + page: JSX.Element, + layoutProps: Record + ) => JSX.Element; +}; +type AppPropsWithLayout

    > = AppProps

    & { + Component: ComponentWithLayout

    ; +}; + +export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { + // Remove the server-side injected CSS (@see https://github.com/mui-org/material-ui/blob/master/examples/nextjs/pages/_app.js) + useEffect(() => { + const jssStyles = document.querySelector("#jss-server-side"); + jssStyles?.parentElement?.removeChild(jssStyles); + }); + + // Persistent Layout Pattern (https://adamwathan.me/2019/10/17/persistent-layout-patterns-in-nextjs/) + const getLayout = Component.getLayout || ((page) => page); + + const router = useRouter(); + const mode = process.env.NEXT_PUBLIC_LOG4BRAINS_STATIC + ? Log4brainsMode.static + : Log4brainsMode.preview; + + return ( + <> + + Architecture knowledge base + + + + + + + {getLayout(, pageProps)} + + + + ); +} diff --git a/packages/web/src/pages/_document.tsx b/packages/web/src/pages/_document.tsx new file mode 100644 index 00000000..5e5c706e --- /dev/null +++ b/packages/web/src/pages/_document.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import Document, { Html, Head, Main, NextScript } from "next/document"; +import { ServerStyleSheets } from "@material-ui/core/styles"; +import { theme } from "../mui"; + +// @see https://github.com/mui-org/material-ui/blob/master/examples/nextjs/pages/_document.js + +export default class MyDocument extends Document { + render() { + return ( + + + {/* PWA primary color */} + + + + + + +

    + + {!process.env.NEXT_PUBLIC_LOG4BRAINS_STATIC && ( +