|
| 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) |
0 commit comments