From 3656c9989790571b09ef198c2f90aeda8a44f0d4 Mon Sep 17 00:00:00 2001 From: Brian Phillips <28457+brianphillips@users.noreply.github.com> Date: Wed, 24 May 2023 08:57:41 -0500 Subject: [PATCH 1/4] Parse and display MADR v3 front matter correctly Signed-off-by: Brian Phillips <28457+brianphillips@users.noreply.github.com> --- plugins/adr-backend/src/search/madrParser.ts | 95 ++++++------------- plugins/adr-common/package.json | 3 +- plugins/adr-common/src/index.ts | 41 ++++++++ .../src/components/AdrReader/AdrReader.tsx | 1 + .../src/components/AdrReader/decorators.ts | 20 ++++ yarn.lock | 10 ++ 6 files changed, 102 insertions(+), 68 deletions(-) diff --git a/plugins/adr-backend/src/search/madrParser.ts b/plugins/adr-backend/src/search/madrParser.ts index c992ef125e..0dd1437538 100644 --- a/plugins/adr-backend/src/search/madrParser.ts +++ b/plugins/adr-backend/src/search/madrParser.ts @@ -16,7 +16,10 @@ import { DateTime } from 'luxon'; import { marked } from 'marked'; -import { MADR_DATE_FORMAT } from '@backstage/plugin-adr-common'; +import { + MADR_DATE_FORMAT, + parseMadrWithFrontmatter, +} from '@backstage/plugin-adr-common'; const getTitle = (tokens: marked.TokensList): string | undefined => { return ( @@ -26,22 +29,6 @@ const getTitle = (tokens: marked.TokensList): string | undefined => { )?.text; }; -const getStatusForV3Format = (tokens: marked.TokensList): string | undefined => - ( - tokens.find( - t => - t.type === 'heading' && - t.depth === 2 && - t.text.toLowerCase().includes('status:'), - ) as marked.Tokens.Text - )?.text - .toLowerCase() - .split('\n') - .find(l => /^status:/.test(l)) - ?.replace(/^status:/i, '') - .trim() - .toLocaleLowerCase('en-US'); - const getStatusForV2Format = (tokens: marked.TokensList): string | undefined => (tokens.find(t => t.type === 'list') as marked.Tokens.List)?.items ?.find(t => /^status:/i.test(t.text)) @@ -49,71 +36,45 @@ const getStatusForV2Format = (tokens: marked.TokensList): string | undefined => .trim() .toLocaleLowerCase('en-US'); -const getDateForV3Format = ( - tokens: marked.TokensList, - dateFormat: string, -): string | undefined => { - let adrDateTime: DateTime | undefined; - const dateLine = ( - tokens.find( - t => - t.type === 'heading' && - t.depth === 2 && - t.text.toLowerCase().includes('date:'), - ) as marked.Tokens.Text - )?.text - .toLowerCase() - .split('\n') - .find(l => /^date:/.test(l)); - - if (dateLine) { - adrDateTime = DateTime.fromFormat( - dateLine.replace(/^date:/i, '').trim(), - dateFormat, - ); - } - return adrDateTime?.isValid - ? adrDateTime.toFormat(MADR_DATE_FORMAT) - : undefined; -}; - -const getDateForV2Format = ( - tokens: marked.TokensList, - dateFormat: string, -): string | undefined => { +const getDateForV2Format = (tokens: marked.TokensList): string | undefined => { const listTokens = (tokens.find(t => t.type === 'list') as marked.Tokens.List) ?.items; - const adrDateTime = DateTime.fromFormat( - listTokens - ?.find(t => /^date:/i.test(t.text)) - ?.text.replace(/^date:/i, '') - .trim() ?? '', - dateFormat, - ); - return adrDateTime?.isValid - ? adrDateTime.toFormat(MADR_DATE_FORMAT) - : undefined; + const adrDateTime = listTokens + ?.find(t => /^date:/i.test(t.text)) + ?.text.replace(/^date:/i, '') + .trim(); + return adrDateTime; }; -const getStatus = (tokens: marked.TokensList): string | undefined => { - return getStatusForV3Format(tokens) ?? getStatusForV2Format(tokens); +const getStatus = ( + tokens: marked.TokensList, + frontMatterStatus?: string, +): string | undefined => { + return frontMatterStatus ?? getStatusForV2Format(tokens); }; const getDate = ( tokens: marked.TokensList, dateFormat: string, -): string | undefined => - getDateForV3Format(tokens, dateFormat) ?? - getDateForV2Format(tokens, dateFormat); + frontMatterDate?: string, +): string | undefined => { + const dateString = frontMatterDate ?? getDateForV2Format(tokens); + if (!dateString) { + return undefined; + } + const date = DateTime.fromFormat(dateString, dateFormat); + return date?.isValid ? date.toFormat(MADR_DATE_FORMAT) : undefined; +}; export const madrParser = (content: string, dateFormat = MADR_DATE_FORMAT) => { - const tokens = marked.lexer(content); + const preparsed = parseMadrWithFrontmatter(content); + const tokens = marked.lexer(preparsed.content); if (!tokens.length) { throw new Error('ADR has no content'); } return { title: getTitle(tokens), - status: getStatus(tokens), - date: getDate(tokens, dateFormat), + status: getStatus(tokens, preparsed.status), + date: getDate(tokens, dateFormat, preparsed.date), }; }; diff --git a/plugins/adr-common/package.json b/plugins/adr-common/package.json index adbb6bea58..63bd30cb97 100644 --- a/plugins/adr-common/package.json +++ b/plugins/adr-common/package.json @@ -31,7 +31,8 @@ "dependencies": { "@backstage/catalog-model": "workspace:^", "@backstage/integration": "workspace:^", - "@backstage/plugin-search-common": "workspace:^" + "@backstage/plugin-search-common": "workspace:^", + "front-matter": "^4.0.2" }, "devDependencies": { "@backstage/cli": "workspace:^" diff --git a/plugins/adr-common/src/index.ts b/plugins/adr-common/src/index.ts index cf71367cba..194f4899d1 100644 --- a/plugins/adr-common/src/index.ts +++ b/plugins/adr-common/src/index.ts @@ -20,6 +20,7 @@ import { Entity, getEntitySourceLocation } from '@backstage/catalog-model'; import { IndexableDocument } from '@backstage/plugin-search-common'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import frontMatter from 'front-matter'; /** * ADR plugin annotation. @@ -101,3 +102,43 @@ export interface AdrDocument extends IndexableDocument { */ date?: string; } + +/** + * Parsed MADR document with front matter (if present) parsed and extracted from the main markdown content. + * @public + */ +export interface ParsedMadr { + /** + * Main body of ADR content (with any front matter removed) + */ + content: string; + /** + * ADR status + */ + status?: string; + /** + * ADR date + */ + date?: string; + /** + * All attributes parsed from front matter + */ + attributes: Record; +} + +/** + * Utility function to parse raw markdown content for an ADR and extract any metadata found as "front matter" at the top of the Markdown document. + * @param content - Raw markdown content which may (optionally) include front matter + * @public + */ +export const parseMadrWithFrontmatter = (content: string): ParsedMadr => { + const parsed = frontMatter>(content); + const status = parsed.attributes.status; + const date = parsed.attributes.date; + return { + content: parsed.body, + status: status ? String(status) : undefined, + date: date ? String(date) : undefined, + attributes: parsed.attributes, + }; +}; diff --git a/plugins/adr/src/components/AdrReader/AdrReader.tsx b/plugins/adr/src/components/AdrReader/AdrReader.tsx index ad99ce3a5a..6b07eee4cd 100644 --- a/plugins/adr/src/components/AdrReader/AdrReader.tsx +++ b/plugins/adr/src/components/AdrReader/AdrReader.tsx @@ -59,6 +59,7 @@ export const AdrReader = (props: { const adrDecorators = decorators ?? [ adrDecoratorFactories.createRewriteRelativeLinksDecorator(), adrDecoratorFactories.createRewriteRelativeEmbedsDecorator(), + adrDecoratorFactories.createFrontMatterFormatterDecorator(), ]; return adrDecorators.reduce( diff --git a/plugins/adr/src/components/AdrReader/decorators.ts b/plugins/adr/src/components/AdrReader/decorators.ts index f24bb48a00..980eab5179 100644 --- a/plugins/adr/src/components/AdrReader/decorators.ts +++ b/plugins/adr/src/components/AdrReader/decorators.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { parseMadrWithFrontmatter } from '@backstage/plugin-adr-common'; import { AdrContentDecorator } from './types'; /** @@ -44,4 +45,23 @@ export const adrDecoratorFactories = Object.freeze({ ), }); }, + /** + * Formats YAML front-matter into a table format (if any exists in the markdown document) + */ + createFrontMatterFormatterDecorator(): AdrContentDecorator { + return ({ content }) => { + const parsedFrontmatter = parseMadrWithFrontmatter(content); + let table = ''; + const attrs = parsedFrontmatter.attributes; + if (Object.keys(attrs).length > 0) { + const stripNewLines = (val: unknown) => + String(val).replaceAll('\n', '
'); + const row = (vals: string[]) => `|${vals.join('|')}|\n`; + table = `${row(Object.keys(attrs))}`; + table += `${row(Object.keys(attrs).map(() => '---'))}`; + table += `${row(Object.values(attrs).map(stripNewLines))}\n\n`; + } + return { content: table + parsedFrontmatter.content }; + }; + }, }); diff --git a/yarn.lock b/yarn.lock index e29e353cc6..d5fe3b1ec1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4521,6 +4521,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-search-common": "workspace:^" + front-matter: ^4.0.2 languageName: unknown linkType: soft @@ -25387,6 +25388,15 @@ __metadata: languageName: node linkType: hard +"front-matter@npm:^4.0.2": + version: 4.0.2 + resolution: "front-matter@npm:4.0.2" + dependencies: + js-yaml: ^3.13.1 + checksum: a5b4c36d75a820301ebf31db0f677332d189c4561903ab6853eaa0504b43634f98557dbf87752e09043dbd2c9dcc14b4bcf9151cb319c8ad7e26edb203c0cd23 + languageName: node + linkType: hard + "fs-constants@npm:^1.0.0": version: 1.0.0 resolution: "fs-constants@npm:1.0.0" From 58524588448c66b59e3fc2a3a306040befc0abb2 Mon Sep 17 00:00:00 2001 From: Brian Phillips <28457+brianphillips@users.noreply.github.com> Date: Wed, 24 May 2023 09:11:24 -0500 Subject: [PATCH 2/4] Add changesets and API reports Signed-off-by: Brian Phillips <28457+brianphillips@users.noreply.github.com> --- .changeset/nine-bags-dream.md | 5 +++++ .changeset/real-donuts-brush.md | 5 +++++ .changeset/strange-geese-argue.md | 5 +++++ plugins/adr-common/api-report.md | 11 +++++++++++ plugins/adr/api-report.md | 1 + 5 files changed, 27 insertions(+) create mode 100644 .changeset/nine-bags-dream.md create mode 100644 .changeset/real-donuts-brush.md create mode 100644 .changeset/strange-geese-argue.md diff --git a/.changeset/nine-bags-dream.md b/.changeset/nine-bags-dream.md new file mode 100644 index 0000000000..f0967775db --- /dev/null +++ b/.changeset/nine-bags-dream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-adr': patch +--- + +Render table of front matter metadata when displaying MADR v3 formatted ADR diff --git a/.changeset/real-donuts-brush.md b/.changeset/real-donuts-brush.md new file mode 100644 index 0000000000..b6a875bd64 --- /dev/null +++ b/.changeset/real-donuts-brush.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-adr-backend': patch +--- + +Use front matter parser for MADR v3 formatted ADRs when indexing status/date diff --git a/.changeset/strange-geese-argue.md b/.changeset/strange-geese-argue.md new file mode 100644 index 0000000000..7be0886abb --- /dev/null +++ b/.changeset/strange-geese-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-adr-common': patch +--- + +Add utility function for parsing MADR front matter diff --git a/plugins/adr-common/api-report.md b/plugins/adr-common/api-report.md index 0236b355a5..a36854f0e7 100644 --- a/plugins/adr-common/api-report.md +++ b/plugins/adr-common/api-report.md @@ -35,4 +35,15 @@ export const MADR_DATE_FORMAT = 'yyyy-MM-dd'; // @public export const madrFilePathFilter: AdrFilePathFilterFn; + +// @public +export interface ParsedMadr { + attributes: Record; + content: string; + date?: string; + status?: string; +} + +// @public +export const parseMadrWithFrontmatter: (content: string) => ParsedMadr; ``` diff --git a/plugins/adr/api-report.md b/plugins/adr/api-report.md index dceca2411c..4cec5047fc 100644 --- a/plugins/adr/api-report.md +++ b/plugins/adr/api-report.md @@ -80,6 +80,7 @@ export const AdrReader: { decorators: Readonly<{ createRewriteRelativeLinksDecorator(): AdrContentDecorator; createRewriteRelativeEmbedsDecorator(): AdrContentDecorator; + createFrontMatterFormatterDecorator(): AdrContentDecorator; }>; }; From 02bcce712527154a53408db8fc148f1759eac385 Mon Sep 17 00:00:00 2001 From: Brian Phillips <28457+brianphillips@users.noreply.github.com> Date: Wed, 24 May 2023 09:53:47 -0500 Subject: [PATCH 3/4] Update docs to reflect default handling of front matter Signed-off-by: Brian Phillips <28457+brianphillips@users.noreply.github.com> --- plugins/adr/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/adr/README.md b/plugins/adr/README.md index 7feb9b03b0..e6178f003b 100644 --- a/plugins/adr/README.md +++ b/plugins/adr/README.md @@ -113,7 +113,7 @@ import { AdrDocument } from '@backstage/plugin-adr-common'; ## Custom ADR formats -By default, this plugin will parse ADRs according to the format specified by the [Markdown Architecture Decision Record (MADR) v2.x template](https://github.com/adr/madr/tree/2.1.2). If your ADRs are written using a different format, you can apply the following customizations to correctly identify and parse your documents: +By default, this plugin will parse ADRs according to the format specified by the [Markdown Architecture Decision Record (MADR) v2.x template](https://github.com/adr/madr/tree/2.1.2) or the [Markdown Any Decision Record (MADR) 3.x template](https://github.com/adr/madr/tree/3.0.0). If your ADRs are written using a different format, you can apply the following customizations to correctly identify and parse your documents: ### Custom Filename/Path Format @@ -135,7 +135,7 @@ const myCustomFilterFn: AdrFilePathFilterFn = (path: string): boolean => { ### Custom Content Decorators -Your ADR Markdown content will typically be rendered in the UI as is with the exception of relative links/embeds being rewritten as absolute URLs so they can be linked correctly (e.g. `./my-diagram.png` => `/my-diagram.png`). Depending on your ADR format, you may want to apply additional transformations to the content (e.g. parsing/ignoring front matter). You can do so by passing in a list of custom content decorators for the optional `contentDecorators` parameter. Note that passing in this parameter will override the default decorators. If you want to include the default ones, make sure to add them as well: +Your ADR Markdown content will typically be rendered in the UI as is with the exception of relative links/embeds being rewritten as absolute URLs so they can be linked correctly (e.g. `./my-diagram.png` => `/my-diagram.png`). Depending on your ADR format, you may want to apply additional transformations to the content (e.g. hiding or formatting front matter in a different way). You can do so by passing in a list of custom content decorators for the optional `contentDecorators` parameter. Note that passing in this parameter will override the default decorators. If you want to include the default ones, make sure to add them as well: ```tsx import { @@ -154,6 +154,7 @@ const myCustomDecorator: AdrContentDecorator = ({ content }) => { From 47c77f209b9e1f6bd8f317494328f5c60a8aa442 Mon Sep 17 00:00:00 2001 From: Brian Phillips <28457+brianphillips@users.noreply.github.com> Date: Wed, 24 May 2023 15:16:39 -0500 Subject: [PATCH 4/4] add ADRs to example app Signed-off-by: Brian Phillips <28457+brianphillips@users.noreply.github.com> --- app-config.yaml | 3 +++ packages/app/package.json | 1 + packages/app/src/components/catalog/EntityPage.tsx | 5 +++++ packages/app/src/components/search/SearchPage.tsx | 7 +++++++ packages/backend/src/plugins/search.ts | 13 +++++++++++++ plugins/adr/examples/component/catalog-info.yaml | 13 +++++++++++++ yarn.lock | 3 ++- 7 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 plugins/adr/examples/component/catalog-info.yaml diff --git a/app-config.yaml b/app-config.yaml index e2b03786c4..95995025bc 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -274,6 +274,9 @@ catalog: # Example component for TechDocs - type: file target: ../../plugins/techdocs-backend/examples/documented-component/catalog-info.yaml + # Example component for ADRs + - type: file + target: ../../plugins/adr/examples/component/catalog-info.yaml # Backstage example templates - type: file target: ../../plugins/scaffolder-backend/sample-templates/all-templates.yaml diff --git a/packages/app/package.json b/packages/app/package.json index 010b2dd648..3b7726fe4a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -15,6 +15,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/integration-react": "workspace:^", + "@backstage/plugin-adr": "workspace:^", "@backstage/plugin-airbrake": "workspace:^", "@backstage/plugin-apache-airflow": "workspace:^", "@backstage/plugin-api-docs": "workspace:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 026544ab26..ca6d09e318 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -26,6 +26,7 @@ import { RELATION_PROVIDES_API, } from '@backstage/catalog-model'; import { EmptyState, InfoCard } from '@backstage/core-components'; +import { EntityAdrContent, isAdrAvailable } from '@backstage/plugin-adr'; import { EntityApiDefinitionCard, EntityConsumedApisCard, @@ -484,6 +485,10 @@ const serviceEntityPage = ( {techdocsContent} + + + + { name: 'Documentation', icon: , }, + { + value: 'adr', + name: 'Architecture Decision Records', + icon: , + }, ]} /> @@ -131,6 +137,7 @@ const SearchPage = () => { } /> } /> } /> + } /> diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 0e6982545f..4290fcd68a 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -15,6 +15,7 @@ */ import { useHotCleanup } from '@backstage/backend-common'; +import { DefaultAdrCollatorFactory } from '@backstage/plugin-adr-backend'; import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend'; import { ToolDocumentCollatorFactory } from '@backstage/plugin-explore-backend'; import { createRouter } from '@backstage/plugin-search-backend'; @@ -68,6 +69,18 @@ export default async function createPlugin( // Collators are responsible for gathering documents known to plugins. This // particular collator gathers entities from the software catalog. + indexBuilder.addCollator({ + schedule, + factory: DefaultAdrCollatorFactory.fromConfig({ + cache: env.cache, + config: env.config, + discovery: env.discovery, + logger: env.logger, + reader: env.reader, + tokenManager: env.tokenManager, + }), + }); + indexBuilder.addCollator({ schedule, factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { diff --git a/plugins/adr/examples/component/catalog-info.yaml b/plugins/adr/examples/component/catalog-info.yaml new file mode 100644 index 0000000000..580110243f --- /dev/null +++ b/plugins/adr/examples/component/catalog-info.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: component-with-adrs + title: Component With ADRs + description: A Service with ADRs registerd + annotations: + backstage.io/adr-location: https://github.com/adr/madr/tree/develop/docs/decisions +spec: + type: service + lifecycle: experimental + owner: user:guest diff --git a/yarn.lock b/yarn.lock index d5fe3b1ec1..d61e80b77b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4525,7 +4525,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-adr@workspace:plugins/adr": +"@backstage/plugin-adr@workspace:^, @backstage/plugin-adr@workspace:plugins/adr": version: 0.0.0-use.local resolution: "@backstage/plugin-adr@workspace:plugins/adr" dependencies: @@ -24254,6 +24254,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/integration-react": "workspace:^" + "@backstage/plugin-adr": "workspace:^" "@backstage/plugin-airbrake": "workspace:^" "@backstage/plugin-apache-airflow": "workspace:^" "@backstage/plugin-api-docs": "workspace:^"