1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Support "npm run writers" to display a list of writer-focused tools (#58326)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sarah Schneider
2025-11-03 14:18:43 -05:00
committed by GitHub
parent 00e523c65d
commit d6cb86c0bf
17 changed files with 287 additions and 67 deletions

View File

@@ -1,6 +1,8 @@
/**
* This script iterates over all pages and all reusables and looks for
* mentions of variables in Liquid syntax. For example,
* @purpose Writer tool
* @description Look for mentions of variables in Liquid syntax across all pages
*
* For example,
*
* ---
* title: '{% data variables.product.prodname_mobile %} is cool'

View File

@@ -1,3 +1,7 @@
/**
* @purpose Writer tool
* @description Run the Docs content linter, specifying paths and optional rules
*/
// @ts-nocheck
import fs from 'fs'
import path from 'path'

View File

@@ -1,7 +1,7 @@
// 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
/**
* @purpose Writer tool
* @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file
*/
import fs from 'fs'
import path from 'path'

View File

@@ -1,3 +1,7 @@
/**
* @purpose Writer tool
* @description Create a properly formatted Call-to-Action URL with tracking parameters
*/
import { Command } from 'commander'
import readline from 'readline'
import chalk from 'chalk'

View File

@@ -1,3 +1,7 @@
/**
* @purpose Writer tool
* @description Move or rename a file or a folder and automatically add redirects
*/
// @ts-nocheck
// [start-readme]
//

View File

@@ -1,3 +1,7 @@
/**
* @purpose Writer tool
* @description Find all content files that use a specific reusable
*/
// Usage: npm run reusables -- --help
// Usage: npm run reusables -- find used accounts/create-account.md
// Usage: npm run reusables -- find unused accounts/create-account.md

View File

@@ -1,10 +1,7 @@
// [start-readme]
//
// Run this script to update filepaths to match short titles (or titles as a fallback).
// Use
// npm run-script -- update-filepaths --help
//
// [end-readme]
/**
* @purpose Writer tool
* @description Update content filenames to match short titles
*/
import fs from 'fs'
import path from 'path'

View File

@@ -1,5 +1,6 @@
/**
* Development tool that generates a local Table of Contents (TOC) for the GitHub Docs website.
* @purpose Writer tool
* @description Generate a local table of contents for the GitHub Docs website
*
* This script creates static HTML files for each documentation version, renders page titles
* using Liquid templating, and opens the generated TOC in your browser for easy navigation

View File

@@ -1,10 +1,7 @@
#!/usr/bin/env bash
# [start-readme]
#
# This script is run on a writer's machine to begin developing Early Access content locally.
#
# [end-readme]
# @purpose Writer tool
# @description Clone the docs-early-access repo
set -e

View File

@@ -1,10 +1,7 @@
#!/usr/bin/env bash
# [start-readme]
#
# This script is run on a writer's machine to create an Early Access branch that matches the current docs-internal branch.
#
# [end-readme]
# @purpose Writer tool
# @description Create matching branches in docs-early-access and docs-internal
set -e

View File

@@ -1,10 +1,7 @@
// [start-readme]
//
// This script is run on a writer's machine while developing Early Access content locally.
// You must pass the script the location of your local copy of
// the `github/docs-early-access` git repo as the first argument.
//
// [end-readme]
/**
* @purpose Writer tool
* @description Create or destroy symlinks to your local docs-early-access checkout
*/
import { rimraf } from 'rimraf'
import fs from 'fs'

View File

@@ -1,9 +1,7 @@
// [start-readme]
//
// This script is run on a writer's machine while developing Early Access content locally. It
// updates the data and image paths to either include `early-access` or remove it.
//
// [end-readme]
/**
* @purpose Writer tool
* @description Add or remove "early-access" from data and image paths
*/
import fs from 'fs'
import path from 'path'

View File

@@ -1,3 +1,8 @@
/**
* @purpose Writer tool
* @description Get data about a top-level docs product and output a CSV
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

View File

@@ -1,3 +1,8 @@
/**
* @purpose Writer tool
* @description Get a data snapshot of a given Docs URL for the last 30 days or specified period
*/
import fs from 'fs'
import path from 'path'
import { Command } from 'commander'

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env tsx
import { readFileSync } from 'fs'
import { glob } from 'glob'
import path from 'path'
import { fileURLToPath } from 'url'
const __scriptname = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__scriptname)
const PURPOSE_STRING = '@purpose Writer tool'
const DESCRIPTION_STRING = '@description'
const DESCRIPTION_REGEX = new RegExp(`${DESCRIPTION_STRING}\\s+(.+)`)
interface WriterTool {
name: string
description: string
priority?: number // Lower numbers = higher priority
}
interface WriterToolsCollection {
[category: string]: WriterTool[]
}
interface ScriptMetadata {
isWriterTool?: boolean
category?: string
description?: string
}
// Manual entries for scripts that aren't TypeScript files with metadata
const MANUAL_ENTRIES: WriterToolsCollection = {
'Validation and formatting': [
{ name: 'prettier', description: 'Format markdown, YAML, and other files' },
],
Development: [
{ name: 'dev', description: 'Start local development server' },
{ name: 'build', description: 'Build the application' },
],
}
async function discoverWriterTools(): Promise<WriterToolsCollection> {
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json')
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
const tools: WriterToolsCollection = { ...MANUAL_ENTRIES } // Start with manual entries
// First get all files
const allFiles = await glob('src/**/*', {
cwd: path.join(__dirname, '..', '..'),
absolute: true,
ignore: ['**/node_modules/**', '**/tests/**', '**/test/**', '**/.*'],
})
// Then filter for .ts, .js, .sh scripts
const scriptFiles = allFiles.filter((file) => {
if (file === __scriptname) return false // skip the current file
const ext = path.extname(file)
if (['.ts', '.js', '.sh'].includes(ext)) return true
// For extensionless files, check if they're executable or have shebang
if (ext === '') {
try {
const content = readFileSync(file, 'utf8')
return content.startsWith('#!/bin/bash') || content.startsWith('#!/usr/bin/env bash')
} catch {
return false
}
}
return false
})
for (const filePath of scriptFiles) {
try {
const relativePath = path.relative(process.cwd(), filePath)
const content = readFileSync(filePath, 'utf8')
const metadata = extractMetadata(content)
if (metadata.isWriterTool) {
metadata.category = getCategory(relativePath)
// Find corresponding npm script
const scriptName = findScriptName(packageJson.scripts, relativePath)
if (scriptName) {
if (!tools[metadata.category]) tools[metadata.category] = []
// Check if not already added manually
const exists = tools[metadata.category].some((tool) => tool.name === scriptName)
if (!exists) {
tools[metadata.category].push({
name: scriptName,
description: metadata.description || `${scriptName} tool`,
})
}
}
}
} catch {
// Skip files that can't be read
continue
}
}
return tools
}
function extractMetadata(content: string): ScriptMetadata {
const metadata: ScriptMetadata = {}
const lines = content.split('\n').slice(0, 20) // Only check first 20 lines
for (const line of lines) {
if (line.includes(PURPOSE_STRING)) {
metadata.isWriterTool = true
}
if (line.includes(DESCRIPTION_STRING)) {
// Extract description from line like "@description Add content type frontmatter to articles"
const match = line.match(DESCRIPTION_REGEX)
if (match) {
metadata.description = match[1].trim()
}
}
}
return metadata
}
// Convert the DIR in src/DIR/ to a title-cased category name
// E.g. src/secret-scanning becomes Secret Scanning
function getCategory(relativePath: string): string {
const directory = relativePath.split(path.sep)[1]
const category = directory
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
// Clarify this one category
return category.replace('Content Render', 'Content Tasks')
}
function findScriptName(scripts: Record<string, string>, relativePath: string): string | null {
for (const [scriptName, command] of Object.entries(scripts)) {
// Check if the command includes this file path
if (command.includes(relativePath)) {
return scriptName
}
// Also check for simplified paths without the src/ prefix
const simplifiedPath = relativePath.replace(/^src\//, '')
if (command.includes(simplifiedPath)) {
return scriptName
}
}
return null
}
function prioritizeOrder(tools: WriterToolsCollection) {
// Define priorities for specific tools
const priorities = {
'move-content': 1,
'cta-builder': 2,
'lint-content': 1,
docstat: 1,
dev: 1,
}
// Assign priorities to discovered tools
Object.values(tools)
.flat()
.forEach((tool) => {
if (priorities[tool.name as keyof typeof priorities]) {
tool.priority = priorities[tool.name as keyof typeof priorities]
}
})
// Sort each category by priority, then alphabetically
Object.keys(tools).forEach((category) => {
tools[category].sort((a, b) => {
// Items with priority come first
if (a.priority !== undefined && b.priority === undefined) return -1
if (a.priority === undefined && b.priority !== undefined) return 1
// Both have priority: sort by priority value
if (a.priority !== undefined && b.priority !== undefined) {
return a.priority - b.priority
}
// Neither has priority: sort alphabetically
return a.name.localeCompare(b.name)
})
})
return tools
}
async function main(): Promise<void> {
console.log('For more info, run a command with "-- --help".\n')
const tools = prioritizeOrder(await discoverWriterTools())
Object.entries(tools).forEach(([category, scripts]) => {
console.log(`${category}:`)
scripts.forEach((script) => {
const padding = ' '.repeat(Math.max(0, 34 - script.name.length))
console.log(` npm run ${script.name}${padding}# ${script.description}`)
})
console.log('')
})
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
await main()
} catch (error) {
console.error(error)
process.exit(1)
}
}