Support a new contentType frontmatter property (#56715)
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"exports": "./src/frame/server.ts",
|
||||
"scripts": {
|
||||
"add-content-type": "tsx src/content-render/scripts/add-content-type.ts",
|
||||
"ai-edit": "tsx src/ai-editors/scripts/ai-edit.ts",
|
||||
"all-documents": "tsx src/content-render/scripts/all-documents/cli.ts",
|
||||
"analyze-text": "tsx src/search/scripts/analyze-text.ts",
|
||||
|
||||
186
src/content-render/scripts/add-content-type.ts
Normal file
186
src/content-render/scripts/add-content-type.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// This script auto-populates the `contentType` frontmatter property based on
|
||||
// the directory location of the content file.
|
||||
// Run with:
|
||||
// npm run-script -- add-content-type --help
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { program } from 'commander'
|
||||
import frontmatter from '@/frame/lib/read-frontmatter'
|
||||
import walkFiles from '@/workflows/walk-files'
|
||||
import { contentTypesEnum } from '#src/frame/lib/frontmatter.js'
|
||||
import type { MarkdownFrontmatter } from '@/types'
|
||||
|
||||
const RESPONSIBLE_USE_STRING = 'responsible-use'
|
||||
const LANDING_TYPE = 'landing'
|
||||
const RAI_TYPE = 'rai'
|
||||
const OTHER_TYPE = 'other'
|
||||
|
||||
interface ScriptOptions {
|
||||
dryRun?: boolean
|
||||
paths?: string[]
|
||||
removeType?: boolean
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
program
|
||||
.description('Auto-populate the contentType frontmatter property based on file location')
|
||||
.option(
|
||||
'-p, --paths [paths...]',
|
||||
'One or more specific paths to process (e.g., copilot or content/copilot/how-tos/file.md)',
|
||||
)
|
||||
.option('-r, --remove-type', `Remove the legacy 'type' frontmatter property if present`)
|
||||
.option('-d, --dry-run', 'Preview changes without modifying files')
|
||||
.option('-v, --verbose', 'Show detailed output of changes made')
|
||||
.addHelpText(
|
||||
'after',
|
||||
`
|
||||
Possible contentType values:
|
||||
${contentTypesEnum.join(', ')}
|
||||
|
||||
Examples:
|
||||
npm run-script -- add-content-type // runs on all content files, does not remove legacy 'type' prop
|
||||
npm run-script -- add-content-type --paths copilot actions --remove-type --dry-run
|
||||
npm run-script -- add-content-type --paths content/copilot/how-tos
|
||||
npm run-script -- add-content-type --verbose`,
|
||||
)
|
||||
.parse(process.argv)
|
||||
|
||||
const options: ScriptOptions = program.opts()
|
||||
|
||||
const contentDir = path.join(process.cwd(), 'content')
|
||||
|
||||
async function main() {
|
||||
const filesToProcess: string[] = walkFiles(contentDir, ['.md']).filter((file: string) => {
|
||||
if (file.endsWith('README.md')) return false
|
||||
if (file.includes('early-access')) return false
|
||||
if (!options.paths) return true
|
||||
return options.paths.some((p: string) => {
|
||||
// Allow either a full content path like "content/foo/bar.md"
|
||||
// or a top-level directory name like "copilot"
|
||||
if (!p.startsWith('content')) {
|
||||
p = path.join('content', p)
|
||||
}
|
||||
if (!fs.existsSync(p)) {
|
||||
console.error(`${p} not found`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (path.relative(process.cwd(), file).startsWith(p)) return true
|
||||
})
|
||||
})
|
||||
|
||||
let processedCount = 0
|
||||
let updatedCount = 0
|
||||
|
||||
for (const filePath of filesToProcess) {
|
||||
try {
|
||||
const result = processFile(filePath, options)
|
||||
if (result.processed) processedCount++
|
||||
if (result.updated) updatedCount++
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing ${filePath}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nUpdated ${updatedCount} files out of ${processedCount}`)
|
||||
}
|
||||
|
||||
function processFile(filePath: string, options: ScriptOptions) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8')
|
||||
const relativePath = path.relative(contentDir, filePath)
|
||||
|
||||
const { data, content } = frontmatter(fileContent) as unknown as {
|
||||
data: MarkdownFrontmatter & { contentType?: string }
|
||||
content: string
|
||||
}
|
||||
|
||||
if (!data) return { processed: false, updated: false }
|
||||
|
||||
// Remove the legacy type property if option is passed
|
||||
const removeLegacyType = Boolean(options.removeType && data.type)
|
||||
|
||||
// Skip if contentType already exists and we're not removing legacy type
|
||||
if (data.contentType && !removeLegacyType) {
|
||||
console.log(`contentType already set on ${relativePath}`)
|
||||
return { processed: true, updated: false }
|
||||
}
|
||||
|
||||
const newContentType = data.contentType || determineContentType(relativePath, data.type || '')
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`\n${relativePath}`)
|
||||
if (!data.contentType) {
|
||||
console.log(` ✅ Would set contentType: "${newContentType}"`)
|
||||
}
|
||||
if (removeLegacyType) {
|
||||
console.log(` ✂️ Would remove legacy type: "${data.type}"`)
|
||||
}
|
||||
return { processed: true, updated: false }
|
||||
}
|
||||
|
||||
// Set the contentType property if it doesn't exist
|
||||
if (!data.contentType) {
|
||||
data.contentType = newContentType
|
||||
}
|
||||
|
||||
let legacyTypeValue
|
||||
if (removeLegacyType) {
|
||||
legacyTypeValue = data.type
|
||||
delete data.type
|
||||
}
|
||||
|
||||
// Write the file back
|
||||
fs.writeFileSync(filePath, frontmatter.stringify(content, data, { lineWidth: -1 } as any))
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(`\n${relativePath}`)
|
||||
console.log(` ✅ Set contentType: "${newContentType}"`)
|
||||
if (removeLegacyType) {
|
||||
console.log(` ✂️ Removed legacy type: "${legacyTypeValue}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: true, updated: true }
|
||||
}
|
||||
|
||||
function determineContentType(relativePath: string, legacyType: string): string {
|
||||
// The split path array will be structured like:
|
||||
// [ 'copilot', 'how-tos', 'troubleshoot', 'index.md' ]
|
||||
// where the content type we want is in slot 1.
|
||||
const pathSegments = relativePath.split(path.sep)
|
||||
|
||||
const topLevelDirectory = pathSegments[0]
|
||||
const derivedContentType = pathSegments[1]
|
||||
|
||||
// There is only one content/index.md, and it's the homepage.
|
||||
if (topLevelDirectory === 'index.md') return 'homepage'
|
||||
|
||||
// SPECIAL HANDLING FOR RAI
|
||||
// If a legacy type includes 'rai', use it for the contentType.
|
||||
// If a directory name includes a responsible-use string, assume the 'rai' type.
|
||||
if (legacyType === 'rai' || derivedContentType.includes(RESPONSIBLE_USE_STRING)) {
|
||||
return RAI_TYPE
|
||||
}
|
||||
|
||||
// When the content directory matches any of the allowed
|
||||
// content type values (such as 'get-started',
|
||||
// 'concepts', 'how-tos', 'reference', and 'tutorials'),
|
||||
// immediately return it. We're satisfied.
|
||||
if (contentTypesEnum.includes(derivedContentType)) {
|
||||
return derivedContentType
|
||||
}
|
||||
|
||||
// There is only one content/<product>/index.md file per doc set.
|
||||
// This index.md is always a landing page.
|
||||
if (derivedContentType === 'index.md') {
|
||||
return LANDING_TYPE
|
||||
}
|
||||
|
||||
// Classify anything else as 'other'.
|
||||
return OTHER_TYPE
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
@@ -17,8 +17,24 @@ const layoutNames = [
|
||||
false,
|
||||
]
|
||||
|
||||
// DEPRECATED: Use 'contentType' instead of 'type' for new content.
|
||||
// 'type' exists on ~40% of files but is used only for internal analytics.
|
||||
// Migration tool: src/content-render/scripts/add-content-type.ts
|
||||
const guideTypes = ['overview', 'quick_start', 'tutorial', 'how_to', 'reference', 'rai']
|
||||
|
||||
// As of July 2025, use 'contentType' rather than 'type'.
|
||||
export const contentTypesEnum = [
|
||||
'get-started',
|
||||
'concepts',
|
||||
'how-tos',
|
||||
'reference',
|
||||
'tutorials',
|
||||
'homepage', // Only applies to the sole 'content/index.md' file (the homepage).
|
||||
'landing', // Only applies to 'content/<product>/index.md' files (product landings).
|
||||
'rai', // Only applies to files that live in directories with 'responsible-use' in the name.
|
||||
'other', // Everything else.
|
||||
]
|
||||
|
||||
export const schema = {
|
||||
type: 'object',
|
||||
required: ['title', 'versions'],
|
||||
@@ -150,10 +166,18 @@ export const schema = {
|
||||
prefix: { type: 'string' },
|
||||
},
|
||||
},
|
||||
// DEPRECATED: Use 'contentType' instead of 'type' for new content.
|
||||
// 'type' exists on ~40% of files but is used only for internal analytics.
|
||||
// Migration tool: src/content-render/scripts/add-content-type.ts
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: guideTypes,
|
||||
},
|
||||
// As of July 2025, use 'contentType' rather than 'type'.
|
||||
contentType: {
|
||||
type: 'string',
|
||||
enum: contentTypesEnum,
|
||||
},
|
||||
topics: {
|
||||
type: 'array',
|
||||
},
|
||||
|
||||
@@ -187,8 +187,12 @@ class Page {
|
||||
|
||||
constructor(opts: PageReadResult) {
|
||||
if (opts.frontmatterErrors && opts.frontmatterErrors.length) {
|
||||
console.error(
|
||||
`${opts.frontmatterErrors.length} frontmatter errors trying to load ${opts.fullPath}:`,
|
||||
)
|
||||
console.error(opts.frontmatterErrors)
|
||||
throw new FrontmatterErrorsError(
|
||||
`${opts.frontmatterErrors.length} frontmatter errors trying to load ${opts.fullPath}`,
|
||||
`${opts.frontmatterErrors.length} frontmatter errors in ${opts.fullPath}`,
|
||||
opts.frontmatterErrors,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -471,4 +471,6 @@ export type MarkdownFrontmatter = {
|
||||
versions: FrontmatterVersions
|
||||
subcategory?: boolean
|
||||
hidden?: boolean
|
||||
type?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function checkContentType(filePaths: string[], type: string) {
|
||||
const unallowedChangedFiles = []
|
||||
for (const filePath of filePaths) {
|
||||
const { data } = matter(readFileSync(filePath, 'utf8'))
|
||||
if (data.type === type) {
|
||||
if (data.type === type || data.contentType === type) {
|
||||
unallowedChangedFiles.push(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ async function main() {
|
||||
const listUnallowedChangedFiles = unallowedChangedFiles.map((file) => `\n - ${file}`).join('')
|
||||
const listUnallowedFiles = filters.notAllowed.map((file: string) => `\n - ${file}`).join('')
|
||||
|
||||
const reviewMessage = `👋 Hey there spelunker. It looks like you've modified some files that we can't accept as contributions:${listUnallowedChangedFiles}\n\nYou'll need to revert all of the files you changed that match that list using [GitHub Desktop](https://docs.github.com/en/free-pro-team@latest/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/reverting-a-commit-in-github-desktop) or \`git checkout origin/main <file name>\`. Once you get those files reverted, we can continue with the review process. :octocat:\n\nThe complete list of files we can't accept are:${listUnallowedFiles}\n\nWe also can't accept contributions to files in the content directory with frontmatter \`type: rai\`.`
|
||||
const reviewMessage = `👋 Hey there spelunker. It looks like you've modified some files that we can't accept as contributions:${listUnallowedChangedFiles}\n\nYou'll need to revert all of the files you changed that match that list using [GitHub Desktop](https://docs.github.com/en/free-pro-team@latest/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/reverting-a-commit-in-github-desktop) or \`git checkout origin/main <file name>\`. Once you get those files reverted, we can continue with the review process. :octocat:\n\nThe complete list of files we can't accept are:${listUnallowedFiles}\n\nWe also can't accept contributions to files in the content directory with frontmatter \`type: rai\` or \`contentType: rai\`.`
|
||||
|
||||
let workflowFailMessage =
|
||||
"It looks like you've modified some files that we can't accept as contributions."
|
||||
|
||||
Reference in New Issue
Block a user