Skip to content

Commit 2b0e25a

Browse files
authored
Support a new contentType frontmatter property (#56715)
1 parent 45c42cf commit 2b0e25a

File tree

7 files changed

+220
-3
lines changed

7 files changed

+220
-3
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"exports": "./src/frame/server.ts",
1818
"scripts": {
19+
"add-content-type": "tsx src/content-render/scripts/add-content-type.ts",
1920
"ai-edit": "tsx src/ai-editors/scripts/ai-edit.ts",
2021
"all-documents": "tsx src/content-render/scripts/all-documents/cli.ts",
2122
"analyze-text": "tsx src/search/scripts/analyze-text.ts",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// This script auto-populates the `contentType` frontmatter property based on
2+
// the directory location of the content file.
3+
// Run with:
4+
// npm run-script -- add-content-type --help
5+
6+
import fs from 'fs'
7+
import path from 'path'
8+
import { program } from 'commander'
9+
import frontmatter from '@/frame/lib/read-frontmatter'
10+
import walkFiles from '@/workflows/walk-files'
11+
import { contentTypesEnum } from '#src/frame/lib/frontmatter.js'
12+
import type { MarkdownFrontmatter } from '@/types'
13+
14+
const RESPONSIBLE_USE_STRING = 'responsible-use'
15+
const LANDING_TYPE = 'landing'
16+
const RAI_TYPE = 'rai'
17+
const OTHER_TYPE = 'other'
18+
19+
interface ScriptOptions {
20+
dryRun?: boolean
21+
paths?: string[]
22+
removeType?: boolean
23+
verbose?: boolean
24+
}
25+
26+
program
27+
.description('Auto-populate the contentType frontmatter property based on file location')
28+
.option(
29+
'-p, --paths [paths...]',
30+
'One or more specific paths to process (e.g., copilot or content/copilot/how-tos/file.md)',
31+
)
32+
.option('-r, --remove-type', `Remove the legacy 'type' frontmatter property if present`)
33+
.option('-d, --dry-run', 'Preview changes without modifying files')
34+
.option('-v, --verbose', 'Show detailed output of changes made')
35+
.addHelpText(
36+
'after',
37+
`
38+
Possible contentType values:
39+
${contentTypesEnum.join(', ')}
40+
41+
Examples:
42+
npm run-script -- add-content-type // runs on all content files, does not remove legacy 'type' prop
43+
npm run-script -- add-content-type --paths copilot actions --remove-type --dry-run
44+
npm run-script -- add-content-type --paths content/copilot/how-tos
45+
npm run-script -- add-content-type --verbose`,
46+
)
47+
.parse(process.argv)
48+
49+
const options: ScriptOptions = program.opts()
50+
51+
const contentDir = path.join(process.cwd(), 'content')
52+
53+
async function main() {
54+
const filesToProcess: string[] = walkFiles(contentDir, ['.md']).filter((file: string) => {
55+
if (file.endsWith('README.md')) return false
56+
if (file.includes('early-access')) return false
57+
if (!options.paths) return true
58+
return options.paths.some((p: string) => {
59+
// Allow either a full content path like "content/foo/bar.md"
60+
// or a top-level directory name like "copilot"
61+
if (!p.startsWith('content')) {
62+
p = path.join('content', p)
63+
}
64+
if (!fs.existsSync(p)) {
65+
console.error(`${p} not found`)
66+
process.exit(1)
67+
}
68+
if (path.relative(process.cwd(), file).startsWith(p)) return true
69+
})
70+
})
71+
72+
let processedCount = 0
73+
let updatedCount = 0
74+
75+
for (const filePath of filesToProcess) {
76+
try {
77+
const result = processFile(filePath, options)
78+
if (result.processed) processedCount++
79+
if (result.updated) updatedCount++
80+
} catch (error) {
81+
console.error(
82+
`Error processing ${filePath}:`,
83+
error instanceof Error ? error.message : String(error),
84+
)
85+
}
86+
}
87+
88+
console.log(`\nUpdated ${updatedCount} files out of ${processedCount}`)
89+
}
90+
91+
function processFile(filePath: string, options: ScriptOptions) {
92+
const fileContent = fs.readFileSync(filePath, 'utf8')
93+
const relativePath = path.relative(contentDir, filePath)
94+
95+
const { data, content } = frontmatter(fileContent) as unknown as {
96+
data: MarkdownFrontmatter & { contentType?: string }
97+
content: string
98+
}
99+
100+
if (!data) return { processed: false, updated: false }
101+
102+
// Remove the legacy type property if option is passed
103+
const removeLegacyType = Boolean(options.removeType && data.type)
104+
105+
// Skip if contentType already exists and we're not removing legacy type
106+
if (data.contentType && !removeLegacyType) {
107+
console.log(`contentType already set on ${relativePath}`)
108+
return { processed: true, updated: false }
109+
}
110+
111+
const newContentType = data.contentType || determineContentType(relativePath, data.type || '')
112+
113+
if (options.dryRun) {
114+
console.log(`\n${relativePath}`)
115+
if (!data.contentType) {
116+
console.log(` ✅ Would set contentType: "${newContentType}"`)
117+
}
118+
if (removeLegacyType) {
119+
console.log(` ✂️ Would remove legacy type: "${data.type}"`)
120+
}
121+
return { processed: true, updated: false }
122+
}
123+
124+
// Set the contentType property if it doesn't exist
125+
if (!data.contentType) {
126+
data.contentType = newContentType
127+
}
128+
129+
let legacyTypeValue
130+
if (removeLegacyType) {
131+
legacyTypeValue = data.type
132+
delete data.type
133+
}
134+
135+
// Write the file back
136+
fs.writeFileSync(filePath, frontmatter.stringify(content, data, { lineWidth: -1 } as any))
137+
138+
if (options.verbose) {
139+
console.log(`\n${relativePath}`)
140+
console.log(` ✅ Set contentType: "${newContentType}"`)
141+
if (removeLegacyType) {
142+
console.log(` ✂️ Removed legacy type: "${legacyTypeValue}"`)
143+
}
144+
}
145+
146+
return { processed: true, updated: true }
147+
}
148+
149+
function determineContentType(relativePath: string, legacyType: string): string {
150+
// The split path array will be structured like:
151+
// [ 'copilot', 'how-tos', 'troubleshoot', 'index.md' ]
152+
// where the content type we want is in slot 1.
153+
const pathSegments = relativePath.split(path.sep)
154+
155+
const topLevelDirectory = pathSegments[0]
156+
const derivedContentType = pathSegments[1]
157+
158+
// There is only one content/index.md, and it's the homepage.
159+
if (topLevelDirectory === 'index.md') return 'homepage'
160+
161+
// SPECIAL HANDLING FOR RAI
162+
// If a legacy type includes 'rai', use it for the contentType.
163+
// If a directory name includes a responsible-use string, assume the 'rai' type.
164+
if (legacyType === 'rai' || derivedContentType.includes(RESPONSIBLE_USE_STRING)) {
165+
return RAI_TYPE
166+
}
167+
168+
// When the content directory matches any of the allowed
169+
// content type values (such as 'get-started',
170+
// 'concepts', 'how-tos', 'reference', and 'tutorials'),
171+
// immediately return it. We're satisfied.
172+
if (contentTypesEnum.includes(derivedContentType)) {
173+
return derivedContentType
174+
}
175+
176+
// There is only one content/<product>/index.md file per doc set.
177+
// This index.md is always a landing page.
178+
if (derivedContentType === 'index.md') {
179+
return LANDING_TYPE
180+
}
181+
182+
// Classify anything else as 'other'.
183+
return OTHER_TYPE
184+
}
185+
186+
main().catch(console.error)

src/frame/lib/frontmatter.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,24 @@ const layoutNames = [
1717
false,
1818
]
1919

20+
// DEPRECATED: Use 'contentType' instead of 'type' for new content.
21+
// 'type' exists on ~40% of files but is used only for internal analytics.
22+
// Migration tool: src/content-render/scripts/add-content-type.ts
2023
const guideTypes = ['overview', 'quick_start', 'tutorial', 'how_to', 'reference', 'rai']
2124

25+
// As of July 2025, use 'contentType' rather than 'type'.
26+
export const contentTypesEnum = [
27+
'get-started',
28+
'concepts',
29+
'how-tos',
30+
'reference',
31+
'tutorials',
32+
'homepage', // Only applies to the sole 'content/index.md' file (the homepage).
33+
'landing', // Only applies to 'content/<product>/index.md' files (product landings).
34+
'rai', // Only applies to files that live in directories with 'responsible-use' in the name.
35+
'other', // Everything else.
36+
]
37+
2238
export const schema = {
2339
type: 'object',
2440
required: ['title', 'versions'],
@@ -150,10 +166,18 @@ export const schema = {
150166
prefix: { type: 'string' },
151167
},
152168
},
169+
// DEPRECATED: Use 'contentType' instead of 'type' for new content.
170+
// 'type' exists on ~40% of files but is used only for internal analytics.
171+
// Migration tool: src/content-render/scripts/add-content-type.ts
153172
type: {
154173
type: 'string',
155174
enum: guideTypes,
156175
},
176+
// As of July 2025, use 'contentType' rather than 'type'.
177+
contentType: {
178+
type: 'string',
179+
enum: contentTypesEnum,
180+
},
157181
topics: {
158182
type: 'array',
159183
},

src/frame/lib/page.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,12 @@ class Page {
187187

188188
constructor(opts: PageReadResult) {
189189
if (opts.frontmatterErrors && opts.frontmatterErrors.length) {
190+
console.error(
191+
`${opts.frontmatterErrors.length} frontmatter errors trying to load ${opts.fullPath}:`,
192+
)
193+
console.error(opts.frontmatterErrors)
190194
throw new FrontmatterErrorsError(
191-
`${opts.frontmatterErrors.length} frontmatter errors trying to load ${opts.fullPath}`,
195+
`${opts.frontmatterErrors.length} frontmatter errors in ${opts.fullPath}`,
192196
opts.frontmatterErrors,
193197
)
194198
}

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,4 +471,6 @@ export type MarkdownFrontmatter = {
471471
versions: FrontmatterVersions
472472
subcategory?: boolean
473473
hidden?: boolean
474+
type?: string
475+
contentType?: string
474476
}

src/workflows/fm-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function checkContentType(filePaths: string[], type: string) {
88
const unallowedChangedFiles = []
99
for (const filePath of filePaths) {
1010
const { data } = matter(readFileSync(filePath, 'utf8'))
11-
if (data.type === type) {
11+
if (data.type === type || data.contentType === type) {
1212
unallowedChangedFiles.push(filePath)
1313
}
1414
}

src/workflows/unallowed-contributions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async function main() {
4646
const listUnallowedChangedFiles = unallowedChangedFiles.map((file) => `\n - ${file}`).join('')
4747
const listUnallowedFiles = filters.notAllowed.map((file: string) => `\n - ${file}`).join('')
4848

49-
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\`.`
49+
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\`.`
5050

5151
let workflowFailMessage =
5252
"It looks like you've modified some files that we can't accept as contributions."

0 commit comments

Comments
 (0)