feat(ADR): implement ADR plugin

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2022-04-13 14:39:17 -04:00
parent 1cb9cec16b
commit e73075a301
37 changed files with 1679 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-adr': minor
'@backstage/plugin-adr-backend': minor
'@backstage/plugin-adr-common': minor
---
Implement ADR plugin
+9
View File
@@ -0,0 +1,9 @@
---
title: Architecture Decision Records
author: Phil Kuang
authorUrl: https://github.com/kuangp
category: Discovery
description: Browse your project's ADRs.
documentation: https://github.com/backstage/backstage/tree/master/plugins/adr
iconUrl: img/adr-logo.png
npmPackageName: '@backstage/plugin-adr'
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+47
View File
@@ -0,0 +1,47 @@
# ADR Backend
This ADR backend plugin is primarily responsible for the following:
- Provides a `DefaultAdrCollatorFactory`, which can be used in the search backend to index ADR documents associated with entities to your Backstage Search.
## Indexing ADR documents for search
Before you are able to start indexing ADR documents to search, you need to go through the [search getting started guide](https://backstage.io/docs/features/search/getting-started).
When you have your `packages/backend/src/plugins/search.ts` file ready to make modifications, install this plugin and add the following code snippet to add the `DefaultAdrCollatorFactory`. Also make sure to set up the frontend [ADR plugin](../adr/README.md) so search results can be routed correctly.
```bash
# From your Backstage root directory
yarn --cwd packages/backend add @backstage/plugin-adr-backend
```
```ts
import { DefaultAdrCollatorFactory } from '@backstage/plugin-adr-backend';
...
indexBuilder.addCollator({
schedule,
factory: DefaultAdrCollatorFactory.fromConfig({
cache: env.cache,
config: env.config,
discovery: env.discovery,
logger: env.logger,
reader: env.reader,
tokenManager: env.tokenManager,
}),
});
```
### Parsing custom ADR document formats
By default, the `DefaultAdrCollatorFactory` will parse and index documents that follow the [MADR](https://adr.github.io/madr/) standard file name and template format. If you use a different ADR format and file name convention, you can configure `DefaultAdrCollatorFactory` with custom `adrFilePathFilterFn` and `parser` options (see type definitions for details):
```ts
DefaultAdrCollatorFactory.fromConfig({
...
parser: myCustomAdrParser,
adrFilePathFilterFn: myCustomAdrFilePathFilter,
...
})
```
+66
View File
@@ -0,0 +1,66 @@
## API Report File for "@backstage/plugin-adr-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { AdrDocument } from '@backstage/plugin-adr-common';
import { AdrFilePathFilterFn } from '@backstage/plugin-adr-common';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import { Entity } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Readable } from 'stream';
import { TokenManager } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
// @public
export type AdrCollatorFactoryOptions = {
adrFilePathFilterFn?: AdrFilePathFilterFn;
cache: PluginCacheManager;
config: Config;
catalogClient?: CatalogApi;
discovery: PluginEndpointDiscovery;
logger: Logger;
parser?: AdrParser;
reader: UrlReader;
tokenManager: TokenManager;
};
// @public
export type AdrParser = (ctx: AdrParserContext) => Promise<AdrDocument>;
// @public
export type AdrParserContext = {
entity: Entity;
content: string;
path: string;
};
// @public
export const createMadrParser: (options?: MadrParserOptions) => AdrParser;
// @public
export class DefaultAdrCollatorFactory implements DocumentCollatorFactory {
// (undocumented)
execute(): AsyncGenerator<AdrDocument>;
// (undocumented)
static fromConfig(
options: AdrCollatorFactoryOptions,
): DefaultAdrCollatorFactory;
// (undocumented)
getCollator(): Promise<Readable>;
// (undocumented)
readonly type: string;
}
// @public
export type MadrParserOptions = {
locationTemplate?: string;
dateFormat?: string;
};
```
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@backstage/plugin-adr-backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/adr-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "^0.13.3-next.0",
"@backstage/catalog-client": "^1.0.1",
"@backstage/catalog-model": "^1.0.1",
"@backstage/config": "^1.0.0",
"@backstage/errors": "^1.0.0",
"@backstage/integration": "^1.2.0-next.0",
"@backstage/plugin-adr-common": "^0.0.0",
"@backstage/plugin-search-common": "^0.3.3",
"luxon": "^2.0.2",
"marked": "^4.0.14",
"winston": "^3.2.1",
"node-fetch": "^2.6.5",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.1-next.0",
"@types/marked": "^4.0.0",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3",
"msw": "^0.35.0"
},
"files": [
"dist"
]
}
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* ADR backend plugin
*
* @packageDocumentation
*/
export * from './search';
@@ -0,0 +1,212 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Readable } from 'stream';
import { Logger } from 'winston';
import {
CacheClient,
PluginCacheManager,
PluginEndpointDiscovery,
TokenManager,
UrlReader,
} from '@backstage/backend-common';
import {
CatalogApi,
CatalogClient,
CATALOG_FILTER_EXISTS,
} from '@backstage/catalog-client';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { NotModifiedError } from '@backstage/errors';
import {
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import {
AdrDocument,
AdrFilePathFilterFn,
ANNOTATION_ADR_LOCATION,
getAdrLocationUrl,
madrFilePathFilter,
} from '@backstage/plugin-adr-common';
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import { createMadrParser } from './createMadrParser';
import { AdrParser } from './types';
/**
* Options to configure the AdrCollatorFactory
* @public
*/
export type AdrCollatorFactoryOptions = {
/**
* Function used to filter ADR file paths.
* Defaults to filtering paths following MADR filename format.
*/
adrFilePathFilterFn?: AdrFilePathFilterFn;
/**
* Plugin cache manager
*/
cache: PluginCacheManager;
/**
* App Config
*/
config: Config;
/**
* Catalog API client. Defaults to CatalogClient.
*/
catalogClient?: CatalogApi;
/**
* Plugin Endpoint Discovery client
*/
discovery: PluginEndpointDiscovery;
/**
* Logger
*/
logger: Logger;
/**
* ADR content parser. Defaults to built in MADR parser.
*/
parser?: AdrParser;
/**
* URL Reader
*/
reader: UrlReader;
/**
* Token Manager
*/
tokenManager: TokenManager;
};
/**
* Default collator to index ADR documents for Backstage search.
* @public
*/
export class DefaultAdrCollatorFactory implements DocumentCollatorFactory {
public readonly type: string = 'adr';
private readonly adrFilePathFilterFn: AdrFilePathFilterFn;
private readonly cacheClient: CacheClient;
private readonly catalogClient: CatalogApi;
private readonly logger: Logger;
private readonly parser: AdrParser;
private readonly reader: UrlReader;
private readonly scmIntegrations: ScmIntegrationRegistry;
private readonly tokenManager: TokenManager;
private constructor(options: AdrCollatorFactoryOptions) {
this.adrFilePathFilterFn =
options.adrFilePathFilterFn ?? madrFilePathFilter;
this.cacheClient = options.cache.getClient();
this.catalogClient =
options.catalogClient ??
new CatalogClient({ discoveryApi: options.discovery });
this.logger = options.logger;
this.parser = options.parser ?? createMadrParser();
this.reader = options.reader;
this.scmIntegrations = ScmIntegrations.fromConfig(options.config);
this.tokenManager = options.tokenManager;
}
static fromConfig(options: AdrCollatorFactoryOptions) {
return new DefaultAdrCollatorFactory(options);
}
async getCollator() {
return Readable.from(this.execute());
}
async *execute(): AsyncGenerator<AdrDocument> {
const { token } = await this.tokenManager.getToken();
const entities = (
await this.catalogClient.getEntities(
{
filter: {
[`metadata.annotations.${ANNOTATION_ADR_LOCATION}`]:
CATALOG_FILTER_EXISTS,
},
fields: [
'kind',
'metadata.annotations',
'metadata.name',
'metadata.namespace',
],
},
{ token },
)
).items;
for (const ent of entities) {
let adrsUrl: string;
try {
adrsUrl = getAdrLocationUrl(ent, this.scmIntegrations);
} catch (e: any) {
this.logger.error(
`Unable to get ADR location URL for ${stringifyEntityRef(ent)}: ${
e.message
}`,
);
continue;
}
const cacheItem = (await this.cacheClient.get(adrsUrl)) as {
adrFiles: {
path: string;
content: string;
}[];
etag: string;
};
let adrFiles = cacheItem?.adrFiles;
try {
const tree = await this.reader.readTree(adrsUrl, {
etag: cacheItem?.etag,
filter: filePath => this.adrFilePathFilterFn(filePath),
});
adrFiles = await Promise.all(
(
await tree.files()
).map(async f => ({
path: f.path,
content: (await f.content()).toString('utf8'),
})),
);
await this.cacheClient.set(adrsUrl, { adrFiles, etag: tree.etag });
} catch (error: any) {
// Ignore error if we're able to use cached response
if (!cacheItem || error.name !== NotModifiedError.name) {
throw error;
}
}
for (const { content, path } of adrFiles) {
try {
const adrDoc = await this.parser({
entity: ent,
path,
content,
});
yield adrDoc;
} catch (e: any) {
this.logger.error(`Unable to parse ADR ${path}: ${e.message}`);
}
}
this.logger.info(`Indexed ${adrFiles.length} ADRs from ${adrsUrl}`);
}
}
}
@@ -0,0 +1,112 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateTime } from 'luxon';
import { marked } from 'marked';
import { MADR_DATE_FORMAT } from '@backstage/plugin-adr-common';
import { AdrParser } from './types';
const applyArgsToFormat = (
format: string,
args: Record<string, string>,
): string => {
let formatted = format;
for (const [key, value] of Object.entries(args)) {
formatted = formatted.replace(`:${key}`, value);
}
return formatted.toLowerCase();
};
const DEFAULT_LOCATION_TEMPLATE =
'/catalog/:namespace/:kind/:name/adrs?record=:record';
/**
*
* Options for the default MADR content parser
* @public
*/
export type MadrParserOptions = {
/**
* Location template for the route of the frontend plugin
* Defaults to '/catalog/:namespace/:kind/:name/adrs?record=:record'
*/
locationTemplate?: string;
/**
* luxon DateTime format string to parse ADR dates with.
* Defaults to 'yyyy-MM-dd'
*/
dateFormat?: string;
};
/**
* Default content parser for ADRs following the MADR template (https://adr.github.io/madr/)
* @public
*/
export const createMadrParser = (
options: MadrParserOptions = {},
): AdrParser => {
const locationTemplate =
options.locationTemplate ?? DEFAULT_LOCATION_TEMPLATE;
const dateFormat = options.dateFormat ?? MADR_DATE_FORMAT;
return async ({ entity, content, path }) => {
const tokens = marked.lexer(content);
if (!tokens.length) {
throw new Error('ADR has no content');
}
// First h1 header should contain ADR title
const adrTitle = (
tokens.find(
t => t.type === 'heading' && t.depth === 1,
) as marked.Tokens.Heading
)?.text;
// First list should contain status & date metadata (if defined)
const listTokens = (
tokens.find(t => t.type === 'list') as marked.Tokens.List
)?.items;
const adrStatus = listTokens
.find(t => /^status:/i.test(t.text))
?.text.replace(/^status:/i, '')
.trim()
.toLocaleLowerCase('en-US');
const adrDateTime = DateTime.fromFormat(
listTokens
.find(t => /^date:/i.test(t.text))
?.text.replace(/^date:/i, '')
.trim() ?? '',
dateFormat,
);
const adrDate = adrDateTime.isValid
? adrDateTime.toFormat(MADR_DATE_FORMAT)
: undefined;
return {
title: adrTitle ?? path.replace(/\.md$/, ''),
text: content,
status: adrStatus,
date: adrDate,
location: applyArgsToFormat(locationTemplate, {
namespace: entity.metadata.namespace || 'default',
kind: entity.kind,
name: entity.metadata.name,
record: path,
}),
};
};
};
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './DefaultAdrCollatorFactory';
export * from './createMadrParser';
export * from './types';
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { AdrDocument } from '@backstage/plugin-adr-common';
/**
* Context passed to a AdrParser.
* @public
*/
export type AdrParserContext = {
/**
* The entity associated with the ADR.
*/
entity: Entity;
/**
* The ADR content string.
*/
content: string;
/**
* The ADR file path.
*/
path: string;
};
/**
* ADR parser function type.
* @public
*/
export type AdrParser = (ctx: AdrParserContext) => Promise<AdrDocument>;
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+3
View File
@@ -0,0 +1,3 @@
# ADR Common
Common types and functionalities for the ADR plugin.
+36
View File
@@ -0,0 +1,36 @@
## API Report File for "@backstage/plugin-adr-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Entity } from '@backstage/catalog-model';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { ScmIntegrationRegistry } from '@backstage/integration';
// @public
export interface AdrDocument extends IndexableDocument {
date?: string;
status?: string;
}
// @public
export type AdrFilePathFilterFn = (path: string) => boolean;
// @public
export const ANNOTATION_ADR_LOCATION = 'backstage.io/adr-location';
// @public
export const getAdrLocationUrl: (
entity: Entity,
scmIntegration: ScmIntegrationRegistry,
) => string;
// @public
export const isAdrAvailable: (entity: Entity) => boolean;
// @public
export const MADR_DATE_FORMAT = 'yyyy-MM-dd';
// @public
export const madrFilePathFilter: AdrFilePathFilterFn;
```
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@backstage/plugin-adr-common",
"description": "Common functionalities for the adr plugin",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/adr-common"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/catalog-model": "^1.0.1",
"@backstage/integration": "^1.2.0-next.0",
"@backstage/plugin-search-common": "^0.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.17.1-next.0"
},
"files": [
"dist"
]
}
+95
View File
@@ -0,0 +1,95 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Common types and functionalities for the ADR plugin.
* @packageDocumentation
*/
import { Entity, getEntitySourceLocation } from '@backstage/catalog-model';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { ScmIntegrationRegistry } from '@backstage/integration';
/**
* ADR plugin annotation.
* @public
*/
export const ANNOTATION_ADR_LOCATION = 'backstage.io/adr-location';
/**
* Standard luxon DateTime format string for MADR dates.
* @public
*/
export const MADR_DATE_FORMAT = 'yyyy-MM-dd';
/**
* Utility function to get the value of an entity ADR annotation.
* @public
*/
const getAdrLocationDir = (entity: Entity) =>
entity.metadata.annotations?.[ANNOTATION_ADR_LOCATION]?.trim();
/**
* Utility function to determine if the given entity has ADRs.
* @public
*/
export const isAdrAvailable = (entity: Entity) =>
Boolean(getAdrLocationDir(entity));
/**
* Utility function to extract the ADR location URL from an entity based off
* its ADR annotation and relative to the entity source location.
* @public
*/
export const getAdrLocationUrl = (
entity: Entity,
scmIntegration: ScmIntegrationRegistry,
) => {
if (!isAdrAvailable(entity)) {
throw new Error(`Missing ADR annotation: ${ANNOTATION_ADR_LOCATION}`);
}
return scmIntegration.resolveUrl({
url: getAdrLocationDir(entity)!,
base: getEntitySourceLocation(entity).target,
});
};
/**
* File path filter function type for ADR filenames
* @public
*/
export type AdrFilePathFilterFn = (path: string) => boolean;
/**
* File path filter for MADR filename formats
* @public
*/
export const madrFilePathFilter: AdrFilePathFilterFn = (path: string) =>
/^\d{4}-.+\.md$/.test(path);
/**
* ADR indexable document interface
* @public
*/
export interface AdrDocument extends IndexableDocument {
/**
* ADR status label
*/
status?: string;
/**
* ADR date
*/
date?: string;
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+126
View File
@@ -0,0 +1,126 @@
# Architecture Decision Records (ADR) Plugin
Welcome to the ADR plugin!
This plugin allows you to browse ADRs associated with your entities as well as a way to discover ADRs across others entities via Backstage Search. Use this to learn from the past experience of other projects to guide your own architecture decisions.
NOTE: This plugin currently only supports entities/ADRs registered via GitHub integration.
## Setup
Install this plugin:
```bash
# From your Backstage root directory
yarn --cwd packages/app add @backstage/plugin-adr
```
### Entity Pages
1. Add the plugin as a tab to your Entity pages:
```jsx
// In packages/app/src/components/catalog/EntityPage.tsx
import { EntityAdrContent, isAdrAvailable } from '@backstage/plugin-adr';
...
const serviceEntityPage = (
<EntityLayout>
{/* other tabs... */}
<EntityLayout.Route if={isAdrAvailable} path="/adrs" title="ADRs">
<EntityAdrContent />
</EntityLayout.Route>
</EntityLayout>
```
2. Add `backstage.io/adr-location` annotation to your `catalog-info.yaml`:
```yaml
metadata:
annotations:
backstage.io/adr-location: <RELATIVE_PATH_TO_ADR_FILES_DIR>
```
The value for `backstage.io/adr-location` should be a path relative to your `catalog-info.yaml` file or a absolute URL to the directory which contains your ADR markdown files.
For example, if you have the following directory structure, you would set `backstage.io/adr-location: docs/adrs`:
```
repo-root/
README.md
src/
catalog-info.yaml
docs/
adrs/
0001-use-adrs.md
0002-use-cloud.md
```
### Search
First, make sure to setup Backstage Search with the [ADR backend plugin](../adr-backend/README.md).
Afterwards, add the following code snippet to use `AdrSearchResultListItem` when the type of the search results is `adr`:
```tsx
// In packages/app/src/components/search/SearchPage.tsx
import { AdrSearchResultListItem } from '@backstage/plugin-adr';
...
case 'adr':
return (
<AdrSearchResultListItem
key={document.location}
result={document}
/>
);
```
## Custom ADR formats
By default, this plugin will parse ADRs according to the format specified by the [Markdown Architecture Decision Record (MADR)](https://adr.github.io/madr/) template. 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
In order to ensure the plugin fetches the correct ADR files (e.g. ignoring your template file), you can pass in an optional `filePathFilterFn` parameter to `EntityAdrContent` which will be called with each file path relative to the ADR location specified by `backstage.io/adr-location`. For example, the follow custom filter function will ignore the ADR template file and include files with a specific naming convention including those under a specified sub-directory:
```tsx
const myCustomFilterFn: AdrFilePathFilterFn = (path: string): boolean => {
if (path === '0000-adr-template.md') {
return false;
}
// Match all files following the pattern NNNN-title-with-dashes.md including those under decided-adrs/
return /^(decided-adrs\/)?\d{4}-.+\.md$/.test(path);
}
...
<EntityAdrContent filePathFilterFn={myCustomFilterFn} />
```
### 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` => `<ABSOLUTE_ADR_DIR_URL>/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:
```tsx
import {
AdrReader,
...
} from '@backstage/plugin-adr';
...
const myCustomDecorator: AdrContentDecorator = ({ content }) => {
return { content: applyCustomContentTransformation(content) };
};
...
<EntityAdrContent contentDecorators={[
AdrReader.decorators.createRewriteRelativeLinksDecorator(),
AdrReader.decorators.createRewriteRelativeEmbedsDecorator(),
myCustomDecorator,
]}
/>
```
+64
View File
@@ -0,0 +1,64 @@
## API Report File for "@backstage/plugin-adr"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { AdrDocument } from '@backstage/plugin-adr-common';
import { AdrFilePathFilterFn } from '@backstage/plugin-adr-common';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { isAdrAvailable } from '@backstage/plugin-adr-common';
import { RouteRef } from '@backstage/core-plugin-api';
// @public
export type AdrContentDecorator = (adrInfo: {
baseUrl: string;
content: string;
}) => {
content: string;
};
// @public
export const adrPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// @public
export const AdrReader: {
({
adr,
decorators,
}: {
adr: string;
decorators?: AdrContentDecorator[] | undefined;
}): JSX.Element;
decorators: Readonly<{
createRewriteRelativeLinksDecorator(): AdrContentDecorator;
createRewriteRelativeEmbedsDecorator(): AdrContentDecorator;
}>;
};
// @public
export const AdrSearchResultListItem: ({
lineClamp,
result,
}: {
lineClamp?: number | undefined;
result: AdrDocument;
}) => JSX.Element;
// @public
export const EntityAdrContent: ({
contentDecorators,
filePathFilterFn,
}: {
contentDecorators?: AdrContentDecorator[] | undefined;
filePathFilterFn?: AdrFilePathFilterFn | undefined;
}) => JSX.Element;
export { isAdrAvailable };
```
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@backstage/plugin-adr",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/core-components": "^0.9.3",
"@backstage/core-plugin-api": "^1.0.1",
"@backstage/integration-react": "^1.1.0-next.0",
"@backstage/plugin-adr-common": "^0.0.0",
"@backstage/plugin-catalog-react": "^1.1.0-next.0",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"git-url-parse": "^11.6.0",
"octokit": "^1.7.1",
"react-markdown": "^8.0.0",
"react-router-dom": "6.0.0-beta.0",
"react-text-truncate": "^0.18.0",
"react-use": "^17.2.4",
"remark-gfm": "^3.0.1"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.1-next.0",
"@backstage/core-app-api": "^1.0.1",
"@backstage/dev-utils": "^1.0.2-next.0",
"@backstage/test-utils": "^1.0.2-next.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/git-url-parse": "^9.0.0",
"@types/jest": "*",
"@types/node": "*",
"cross-fetch": "^3.1.5",
"msw": "^0.35.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,83 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useMemo } from 'react';
import {
InfoCard,
MarkdownContent,
Progress,
WarningPanel,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { getAdrLocationUrl } from '@backstage/plugin-adr-common';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useOctokitRequest } from '../../hooks';
import { adrDecoratorFactories } from './decorators';
import { AdrContentDecorator } from './types';
/**
* Component to fetch and render an ADR.
* @public
*/
export const AdrReader = ({
adr,
decorators,
}: {
adr: string;
decorators?: AdrContentDecorator[];
}) => {
const { entity } = useEntity();
const scmIntegrations = useApi(scmIntegrationsApiRef);
const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations);
const { value, loading, error } = useOctokitRequest(
`${adrLocationUrl}/${adr}`,
);
const adrContent = useMemo(() => {
if (!value?.data) {
return '';
}
const adrDecorators = decorators ?? [
adrDecoratorFactories.createRewriteRelativeLinksDecorator(),
adrDecoratorFactories.createRewriteRelativeEmbedsDecorator(),
];
return adrDecorators.reduce(
(content, decorator) =>
decorator({ baseUrl: adrLocationUrl, content }).content,
value.data,
);
}, [adrLocationUrl, decorators, value]);
return (
<InfoCard>
{loading && <Progress />}
{!loading && error && (
<WarningPanel title="Failed to fetch ADR" message={error?.message} />
)}
{!loading && !error && value?.data && (
<MarkdownContent content={adrContent} linkTarget="_blank" />
)}
</InfoCard>
);
};
AdrReader.decorators = adrDecoratorFactories;
@@ -0,0 +1,47 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AdrContentDecorator } from './types';
/**
*
* Factory for creating default ADR content decorators. The adrDecoratorFactories
* symbol is not directly exported, but through the AdrReader.decorators field.
* @public
*/
export const adrDecoratorFactories = Object.freeze({
/**
* Rewrites relative Markdown links as absolute links.
*/
createRewriteRelativeLinksDecorator(): AdrContentDecorator {
return ({ baseUrl, content }) => ({
content: content.replace(
/\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.md)\)/gim,
`[$1](${baseUrl}/$2$3)`,
),
});
},
/**
* Rewrites relative Markdown embeds using absolute URLs.
*/
createRewriteRelativeEmbedsDecorator(): AdrContentDecorator {
return ({ baseUrl, content }) => ({
content: content.replace(
/!\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.png|\.jpg|\.jpeg|\.gif|\.webp)(.*)\)/gim,
`![$1](${baseUrl}/$2$3$4)`,
),
});
},
});
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './AdrReader';
export * from './types';
@@ -0,0 +1,25 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* ADR content decorator function type. Decorators are responsible for
* performing any necessary transformations on the ADR content before rendering.
* @public
*/
export type AdrContentDecorator = (adrInfo: {
baseUrl: string;
content: string;
}) => { content: string };
@@ -0,0 +1,155 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Content,
ContentHeader,
MissingAnnotationEmptyState,
Progress,
SupportButton,
WarningPanel,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import {
AdrFilePathFilterFn,
ANNOTATION_ADR_LOCATION,
getAdrLocationUrl,
isAdrAvailable,
madrFilePathFilter,
} from '@backstage/plugin-adr-common';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Grid,
List,
ListItem,
ListItemText,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import { useOctokitRequest } from '../../hooks';
import { rootRouteRef } from '../../routes';
import { AdrContentDecorator, AdrReader } from '../AdrReader';
const useStyles = makeStyles((theme: Theme) => ({
adrMenu: {
backgroundColor: theme.palette.background.paper,
},
}));
/**
* Component for browsing ADRs on an entity page.
* @public
*/
export const EntityAdrContent = ({
contentDecorators,
filePathFilterFn,
}: {
contentDecorators?: AdrContentDecorator[];
filePathFilterFn?: AdrFilePathFilterFn;
}) => {
const classes = useStyles();
const { entity } = useEntity();
const rootLink = useRouteRef(rootRouteRef);
const [adrList, setAdrList] = useState<string[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const scmIntegrations = useApi(scmIntegrationsApiRef);
const entityHasAdrs = isAdrAvailable(entity);
const { value, loading, error } = useOctokitRequest(
getAdrLocationUrl(entity, scmIntegrations),
);
const selectedAdr =
adrList.find(adr => adr === searchParams.get('record')) ?? '';
useEffect(() => {
if (adrList.length && !selectedAdr) {
searchParams.set('record', adrList[0]);
setSearchParams(searchParams, { replace: true });
}
});
useEffect(() => {
if (!value?.data) {
return;
}
const adrs = value.data
.filter(
(item: { type: string; name: string }) =>
item.type === 'file' &&
(filePathFilterFn
? filePathFilterFn(item.name)
: madrFilePathFilter(item.name)),
)
.map(({ name }: { name: string }) => name);
setAdrList(adrs);
}, [filePathFilterFn, value]);
return (
<Content>
<ContentHeader title="Architecture Decision Records">
<SupportButton />
</ContentHeader>
{!entityHasAdrs && (
<MissingAnnotationEmptyState annotation={ANNOTATION_ADR_LOCATION} />
)}
{loading && <Progress />}
{entityHasAdrs && !loading && error && (
<WarningPanel title="Failed to fetch ADRs" message={error?.message} />
)}
{entityHasAdrs &&
!loading &&
!error &&
(adrList.length ? (
<Grid container direction="row">
<Grid item xs={3}>
<List className={classes.adrMenu} dense>
{adrList.map((adr, idx) => (
<ListItem
key={idx}
button
component={Link}
to={`${rootLink()}?record=${adr}`}
selected={selectedAdr === adr}
>
<ListItemText
secondaryTypographyProps={{ noWrap: true }}
secondary={adr.replace(/\.md$/, '')}
/>
</ListItem>
))}
</List>
</Grid>
<Grid item xs={9}>
<AdrReader adr={selectedAdr} decorators={contentDecorators} />
</Grid>
</Grid>
) : (
<Typography>No ADRs found</Typography>
))}
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityAdrContent } from './EntityAdrContent';
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './useOctokitRequest';
@@ -0,0 +1,59 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import useAsync from 'react-use/lib/useAsync';
import { Octokit } from 'octokit';
import { useApi } from '@backstage/core-plugin-api';
import {
scmAuthApiRef,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
/**
* Hook for triggering authenticated Octokit requests against the GitHub Content API
* @public
*/
export const useOctokitRequest = (request: string): any => {
const authApi = useApi(scmAuthApiRef);
const scmIntegrations = useApi(scmIntegrationsApiRef);
const { owner, name, ref, filepath } = parseGitUrl(request);
const path = filepath.replace(/^\//, '');
const baseUrl = scmIntegrations.github.byUrl(request)?.config.apiBaseUrl;
return useAsync<any>(async () => {
const { token } = await authApi.getCredentials({
url: request,
additionalScope: {
customScopes: {
github: ['repo'],
},
},
});
const octokit = new Octokit({
auth: token,
baseUrl,
});
return octokit.request(
`GET /repos/${owner}/${name}/contents/${path}?ref=${ref}`,
{
headers: { Accept: 'application/vnd.github.v3.raw' },
},
);
}, [request]);
};
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* ADR frontend plugin
*
* @packageDocumentation
*/
export { isAdrAvailable } from '@backstage/plugin-adr-common';
export * from './components/AdrReader';
export { adrPlugin, EntityAdrContent } from './plugin';
export * from './search';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { adrPlugin } from './plugin';
describe('adr', () => {
it('should export plugin', () => {
expect(adrPlugin).toBeDefined();
});
});
+46
View File
@@ -0,0 +1,46 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
/**
* The Backstage plugin that holds ADR specific components
* @public
*/
export const adrPlugin = createPlugin({
id: 'adr',
routes: {
root: rootRouteRef,
},
});
/**
* An extension for browsing ADRs on an entity page.
* @public
*/
export const EntityAdrContent = adrPlugin.provide(
createRoutableExtension({
name: 'EntityAdrContent',
component: () =>
import('./components/EntityAdrContent').then(m => m.EntityAdrContent),
mountPoint: rootRouteRef,
}),
);
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
/**
* Root route ref for the ADR plugin
* @public
*/
export const rootRouteRef = createRouteRef({
id: 'adr',
});
@@ -0,0 +1,80 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import TextTruncate from 'react-text-truncate';
import {
Box,
Chip,
Divider,
ListItem,
ListItemText,
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { AdrDocument } from '@backstage/plugin-adr-common';
const useStyles = makeStyles({
flexContainer: {
flexWrap: 'wrap',
},
itemText: {
width: '100%',
wordBreak: 'break-all',
marginBottom: '1rem',
},
});
/**
* A component to display a ADR search result
* @public
*/
export const AdrSearchResultListItem = ({
lineClamp = 5,
result,
}: {
lineClamp?: number;
result: AdrDocument;
}) => {
const classes = useStyles();
return (
<Link to={result.location}>
<ListItem alignItems="flex-start" className={classes.flexContainer}>
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={result.title}
secondary={
<TextTruncate
line={lineClamp}
truncateText="…"
text={result.text}
element="span"
/>
}
/>
<Box>
{result.status && (
<Chip label={`Status: ${result.status}`} size="small" />
)}
{result.date && <Chip label={`Date: ${result.date}`} size="small" />}
</Box>
</ListItem>
<Divider component="li" />
</Link>
);
};
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './AdrSearchResultListItem';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+6 -1
View File
@@ -6301,6 +6301,11 @@
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.1.tgz#e34763178b46232e4c5f079f1706e18692415519"
integrity sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ==
"@types/marked@^4.0.0":
version "4.0.3"
resolved "https://registry.npmjs.org/@types/marked/-/marked-4.0.3.tgz#2098f4a77adaba9ce881c9e0b6baf29116e5acc4"
integrity sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg==
"@types/mdast@^3.0.0", "@types/mdast@^3.0.3":
version "3.0.10"
resolved "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz#4724244a82a4598884cbbe9bcfd73dff927ee8af"
@@ -18467,7 +18472,7 @@ node-fetch@2.6.1:
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7:
node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.5, node-fetch@^2.6.7:
version "2.6.7"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==