Migrate 7 JavaScript files to TypeScript (#57816)
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
import { renderLiquid } from './liquid/index'
|
||||
import { renderMarkdown, renderUnified } from './unified/index'
|
||||
import { engine } from './liquid/engine'
|
||||
import type { Context } from '@/types'
|
||||
|
||||
const globalCache = new Map()
|
||||
interface RenderOptions {
|
||||
cache?: boolean | ((template: string, context: Context) => string)
|
||||
filename?: string
|
||||
textOnly?: boolean
|
||||
}
|
||||
|
||||
const globalCache = new Map<string, string>()
|
||||
|
||||
// parse multiple times because some templates contain more templates. :]
|
||||
export async function renderContent(template = '', context = {}, options = {}) {
|
||||
export async function renderContent(
|
||||
template = '',
|
||||
context: Context = {} as Context,
|
||||
options: RenderOptions = {},
|
||||
): Promise<string> {
|
||||
// If called with a falsy template, it can't ever become something
|
||||
// when rendered. We can exit early to save some pointless work.
|
||||
if (!template) return template
|
||||
let cacheKey = null
|
||||
let cacheKey: string | null = null
|
||||
if (options && options.cache) {
|
||||
if (!context) throw new Error("If setting 'cache' in options, the 'context' must be set too")
|
||||
if (typeof options.cache === 'function') {
|
||||
@@ -21,13 +32,13 @@ export async function renderContent(template = '', context = {}, options = {}) {
|
||||
throw new Error('cache option must return a string if truthy')
|
||||
}
|
||||
if (globalCache.has(cacheKey)) {
|
||||
return globalCache.get(cacheKey)
|
||||
return globalCache.get(cacheKey) as string
|
||||
}
|
||||
}
|
||||
try {
|
||||
template = await renderLiquid(template, context)
|
||||
if (context.markdownRequested) {
|
||||
const md = await renderMarkdown(template, context, options)
|
||||
const md = await renderMarkdown(template, context)
|
||||
|
||||
return md
|
||||
}
|
||||
@@ -45,7 +56,7 @@ export async function renderContent(template = '', context = {}, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultCacheKey(template, context) {
|
||||
function getDefaultCacheKey(template: string, context: Context): string {
|
||||
return `${template}:${context.currentVersion}:${context.currentLanguage}`
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Liquid } from 'liquidjs'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import IndentedDataReference from './indented-data-reference'
|
||||
import Data from './data'
|
||||
import Octicon from './octicon'
|
||||
import Ifversion from './ifversion'
|
||||
import { Tool, tags as toolTags } from './tool'
|
||||
import { Spotlight, tags as spotlightTags } from './spotlight'
|
||||
import { Prompt } from './prompt'
|
||||
|
||||
export const engine = new Liquid({
|
||||
extname: '.html',
|
||||
dynamicPartials: false,
|
||||
})
|
||||
|
||||
engine.registerTag('indented_data_reference', IndentedDataReference)
|
||||
engine.registerTag('data', Data)
|
||||
engine.registerTag('octicon', Octicon)
|
||||
engine.registerTag('ifversion', Ifversion)
|
||||
|
||||
for (const tag of toolTags) {
|
||||
engine.registerTag(tag, Tool)
|
||||
}
|
||||
|
||||
for (const tag in spotlightTags) {
|
||||
engine.registerTag(tag, Spotlight)
|
||||
}
|
||||
|
||||
engine.registerTag('prompt', Prompt)
|
||||
|
||||
/**
|
||||
* Like the `size` filter, but specifically for
|
||||
* getting the number of keys in an object
|
||||
*/
|
||||
engine.registerFilter('obj_size', (input) => {
|
||||
if (!input) return 0
|
||||
return Object.keys(input).length
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns the version number of a GHES version string
|
||||
* ex: enterprise-server@2.22 => 2.22
|
||||
*/
|
||||
engine.registerFilter('version_num', (input) => {
|
||||
return input.split('@')[1]
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert the input to a slug
|
||||
*/
|
||||
engine.registerFilter('slugify', (input) => {
|
||||
const slugger = new GithubSlugger()
|
||||
return slugger.slug(input)
|
||||
})
|
||||
66
src/content-render/liquid/engine.ts
Normal file
66
src/content-render/liquid/engine.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Liquid } from 'liquidjs'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
// Copilot: When converting data.js to TypeScript, remove the 'as any' assertion for anyData below
|
||||
import Data from './data'
|
||||
import Octicon from './octicon'
|
||||
// Copilot: When converting ifversion.js to TypeScript, remove the 'as any' assertion for anyIfversion below
|
||||
import Ifversion from './ifversion'
|
||||
// Copilot: When converting tool.js to TypeScript, remove the 'as any' assertion for anyTool below
|
||||
import { Tool, tags as toolTags } from './tool'
|
||||
import { Spotlight, tags as spotlightTags } from './spotlight'
|
||||
import { Prompt } from './prompt'
|
||||
import IndentedDataReference from './indented-data-reference'
|
||||
|
||||
// Type assertions for .js files without type definitions
|
||||
// Copilot: Remove these assertions when the corresponding .js files are converted to TypeScript
|
||||
const anyData = Data as any
|
||||
const anyIfversion = Ifversion as any
|
||||
const anyTool = Tool as any
|
||||
const anySpotlight = Spotlight as any
|
||||
const anyPrompt = Prompt as any
|
||||
const anyIndentedDataReference = IndentedDataReference as any
|
||||
|
||||
export const engine = new Liquid({
|
||||
extname: '.html',
|
||||
dynamicPartials: false,
|
||||
})
|
||||
|
||||
engine.registerTag('indented_data_reference', anyIndentedDataReference)
|
||||
engine.registerTag('data', anyData)
|
||||
engine.registerTag('octicon', Octicon)
|
||||
engine.registerTag('ifversion', anyIfversion)
|
||||
|
||||
for (const tag of toolTags) {
|
||||
engine.registerTag(tag, anyTool)
|
||||
}
|
||||
|
||||
for (const tag in spotlightTags) {
|
||||
engine.registerTag(tag, anySpotlight)
|
||||
}
|
||||
|
||||
engine.registerTag('prompt', anyPrompt)
|
||||
|
||||
/**
|
||||
* Like the `size` filter, but specifically for
|
||||
* getting the number of keys in an object
|
||||
*/
|
||||
engine.registerFilter('obj_size', (input: Record<string, unknown> | null | undefined): number => {
|
||||
if (!input) return 0
|
||||
return Object.keys(input).length
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns the version number of a GHES version string
|
||||
* ex: enterprise-server@2.22 => 2.22
|
||||
*/
|
||||
engine.registerFilter('version_num', (input: string): string => {
|
||||
return input.split('@')[1]
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert the input to a slug
|
||||
*/
|
||||
engine.registerFilter('slugify', (input: string): string => {
|
||||
const slugger = new GithubSlugger()
|
||||
return slugger.slug(input)
|
||||
})
|
||||
@@ -3,6 +3,21 @@ import assert from 'assert'
|
||||
import { THROW_ON_EMPTY, IndentedDataReferenceError } from './error-handling'
|
||||
import { getDataByLanguage } from '@/data-directory/lib/get-data'
|
||||
|
||||
// Note: Using 'any' for liquidjs-related types because liquidjs doesn't provide comprehensive TypeScript definitions
|
||||
interface LiquidTag {
|
||||
markup: string
|
||||
liquid: any
|
||||
parse(tagToken: any): void
|
||||
render(scope: any): Promise<string | undefined>
|
||||
}
|
||||
|
||||
interface LiquidScope {
|
||||
environments: {
|
||||
currentLanguage: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
// This class supports a tag that expects two parameters, a data reference and `spaces=NUMBER`:
|
||||
//
|
||||
// {% indented_data_reference foo.bar spaces=NUMBER %}
|
||||
@@ -13,12 +28,15 @@ import { getDataByLanguage } from '@/data-directory/lib/get-data'
|
||||
// reference is used inside a block element (like a list or nested list) without
|
||||
// affecting the formatting when the reference is used elsewhere via {{ site.data.foo.bar }}.
|
||||
|
||||
export default {
|
||||
parse(tagToken) {
|
||||
const IndentedDataReference: LiquidTag = {
|
||||
markup: '',
|
||||
liquid: null as any,
|
||||
|
||||
parse(tagToken: any): void {
|
||||
this.markup = tagToken.args.trim()
|
||||
},
|
||||
|
||||
async render(scope) {
|
||||
async render(scope: LiquidScope): Promise<string | undefined> {
|
||||
// obfuscate first legit space, remove all other spaces, then restore legit space
|
||||
// this way we can support spaces=NUMBER as well as spaces = NUMBER
|
||||
const input = this.markup
|
||||
@@ -29,12 +47,15 @@ export default {
|
||||
const [dataReference, spaces] = input.split(' ')
|
||||
|
||||
// if no spaces are specified, default to 2
|
||||
const numSpaces = spaces ? spaces.replace(/spaces=/, '') : '2'
|
||||
const numSpaces: string = spaces ? spaces.replace(/spaces=/, '') : '2'
|
||||
|
||||
assert(parseInt(numSpaces) || numSpaces === '0', '"spaces=NUMBER" must include a number')
|
||||
|
||||
// Get the referenced value from the context
|
||||
const text = getDataByLanguage(dataReference, scope.environments.currentLanguage)
|
||||
const text: string | undefined = getDataByLanguage(
|
||||
dataReference,
|
||||
scope.environments.currentLanguage,
|
||||
)
|
||||
if (text === undefined) {
|
||||
if (scope.environments.currentLanguage === 'en') {
|
||||
const message = `Can't find the key 'indented_data_reference ${dataReference}' in the scope.`
|
||||
@@ -47,8 +68,10 @@ export default {
|
||||
}
|
||||
|
||||
// add spaces to each line
|
||||
const renderedReferenceWithIndent = text.replace(/^/gm, ' '.repeat(numSpaces))
|
||||
const renderedReferenceWithIndent: string = text.replace(/^/gm, ' '.repeat(parseInt(numSpaces)))
|
||||
|
||||
return this.liquid.parseAndRender(renderedReferenceWithIndent, scope.environments)
|
||||
},
|
||||
}
|
||||
|
||||
export default IndentedDataReference
|
||||
@@ -1,6 +1,22 @@
|
||||
import { TokenizationError } from 'liquidjs'
|
||||
// @ts-ignore - @primer/octicons doesn't provide TypeScript declarations
|
||||
import octicons from '@primer/octicons'
|
||||
|
||||
// Note: Using 'any' for liquidjs-related types because liquidjs doesn't provide comprehensive TypeScript definitions
|
||||
interface LiquidTag {
|
||||
icon: string
|
||||
options: Record<string, string>
|
||||
parse(tagToken: any): void
|
||||
render(): Promise<string>
|
||||
}
|
||||
|
||||
interface OcticonsMatch {
|
||||
groups: {
|
||||
icon: string
|
||||
options?: string
|
||||
}
|
||||
}
|
||||
|
||||
const OptionsSyntax = /([a-zA-Z-]+)="([\w\s-]+)"*/g
|
||||
const Syntax = new RegExp('"(?<icon>[a-zA-Z-]+)"(?<options>(?:\\s' + OptionsSyntax.source + ')*)')
|
||||
const SyntaxHelp = 'Syntax Error in tag \'octicon\' - Valid syntax: octicon "<name>" <key="value">'
|
||||
@@ -12,9 +28,12 @@ const SyntaxHelp = 'Syntax Error in tag \'octicon\' - Valid syntax: octicon "<na
|
||||
* {% octicon "check" %}
|
||||
* {% octicon "check" width="64" aria-label="Example label" %}
|
||||
*/
|
||||
export default {
|
||||
parse(tagToken) {
|
||||
const match = tagToken.args.match(Syntax)
|
||||
const Octicon: LiquidTag = {
|
||||
icon: '',
|
||||
options: {},
|
||||
|
||||
parse(tagToken: any): void {
|
||||
const match: OcticonsMatch | null = tagToken.args.match(Syntax)
|
||||
if (!match) {
|
||||
throw new TokenizationError(SyntaxHelp, tagToken)
|
||||
}
|
||||
@@ -32,7 +51,7 @@ export default {
|
||||
|
||||
// Memoize any options passed
|
||||
if (match.groups.options) {
|
||||
let optionsMatch
|
||||
let optionsMatch: RegExpExecArray | null
|
||||
|
||||
// Loop through each option matching the OptionsSyntax regex
|
||||
while ((optionsMatch = OptionsSyntax.exec(match.groups.options))) {
|
||||
@@ -46,13 +65,15 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async render() {
|
||||
async render(): Promise<string> {
|
||||
// Throw an error if the requested octicon does not exist.
|
||||
if (!Object.prototype.hasOwnProperty.call(octicons, this.icon)) {
|
||||
throw new Error(`Octicon ${this.icon} does not exist`)
|
||||
}
|
||||
|
||||
const result = octicons[this.icon].toSVG(this.options)
|
||||
const result: string = octicons[this.icon].toSVG(this.options)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
export default Octicon
|
||||
@@ -5,18 +5,31 @@ import { readCompressedJsonFileFallback } from '@/frame/lib/read-json-file'
|
||||
import { getOpenApiVersion } from '@/versions/lib/all-versions'
|
||||
import { categoriesWithoutSubcategories } from '../../rest/lib/index'
|
||||
|
||||
interface AppsConfig {
|
||||
pages: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Note: Using 'any' for AppsData to maintain compatibility with existing consumers that expect different shapes
|
||||
type AppsData = any
|
||||
|
||||
const ENABLED_APPS_DIR = 'src/github-apps/data'
|
||||
const githubAppsData = new Map()
|
||||
const githubAppsData = new Map<string, Map<string, AppsData>>()
|
||||
|
||||
// Initialize the Map with the page type keys listed under `pages`
|
||||
// in the config.json file.
|
||||
const appsDataConfig = JSON.parse(fs.readFileSync('src/github-apps/lib/config.json', 'utf8'))
|
||||
const appsDataConfig: AppsConfig = JSON.parse(
|
||||
fs.readFileSync('src/github-apps/lib/config.json', 'utf8'),
|
||||
)
|
||||
for (const pageType of Object.keys(appsDataConfig.pages)) {
|
||||
githubAppsData.set(pageType, new Map())
|
||||
githubAppsData.set(pageType, new Map<string, AppsData>())
|
||||
}
|
||||
|
||||
export async function getAppsData(pageType, docsVersion, apiVersion) {
|
||||
const pageTypeMap = githubAppsData.get(pageType)
|
||||
export async function getAppsData(
|
||||
pageType: string,
|
||||
docsVersion: string,
|
||||
apiVersion?: string,
|
||||
): Promise<AppsData> {
|
||||
const pageTypeMap = githubAppsData.get(pageType)!
|
||||
const filename = `${pageType}.json`
|
||||
const openApiVersion = getOpenApiVersion(docsVersion) + (apiVersion ? `-${apiVersion}` : '')
|
||||
if (!pageTypeMap.has(openApiVersion)) {
|
||||
@@ -27,26 +40,34 @@ export async function getAppsData(pageType, docsVersion, apiVersion) {
|
||||
pageTypeMap.set(openApiVersion, data)
|
||||
}
|
||||
|
||||
return pageTypeMap.get(openApiVersion)
|
||||
return pageTypeMap.get(openApiVersion)!
|
||||
}
|
||||
|
||||
export async function getAppsServerSideProps(context, pageType, { useDisplayTitle = false }) {
|
||||
export async function getAppsServerSideProps(
|
||||
context: any,
|
||||
pageType: string,
|
||||
{ useDisplayTitle = false }: { useDisplayTitle?: boolean },
|
||||
): Promise<{
|
||||
currentVersion: string
|
||||
appsItems: AppsData
|
||||
categoriesWithoutSubcategories: string[]
|
||||
}> {
|
||||
const { getAutomatedPageMiniTocItems } = await import('@/frame/lib/get-mini-toc-items')
|
||||
const { getAutomatedPageContextFromRequest } = await import(
|
||||
'@/automated-pipelines/components/AutomatedPageContext'
|
||||
)
|
||||
const currentVersion = context.query.versionId
|
||||
const currentVersion: string = context.query.versionId
|
||||
const allVersions = context.req.context.allVersions
|
||||
const queryApiVersion = context.query.apiVersion
|
||||
const apiVersion = allVersions[currentVersion].apiVersions.includes(queryApiVersion)
|
||||
const queryApiVersion: string = context.query.apiVersion
|
||||
const apiVersion: string = allVersions[currentVersion].apiVersions.includes(queryApiVersion)
|
||||
? queryApiVersion
|
||||
: allVersions[currentVersion].latestApiVersion
|
||||
|
||||
const appsItems = await getAppsData(pageType, currentVersion, apiVersion)
|
||||
const appsItems: AppsData = await getAppsData(pageType, currentVersion, apiVersion)
|
||||
// Create minitoc
|
||||
const { miniTocItems } = getAutomatedPageContextFromRequest(context.req)
|
||||
const titles = useDisplayTitle
|
||||
? Object.values(appsItems).map((item) => item.displayTitle)
|
||||
const titles: string[] = useDisplayTitle
|
||||
? Object.values(appsItems).map((item: any) => item.displayTitle!)
|
||||
: Object.keys(appsItems)
|
||||
const appMiniToc = await getAutomatedPageMiniTocItems(titles, context)
|
||||
appMiniToc && miniTocItems.push(...appMiniToc)
|
||||
@@ -35,7 +35,7 @@ export default function FineGrainedTokenEndpoints({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index.js')
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index')
|
||||
const { currentVersion, appsItems, categoriesWithoutSubcategories } =
|
||||
await getAppsServerSideProps(context, 'fine-grained-pat', { useDisplayTitle: false })
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function GitHubAppEndpoints({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index.js')
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index')
|
||||
const { currentVersion, appsItems, categoriesWithoutSubcategories } =
|
||||
await getAppsServerSideProps(context, 'server-to-server-rest', { useDisplayTitle: false })
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function UserGitHubAppEndpoints({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index.js')
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index')
|
||||
const { currentVersion, appsItems, categoriesWithoutSubcategories } =
|
||||
await getAppsServerSideProps(context, 'user-to-server-rest', { useDisplayTitle: false })
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function FineGrainedPatPermissions({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index.js')
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index')
|
||||
const { currentVersion, appsItems, categoriesWithoutSubcategories } =
|
||||
await getAppsServerSideProps(context, 'fine-grained-pat-permissions', { useDisplayTitle: true })
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function GitHubAppPermissions({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index.js')
|
||||
const { getAppsServerSideProps } = await import('@/github-apps/lib/index')
|
||||
const { currentVersion, appsItems, categoriesWithoutSubcategories } =
|
||||
await getAppsServerSideProps(context, 'server-to-server-permissions', { useDisplayTitle: true })
|
||||
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
// src/github-apps/data/fine-grained-pat-permissions.json
|
||||
// and src/github-apps/data/server-to-server-permissions.json
|
||||
|
||||
const permissionObjects = {
|
||||
interface SchemaProperty {
|
||||
type: string
|
||||
enum?: string[]
|
||||
description?: string
|
||||
items?: object
|
||||
}
|
||||
|
||||
interface Schema {
|
||||
type: string
|
||||
required?: string[]
|
||||
properties: Record<string, SchemaProperty>
|
||||
}
|
||||
|
||||
const permissionObjects: Schema = {
|
||||
type: 'object',
|
||||
required: ['access', 'category', 'subcategory', 'slug', 'verb', 'requestPath'],
|
||||
properties: {
|
||||
@@ -32,7 +45,7 @@ const permissionObjects = {
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
const schema: Schema = {
|
||||
type: 'object',
|
||||
required: ['title', 'displayTitle', 'permissions'],
|
||||
properties: {
|
||||
@@ -52,3 +65,5 @@ export default {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default schema
|
||||
@@ -10,6 +10,10 @@ import { globSync } from 'glob'
|
||||
import { program } from 'commander'
|
||||
import { createOperations, processOperations } from './utils/get-operations'
|
||||
|
||||
interface ProgramOptions {
|
||||
files: string[]
|
||||
}
|
||||
|
||||
program
|
||||
.description('Generate dereferenced OpenAPI and decorated schema files.')
|
||||
.requiredOption(
|
||||
@@ -18,9 +22,9 @@ program
|
||||
)
|
||||
.parse(process.argv)
|
||||
|
||||
const filenames = program.opts().files
|
||||
const filenames: string[] = (program.opts() as ProgramOptions).files
|
||||
|
||||
const filesToCheck = filenames.flatMap((filename) => globSync(filename))
|
||||
const filesToCheck: string[] = filenames.flatMap((filename: string) => globSync(filename))
|
||||
|
||||
if (filesToCheck.length) {
|
||||
check(filesToCheck)
|
||||
@@ -29,22 +33,22 @@ if (filesToCheck.length) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function check(files) {
|
||||
async function check(files: string[]): Promise<void> {
|
||||
console.log('Verifying OpenAPI files are valid with decorator')
|
||||
const documents = files.map((filename) => [
|
||||
const documents: [string, any][] = files.map((filename: string) => [
|
||||
filename,
|
||||
JSON.parse(fs.readFileSync(path.join(filename))),
|
||||
JSON.parse(fs.readFileSync(path.join(filename), 'utf8')),
|
||||
])
|
||||
|
||||
for (const [filename, schema] of documents) {
|
||||
for (const [filename, schema] of documents as [string, any][]) {
|
||||
try {
|
||||
// munge OpenAPI definitions object in an array of operations objects
|
||||
const operations = await createOperations(schema)
|
||||
// process each operation, asynchronously rendering markdown and stuff
|
||||
await processOperations(operations)
|
||||
await processOperations(operations, {})
|
||||
|
||||
console.log(`Successfully could decorate OpenAPI operations for document ${filename}`)
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
console.log(
|
||||
`🐛 Whoops! It looks like the decorator script wasn't able to parse the dereferenced schema in file ${filename}. A recent change may not yet be supported by the decorator. Please reach out in the #docs-engineering slack channel for help.`,
|
||||
Reference in New Issue
Block a user