Convert 10 JavaScript files to TypeScript (#57642)
This commit is contained in:
@@ -5,14 +5,14 @@ import fs from 'fs'
|
|||||||
// is unpredictable in GitHub Actions because of how it does `git clone`.
|
// is unpredictable in GitHub Actions because of how it does `git clone`.
|
||||||
// So we rely on environment variables instead.
|
// So we rely on environment variables instead.
|
||||||
|
|
||||||
export function getDiffFiles() {
|
export function getDiffFiles(): string[] {
|
||||||
// Instead of testing every single file possible, if there's
|
// Instead of testing every single file possible, if there's
|
||||||
// an environment variable called `DIFF_FILES` or one called
|
// an environment variable called `DIFF_FILES` or one called
|
||||||
// `DIFF_FILE` then use that.
|
// `DIFF_FILE` then use that.
|
||||||
// If `DIFF_FILES` is set, it's expected to be a space separated
|
// If `DIFF_FILES` is set, it's expected to be a space separated
|
||||||
// string. If `DIFF_FILE` is set, it's expected to be a text file
|
// string. If `DIFF_FILE` is set, it's expected to be a text file
|
||||||
// which contains a space separated string.
|
// which contains a space separated string.
|
||||||
const diffFiles = []
|
const diffFiles: string[] = []
|
||||||
// Setting an environment variable called `DIFF_FILES` is optional.
|
// Setting an environment variable called `DIFF_FILES` is optional.
|
||||||
// But if and only if it's set, we will respect it.
|
// But if and only if it's set, we will respect it.
|
||||||
// And if it set, turn it into a cleaned up Set so it's made available
|
// And if it set, turn it into a cleaned up Set so it's made available
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
|
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
|
||||||
import { addError, filterTokens, newLineRe } from 'markdownlint-rule-helpers'
|
import { addError, filterTokens, newLineRe } from 'markdownlint-rule-helpers'
|
||||||
|
|
||||||
export const codeFenceLineLength = {
|
import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '@/content-linter/types'
|
||||||
|
|
||||||
|
export const codeFenceLineLength: Rule = {
|
||||||
names: ['GHD030', 'code-fence-line-length'],
|
names: ['GHD030', 'code-fence-line-length'],
|
||||||
description: 'Code fence lines should not exceed a maximum length',
|
description: 'Code fence lines should not exceed a maximum length',
|
||||||
tags: ['code', 'accessibility'],
|
tags: ['code', 'accessibility'],
|
||||||
parser: 'markdownit',
|
parser: 'markdownit',
|
||||||
function: (params, onError) => {
|
function: (params: RuleParams, onError: RuleErrorCallback) => {
|
||||||
const MAX_LINE_LENGTH = String(params.config.maxLength || 60)
|
const MAX_LINE_LENGTH: number = params.config?.maxLength || 60
|
||||||
filterTokens(params, 'fence', (token) => {
|
filterTokens(params, 'fence', (token: MarkdownToken) => {
|
||||||
const lines = token.content.split(newLineRe)
|
if (!token.content) return
|
||||||
lines.forEach((line, index) => {
|
const lines: string[] = token.content.split(newLineRe)
|
||||||
|
lines.forEach((line: string, index: number) => {
|
||||||
if (line.length > MAX_LINE_LENGTH) {
|
if (line.length > MAX_LINE_LENGTH) {
|
||||||
// The token line number is the line number of the first line of the
|
// The token line number is the line number of the first line of the
|
||||||
// code fence. We want to report the line number of the content within
|
// code fence. We want to report the line number of the content within
|
||||||
// the code fence so we need to add 1 + the index.
|
// the code fence so we need to add 1 + the index.
|
||||||
const lineNumber = token.lineNumber + index + 1
|
const lineNumber: number = token.lineNumber + index + 1
|
||||||
addError(
|
addError(
|
||||||
onError,
|
onError,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
// used below to remove extra newlines in TOC lists
|
// used below to remove extra newlines in TOC lists
|
||||||
const endLine = '</a>\r?\n'
|
const endLine: string = '</a>\r?\n'
|
||||||
const blankLine = '\\s*?[\r\n]*'
|
const blankLine: string = '\\s*?[\r\n]*'
|
||||||
const startNextLine = '[^\\S\r\n]*?[-\\*] <a'
|
const startNextLine: string = '[^\\S\r\n]*?[-\\*] <a'
|
||||||
const blankLineInList = new RegExp(`(${endLine})${blankLine}(${startNextLine})`, 'mg')
|
const blankLineInList: RegExp = new RegExp(`(${endLine})${blankLine}(${startNextLine})`, 'mg')
|
||||||
|
|
||||||
export function processLiquidPost(template) {
|
export function processLiquidPost(template: string): string {
|
||||||
template = cleanUpListEmptyLines(template)
|
template = cleanUpListEmptyLines(template)
|
||||||
template = cleanUpExtraEmptyLines(template)
|
template = cleanUpExtraEmptyLines(template)
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanUpListEmptyLines(template) {
|
function cleanUpListEmptyLines(template: string): string {
|
||||||
// clean up empty lines in TOC lists left by unrendered list items (due to productVersions)
|
// clean up empty lines in TOC lists left by unrendered list items (due to productVersions)
|
||||||
// for example, remove the blank line here:
|
// for example, remove the blank line here:
|
||||||
// - <a>foo</a>
|
// - <a>foo</a>
|
||||||
@@ -22,7 +22,7 @@ function cleanUpListEmptyLines(template) {
|
|||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanUpExtraEmptyLines(template) {
|
function cleanUpExtraEmptyLines(template: string): string {
|
||||||
// this removes any extra newlines left by (now resolved) liquid
|
// this removes any extra newlines left by (now resolved) liquid
|
||||||
// statements so that extra space doesn't mess with list numbering
|
// statements so that extra space doesn't mess with list numbering
|
||||||
template = template.replace(/(\r?\n){3}/g, '\n\n')
|
template = template.replace(/(\r?\n){3}/g, '\n\n')
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
// src/content-render/liquid/prompt.js
|
// src/content-render/liquid/prompt.ts
|
||||||
// Defines {% prompt %}…{% endprompt %} to wrap its content in <code> and append the Copilot icon.
|
// Defines {% prompt %}…{% endprompt %} to wrap its content in <code> and append the Copilot icon.
|
||||||
|
|
||||||
|
// @ts-ignore - @primer/octicons doesn't provide TypeScript declarations
|
||||||
import octicons from '@primer/octicons'
|
import octicons from '@primer/octicons'
|
||||||
|
|
||||||
export const Prompt = {
|
interface LiquidTag {
|
||||||
|
type: 'block'
|
||||||
|
templates?: any[] // Note: Using 'any' because liquidjs doesn't provide proper types for template objects
|
||||||
|
// Note: Using 'any' for liquid-related parameters because liquidjs doesn't provide comprehensive TypeScript definitions
|
||||||
|
parse(tagToken: any, remainTokens: any): void
|
||||||
|
render(scope: any): Generator<any, string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Prompt: LiquidTag = {
|
||||||
type: 'block',
|
type: 'block',
|
||||||
|
|
||||||
// Collect everything until {% endprompt %}
|
// Collect everything until {% endprompt %}
|
||||||
parse(tagToken, remainTokens) {
|
parse(tagToken: any, remainTokens: any): void {
|
||||||
this.templates = []
|
this.templates = []
|
||||||
const stream = this.liquid.parser.parseStream(remainTokens)
|
const stream = this.liquid.parser.parseStream(remainTokens)
|
||||||
stream
|
stream
|
||||||
.on('template', (tpl) => this.templates.push(tpl))
|
.on('template', (tpl: any) => this.templates.push(tpl))
|
||||||
.on('tag:endprompt', () => stream.stop())
|
.on('tag:endprompt', () => stream.stop())
|
||||||
.on('end', () => {
|
.on('end', () => {
|
||||||
throw new Error(`{% prompt %} tag not closed`)
|
throw new Error(`{% prompt %} tag not closed`)
|
||||||
@@ -20,12 +29,12 @@ export const Prompt = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Render the inner Markdown, wrap in <code>, then append the SVG
|
// Render the inner Markdown, wrap in <code>, then append the SVG
|
||||||
render: function* (scope) {
|
render: function* (scope: any): Generator<any, string, unknown> {
|
||||||
const content = yield this.liquid.renderer.renderTemplates(this.templates, scope)
|
const content = yield this.liquid.renderer.renderTemplates(this.templates, scope)
|
||||||
|
|
||||||
// build a URL with the prompt text encoded as query parameter
|
// build a URL with the prompt text encoded as query parameter
|
||||||
const promptParam = encodeURIComponent(content)
|
const promptParam: string = encodeURIComponent(content as string)
|
||||||
const href = `https://github.com/copilot?prompt=${promptParam}`
|
const href: string = `https://github.com/copilot?prompt=${promptParam}`
|
||||||
return `<code>${content}</code><a href="${href}" target="_blank" class="tooltipped tooltipped-nw ml-1" aria-label="Run this prompt in Copilot Chat" style="text-decoration:none;">${octicons.copilot.toSVG()}</a>`
|
return `<code>${content}</code><a href="${href}" target="_blank" class="tooltipped tooltipped-nw ml-1" aria-label="Run this prompt in Copilot Chat" style="text-decoration:none;">${octicons.copilot.toSVG()}</a>`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ import { renderContent } from '@/content-render/index'
|
|||||||
|
|
||||||
describe('prompt tag', () => {
|
describe('prompt tag', () => {
|
||||||
test('wraps content in <code> and appends svg', async () => {
|
test('wraps content in <code> and appends svg', async () => {
|
||||||
const input = 'Here is your prompt: {% prompt %}example prompt text{% endprompt %}.'
|
const input: string = 'Here is your prompt: {% prompt %}example prompt text{% endprompt %}.'
|
||||||
const output = await renderContent(input)
|
const output: string = await renderContent(input)
|
||||||
expect(output).toContain('<code>example prompt text</code><a')
|
expect(output).toContain('<code>example prompt text</code><a')
|
||||||
expect(output).toContain('<svg')
|
expect(output).toContain('<svg')
|
||||||
})
|
})
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Based on https://spec.commonmark.org/0.30/#info-string
|
|
||||||
// Parse out info strings on fenced code blocks, example:
|
|
||||||
// ```javascript lineNumbers:left copy:all annotate
|
|
||||||
// becomes...
|
|
||||||
// node.lang = javascript
|
|
||||||
// node.meta = { lineNumbers: 'left', copy: 'all', annotate: true }
|
|
||||||
// Also parse equals signs, where id=some-id becomes { id: 'some-id' }
|
|
||||||
|
|
||||||
import { visit } from 'unist-util-visit'
|
|
||||||
|
|
||||||
const matcher = (node) => node.type === 'code' && node.lang
|
|
||||||
|
|
||||||
export default function parseInfoString() {
|
|
||||||
return (tree) => {
|
|
||||||
visit(tree, matcher, (node) => {
|
|
||||||
node.meta = strToObj(node.meta)
|
|
||||||
|
|
||||||
// Temporary, remove {:copy} to avoid highlight parse error in translations.
|
|
||||||
node.lang = node.lang.replace('{:copy}', '')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function strToObj(str) {
|
|
||||||
if (!str) return {}
|
|
||||||
return Object.fromEntries(
|
|
||||||
str
|
|
||||||
.split(/\s+/g)
|
|
||||||
.map((k) => k.split(/[:=]/)) // split by colon or equals sign
|
|
||||||
.map(([k, ...v]) => [k, v.length ? v.join(':') : true]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
45
src/content-render/unified/parse-info-string.ts
Normal file
45
src/content-render/unified/parse-info-string.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Based on https://spec.commonmark.org/0.30/#info-string
|
||||||
|
// Parse out info strings on fenced code blocks, example:
|
||||||
|
// ```javascript lineNumbers:left copy:all annotate
|
||||||
|
// becomes...
|
||||||
|
// node.lang = javascript
|
||||||
|
// node.meta = { lineNumbers: 'left', copy: 'all', annotate: true }
|
||||||
|
// Also parse equals signs, where id=some-id becomes { id: 'some-id' }
|
||||||
|
|
||||||
|
import { visit } from 'unist-util-visit'
|
||||||
|
|
||||||
|
interface CodeNode {
|
||||||
|
type: 'code'
|
||||||
|
lang?: string
|
||||||
|
meta?: string | Record<string, string | boolean>
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Using 'any' for node because unist-util-visit's type constraints
|
||||||
|
// don't easily allow for proper code node typing without complex generics
|
||||||
|
const matcher = (node: any): node is CodeNode => node.type === 'code' && node.lang
|
||||||
|
|
||||||
|
export default function parseInfoString() {
|
||||||
|
// Note: Using 'any' for tree because unified's AST types are complex and
|
||||||
|
// this function works with different tree types depending on the processor
|
||||||
|
return (tree: any) => {
|
||||||
|
visit(tree, matcher, (node: CodeNode) => {
|
||||||
|
node.meta = strToObj(node.meta as string)
|
||||||
|
|
||||||
|
// Temporary, remove {:copy} to avoid highlight parse error in translations.
|
||||||
|
if (node.lang) {
|
||||||
|
node.lang = node.lang.replace('{:copy}', '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strToObj(str?: string): Record<string, string | boolean> {
|
||||||
|
if (!str) return {}
|
||||||
|
return Object.fromEntries(
|
||||||
|
str
|
||||||
|
.split(/\s+/g)
|
||||||
|
.map((k: string) => k.split(/[:=]/)) // split by colon or equals sign
|
||||||
|
.map(([k, ...v]: string[]) => [k, v.length ? v.join(':') : true]),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import GithubSlugger from 'github-slugger'
|
|
||||||
import { encode } from 'html-entities'
|
|
||||||
import { toString } from 'hast-util-to-string'
|
|
||||||
import { visit } from 'unist-util-visit'
|
|
||||||
const slugger = new GithubSlugger()
|
|
||||||
|
|
||||||
const matcher = (node) => node.type === 'element' && ['h2', 'h3', 'h4'].includes(node.tagName)
|
|
||||||
|
|
||||||
// replace translated IDs and links in headings with English
|
|
||||||
export default function useEnglishHeadings({ englishHeadings }) {
|
|
||||||
if (!englishHeadings) return
|
|
||||||
return (tree) => {
|
|
||||||
visit(tree, matcher, (node) => {
|
|
||||||
slugger.reset()
|
|
||||||
// Get the plain text content of the heading node
|
|
||||||
const text = toString(node)
|
|
||||||
// find English heading in the collection
|
|
||||||
const englishHeading = englishHeadings[encode(text)]
|
|
||||||
// get English slug
|
|
||||||
const englishSlug = slugger.slug(englishHeading)
|
|
||||||
// use English slug for heading ID and link
|
|
||||||
if (englishSlug) {
|
|
||||||
// only use English slug if there is one, otherwise we'll end up with
|
|
||||||
// empty IDs
|
|
||||||
node.properties.id = englishSlug
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
41
src/content-render/unified/use-english-headings.ts
Normal file
41
src/content-render/unified/use-english-headings.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import GithubSlugger from 'github-slugger'
|
||||||
|
import { encode } from 'html-entities'
|
||||||
|
import { toString } from 'hast-util-to-string'
|
||||||
|
import { visit } from 'unist-util-visit'
|
||||||
|
|
||||||
|
const slugger = new GithubSlugger()
|
||||||
|
|
||||||
|
// Note: Using 'any' for node because the unist/hast type system is complex and
|
||||||
|
// the visit function's type constraints don't easily allow for proper element typing
|
||||||
|
// without extensive type gymnastics. The runtime check ensures type safety.
|
||||||
|
const matcher = (node: any) => node.type === 'element' && ['h2', 'h3', 'h4'].includes(node.tagName)
|
||||||
|
|
||||||
|
interface UseEnglishHeadingsOptions {
|
||||||
|
englishHeadings?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace translated IDs and links in headings with English
|
||||||
|
export default function useEnglishHeadings({ englishHeadings }: UseEnglishHeadingsOptions) {
|
||||||
|
if (!englishHeadings) return
|
||||||
|
// Note: Using 'any' for tree because unified's AST types are complex and
|
||||||
|
// this function works with different tree types depending on the processor
|
||||||
|
return (tree: any) => {
|
||||||
|
// Note: Using 'any' for node because visit() callback typing is restrictive
|
||||||
|
// and doesn't easily allow for proper element typing without complex generics
|
||||||
|
visit(tree, matcher, (node: any) => {
|
||||||
|
slugger.reset()
|
||||||
|
// Get the plain text content of the heading node
|
||||||
|
const text: string = toString(node)
|
||||||
|
// find English heading in the collection
|
||||||
|
const englishHeading: string = englishHeadings[encode(text)]
|
||||||
|
// get English slug
|
||||||
|
const englishSlug: string = slugger.slug(englishHeading)
|
||||||
|
// use English slug for heading ID and link
|
||||||
|
if (englishSlug) {
|
||||||
|
// only use English slug if there is one, otherwise we'll end up with
|
||||||
|
// empty IDs
|
||||||
|
node.properties.id = englishSlug
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,23 @@
|
|||||||
// src/github-apps/data/user-to-server-rest.json
|
// src/github-apps/data/user-to-server-rest.json
|
||||||
// and src/github-apps/data/fine-grained-pat.json
|
// and src/github-apps/data/fine-grained-pat.json
|
||||||
|
|
||||||
export default {
|
interface SchemaProperty {
|
||||||
|
description: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnabledListSchema {
|
||||||
|
type: string
|
||||||
|
required: string[]
|
||||||
|
properties: {
|
||||||
|
slug: SchemaProperty
|
||||||
|
subcategory: SchemaProperty
|
||||||
|
verb: SchemaProperty
|
||||||
|
requestPath: SchemaProperty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: EnabledListSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['slug', 'subcategory', 'verb', 'requestPath'],
|
required: ['slug', 'subcategory', 'verb', 'requestPath'],
|
||||||
properties: {
|
properties: {
|
||||||
@@ -25,3 +41,5 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default schema
|
||||||
@@ -7,12 +7,14 @@ import { REST_DATA_DIR } from '../lib/index'
|
|||||||
|
|
||||||
const clientSideRestAPIRedirects = readCompressedJsonFileFallbackLazily(
|
const clientSideRestAPIRedirects = readCompressedJsonFileFallbackLazily(
|
||||||
path.join(REST_DATA_DIR, 'client-side-rest-api-redirects.json'),
|
path.join(REST_DATA_DIR, 'client-side-rest-api-redirects.json'),
|
||||||
)
|
) as () => Record<string, string>
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
// Returns a client side redirect if one exists for the given path.
|
// Returns a client side redirect if one exists for the given path.
|
||||||
router.get('/', function redirects(req, res) {
|
// Note: Using 'any' for req/res because Express types are complex and the
|
||||||
|
// function signature is constrained by the router.get() overloads
|
||||||
|
router.get('/', function redirects(req: any, res: any) {
|
||||||
if (!req.query.path) {
|
if (!req.query.path) {
|
||||||
return res.status(400).send("Missing 'path' query string")
|
return res.status(400).send("Missing 'path' query string")
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,7 @@ router.get('/', function redirects(req, res) {
|
|||||||
|
|
||||||
defaultCacheControl(res)
|
defaultCacheControl(res)
|
||||||
|
|
||||||
const redirectFrom = `${req.query.path}#${req.query.hash}`
|
const redirectFrom: string = `${req.query.path}#${req.query.hash}`
|
||||||
res.status(200).send({ to: clientSideRestAPIRedirects()[redirectFrom] })
|
res.status(200).send({ to: clientSideRestAPIRedirects()[redirectFrom] })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
const childPage = {
|
|
||||||
type: 'object',
|
|
||||||
required: ['href', 'page'],
|
|
||||||
properties: {
|
|
||||||
href: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
page: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['title', 'relativePath', 'permalinks'],
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
relativePath: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
permalinks: {
|
|
||||||
type: 'array',
|
|
||||||
minItems: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { childPage }
|
|
||||||
55
src/tests/helpers/schemas/site-tree-schema.ts
Normal file
55
src/tests/helpers/schemas/site-tree-schema.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
interface SchemaProperty {
|
||||||
|
type: string
|
||||||
|
minItems?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageProperties {
|
||||||
|
title: SchemaProperty
|
||||||
|
relativePath: SchemaProperty
|
||||||
|
permalinks: SchemaProperty
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageSchema {
|
||||||
|
type: string
|
||||||
|
required: string[]
|
||||||
|
properties: PageProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildPageProperties {
|
||||||
|
href: SchemaProperty
|
||||||
|
page: PageSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildPageSchema {
|
||||||
|
type: string
|
||||||
|
required: string[]
|
||||||
|
properties: ChildPageProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const childPage: ChildPageSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['href', 'page'],
|
||||||
|
properties: {
|
||||||
|
href: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title', 'relativePath', 'permalinks'],
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
relativePath: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
permalinks: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { childPage }
|
||||||
Reference in New Issue
Block a user