Skip to content

Commit 3ef11bc

Browse files
jc-clarksarahs
andauthored
Add application/platform card template to templates article (#59724)
Co-authored-by: Sarah Schneider <sarahs@github.com>
1 parent d3114e4 commit 3ef11bc

File tree

7 files changed

+900
-0
lines changed

7 files changed

+900
-0
lines changed

.github/instructions/content.instructions.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ Examples:
7272
* ❌ Incorrect: `For more information, see [Using GitHub Copilot](/copilot/using-github-copilot).`
7373
* ❌ Incorrect: `For more information, see {% link /copilot/using-github-copilot %}.`
7474

75+
## RAI application and platform cards
76+
77+
Articles with `contentType: rai` in their frontmatter are **application or platform cards**—legally mandated documents describing the responsible use of AI-powered features. The content linter enforces the required section structure (GHD064) and reusable isolation (GHD035).
78+
79+
* **Template**: See `content/contributing/writing-for-github-docs/templates.md` for the full application/platform card template with all required sections and boilerplate reusables.
80+
* **Reusables**: RAI articles must only reference reusables from `data/reusables/rai/`. Place new RAI reusables there too.
81+
* **Frontmatter**: New application cards use `contentType: rai`. The older `type: rai` is for legacy transparency notes not yet migrated.
82+
7583
## Parenthetical dashes
7684

7785
Where a sentence of normal body text contains a parenthetical dash, the dash should always be an em dash without spaces at either side. This rule does not apply to text within code blocks.

content/contributing/writing-for-github-docs/templates.md

Lines changed: 327 additions & 0 deletions
Large diffs are not rendered by default.

data/reusables/contributing/content-linter-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
| GHD061 | frontmatter-hero-image | Hero image paths must be absolute, extensionless, and point to valid images in /assets/images/banner-images/ | error | frontmatter, images |
6666
| GHD062 | frontmatter-intro-links | introLinks keys must be valid keys defined in data/ui.yml under product_landing | error | frontmatter, single-source |
6767
| GHD063 | frontmatter-children | Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion. | error | frontmatter, children |
68+
| GHD064 | rai-app-card-structure | RAI application/platform card articles must follow the required template structure | warning | feature, rai |
6869
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon-<icon-name> | The octicon liquid syntax used is deprecated. Use this format instead `octicon "<octicon-name>" aria-label="<Octicon aria label>"` | error | |
6970
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | |
7071
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |

src/content-linter/lib/linting-rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
5454
import { frontmatterHeroImage } from './frontmatter-hero-image'
5555
import { frontmatterIntroLinks } from './frontmatter-intro-links'
5656
import { frontmatterChildren } from './frontmatter-children'
57+
import { raiAppCardStructure } from '@/content-linter/lib/linting-rules/rai-app-card-structure'
5758

5859
// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
5960
// The elements in the array have a 'names' property that contains rule identifiers
@@ -119,6 +120,7 @@ export const gitHubDocsMarkdownlint = {
119120
frontmatterHeroImage, // GHD061
120121
frontmatterIntroLinks, // GHD062
121122
frontmatterChildren, // GHD063
123+
raiAppCardStructure, // GHD064
122124

123125
// Search-replace rules
124126
searchReplace, // Open-source plugin
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
4+
import { addError } from 'markdownlint-rule-helpers'
5+
6+
import { getFrontmatter } from '../helpers/utils'
7+
import type { RuleParams, RuleErrorCallback, Rule } from '../../types'
8+
9+
// ---------------------------------------------------------------------------
10+
// Template parser — derives all validation data from templates.md
11+
// ---------------------------------------------------------------------------
12+
13+
const TEMPLATES_PATH = path.resolve('content/contributing/writing-for-github-docs/templates.md')
14+
const SENTINEL = '<!-- rai-card-template-source'
15+
const OPTIONAL_MARKER = '<!-- optional-section -->'
16+
const PLACEHOLDER = 'APPLICATION-OR-PLATFORM-SERVICE'
17+
18+
interface TemplateHeading {
19+
level: number
20+
text: string
21+
/** Regex pattern for matching this heading in actual articles. */
22+
pattern: RegExp
23+
/** Human-readable label for error messages. */
24+
label: string
25+
/** If true, the section may be removed from a real article. */
26+
optional: boolean
27+
/** For H3 headings, the pattern of the parent H2. */
28+
parentPattern?: RegExp
29+
}
30+
31+
export interface ParsedTemplate {
32+
h2s: TemplateHeading[]
33+
h3s: TemplateHeading[]
34+
reusables: string[]
35+
}
36+
37+
/**
38+
* Extract the RAI card template code block from templates.md.
39+
* Finds the sentinel HTML comment, then captures the next fenced code block.
40+
* Strips {% raw %} / {% endraw %} and {% comment %}...{% endcomment %} blocks.
41+
*/
42+
function extractTemplateBlock(): string {
43+
const content = fs.readFileSync(TEMPLATES_PATH, 'utf-8')
44+
const sentinelIndex = content.indexOf(SENTINEL)
45+
if (sentinelIndex === -1) {
46+
throw new Error(
47+
`Could not find "${SENTINEL}" marker in ${TEMPLATES_PATH}. ` +
48+
'This marker is required for the GHD064 linter rule.',
49+
)
50+
}
51+
52+
const afterSentinel = content.slice(sentinelIndex)
53+
const codeBlockMatch = afterSentinel.match(/```yaml\s*\n([\s\S]*?)```/)
54+
if (!codeBlockMatch) {
55+
throw new Error(
56+
`Could not find a fenced yaml code block after the "${SENTINEL}" marker in ${TEMPLATES_PATH}.`,
57+
)
58+
}
59+
60+
return codeBlockMatch[1]
61+
.replace(/\{%\s*raw\s*%\}\s*/, '')
62+
.replace(/\{%\s*endraw\s*%\}\s*/, '')
63+
.replace(/\{%\s*comment\s*%\}[\s\S]*?\{%\s*endcomment\s*%\}/g, '')
64+
}
65+
66+
/**
67+
* Build a regex pattern from a template heading text.
68+
* Headings containing the placeholder get a pattern that matches any text
69+
* in place of the placeholder. Fixed headings get an exact match.
70+
*/
71+
function headingToPattern(text: string): RegExp {
72+
if (text.includes(PLACEHOLDER)) {
73+
const escaped = text
74+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
75+
.replace(new RegExp(PLACEHOLDER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '.+')
76+
return new RegExp(`^${escaped}$`, 'i')
77+
}
78+
return new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i')
79+
}
80+
81+
/**
82+
* Build a human-readable label for error messages.
83+
* Replaces the placeholder with "..." to keep messages concise.
84+
*/
85+
function headingLabel(level: number, text: string): string {
86+
const prefix = '#'.repeat(level)
87+
const label = text.includes(PLACEHOLDER) ? text.replace(PLACEHOLDER, '...') : text
88+
return `${prefix} ${label}`
89+
}
90+
91+
/**
92+
* Parse the RAI template into structured heading and reusable data.
93+
* This is the single source of truth for validation — no hardcoded constants.
94+
*/
95+
function parseTemplate(): ParsedTemplate {
96+
const block = extractTemplateBlock()
97+
const lines = block.split('\n')
98+
99+
const h2s: TemplateHeading[] = []
100+
const h3s: TemplateHeading[] = []
101+
const reusables: string[] = []
102+
103+
let currentH2Pattern: RegExp | undefined
104+
let nextIsOptional = false
105+
106+
for (const line of lines) {
107+
if (line.trim() === OPTIONAL_MARKER) {
108+
nextIsOptional = true
109+
continue
110+
}
111+
112+
const h2Match = line.match(/^## (.+)$/)
113+
if (h2Match) {
114+
const text = h2Match[1].trim()
115+
const pattern = headingToPattern(text)
116+
currentH2Pattern = pattern
117+
h2s.push({
118+
level: 2,
119+
text,
120+
pattern,
121+
label: headingLabel(2, text),
122+
optional: nextIsOptional,
123+
})
124+
nextIsOptional = false
125+
continue
126+
}
127+
128+
const h3Match = line.match(/^### (.+)$/)
129+
if (h3Match) {
130+
const text = h3Match[1].trim()
131+
h3s.push({
132+
level: 3,
133+
text,
134+
pattern: headingToPattern(text),
135+
label: headingLabel(3, text),
136+
optional: nextIsOptional,
137+
parentPattern: currentH2Pattern,
138+
})
139+
nextIsOptional = false
140+
continue
141+
}
142+
143+
// Parse reusable references
144+
const reusableMatches = line.matchAll(/\{%\s*data\s+([\w.-]+)\s*%\}/g)
145+
for (const m of reusableMatches) {
146+
if (!reusables.includes(m[1])) {
147+
reusables.push(m[1])
148+
}
149+
}
150+
151+
// Reset optional flag if line has non-whitespace content (not a heading or marker)
152+
if (line.trim() !== '') {
153+
nextIsOptional = false
154+
}
155+
}
156+
157+
return { h2s, h3s, reusables }
158+
}
159+
160+
// Lazy singleton — parsed once on first use
161+
let _parsed: ParsedTemplate | null = null
162+
163+
export function getTemplate(): ParsedTemplate {
164+
if (!_parsed) _parsed = parseTemplate()
165+
return _parsed
166+
}
167+
168+
// ---------------------------------------------------------------------------
169+
// File-level heading extraction
170+
// ---------------------------------------------------------------------------
171+
172+
interface Heading {
173+
level: number
174+
text: string
175+
lineNumber: number
176+
}
177+
178+
function extractHeadings(lines: string[]): Heading[] {
179+
const headings: Heading[] = []
180+
for (let i = 0; i < lines.length; i++) {
181+
const match = lines[i].match(/^(#{1,6})\s+(.+)$/)
182+
if (match) {
183+
headings.push({
184+
level: match[1].length,
185+
text: match[2].trim(),
186+
lineNumber: i + 1,
187+
})
188+
}
189+
}
190+
return headings
191+
}
192+
193+
// ---------------------------------------------------------------------------
194+
// Validators
195+
// ---------------------------------------------------------------------------
196+
197+
/**
198+
* Validate required H2 sections exist and appear in the correct order.
199+
*/
200+
function validateH2Sections(
201+
headings: Heading[],
202+
template: ParsedTemplate,
203+
onError: RuleErrorCallback,
204+
): void {
205+
const h2Headings = headings.filter((h) => h.level === 2)
206+
let lastMatchedIndex = -1
207+
208+
for (const required of template.h2s) {
209+
const matchIndex = h2Headings.findIndex((h) => required.pattern.test(h.text))
210+
211+
if (matchIndex === -1) {
212+
addError(
213+
onError,
214+
1,
215+
`Missing required section: ${required.label}. RAI application/platform cards must include all sections defined in the template.`,
216+
undefined,
217+
undefined,
218+
null,
219+
)
220+
} else if (matchIndex <= lastMatchedIndex) {
221+
addError(
222+
onError,
223+
h2Headings[matchIndex].lineNumber,
224+
`Section out of order: ${required.label}. RAI application/platform card sections must appear in the order defined by the template.`,
225+
h2Headings[matchIndex].text,
226+
undefined,
227+
null,
228+
)
229+
} else {
230+
lastMatchedIndex = matchIndex
231+
}
232+
}
233+
}
234+
235+
/**
236+
* Validate H3 subsections in a single pass: required ones must exist,
237+
* and all H3s under structured parents must match a known template heading.
238+
*/
239+
function validateH3Subsections(
240+
headings: Heading[],
241+
template: ParsedTemplate,
242+
onError: RuleErrorCallback,
243+
): void {
244+
// Group template H3s by parent pattern (keyed by pattern source string to
245+
// avoid relying on RegExp reference equality).
246+
const h3sByParent = new Map<string, { parentPattern: RegExp; h3s: TemplateHeading[] }>()
247+
for (const h3 of template.h3s) {
248+
if (!h3.parentPattern) continue
249+
const key = h3.parentPattern.source
250+
const entry = h3sByParent.get(key) || { parentPattern: h3.parentPattern, h3s: [] }
251+
entry.h3s.push(h3)
252+
h3sByParent.set(key, entry)
253+
}
254+
255+
for (const [, { parentPattern, h3s: templateH3s }] of h3sByParent) {
256+
const parentIndex = headings.findIndex((h) => h.level === 2 && parentPattern.test(h.text))
257+
if (parentIndex === -1) continue // Missing parent caught by validateH2Sections
258+
259+
// Collect actual H3s under this parent
260+
const childH3s: Heading[] = []
261+
for (let i = parentIndex + 1; i < headings.length; i++) {
262+
if (headings[i].level <= 2) break
263+
if (headings[i].level === 3) childH3s.push(headings[i])
264+
}
265+
266+
// Check required H3s exist
267+
for (const required of templateH3s) {
268+
if (required.optional) continue
269+
const found = childH3s.some((h) => required.pattern.test(h.text))
270+
if (!found) {
271+
addError(
272+
onError,
273+
headings[parentIndex].lineNumber,
274+
`Missing required subsection: ${required.label}. This subsection is required under the parent section per the RAI application/platform card template.`,
275+
undefined,
276+
undefined,
277+
null,
278+
)
279+
}
280+
}
281+
282+
// Check all actual H3s match a known template heading
283+
for (const child of childH3s) {
284+
const matchesKnown = templateH3s.some((t) => t.pattern.test(child.text))
285+
if (!matchesKnown) {
286+
addError(
287+
onError,
288+
child.lineNumber,
289+
`Unexpected subsection heading: "${child.text}". H3 headings under this section must match the RAI application/platform card template exactly.`,
290+
child.text,
291+
undefined,
292+
null,
293+
)
294+
}
295+
}
296+
}
297+
}
298+
299+
/**
300+
* Validate that all required boilerplate reusable references are present.
301+
*/
302+
function validateReusables(
303+
lines: string[],
304+
template: ParsedTemplate,
305+
onError: RuleErrorCallback,
306+
): void {
307+
const content = lines.join('\n')
308+
309+
for (const reusable of template.reusables) {
310+
if (!content.includes(reusable)) {
311+
addError(
312+
onError,
313+
1,
314+
`Missing required boilerplate reusable: {% data ${reusable} %}. This reusable contains legally-mandated text that must be included in all RAI application/platform cards.`,
315+
undefined,
316+
undefined,
317+
null,
318+
)
319+
}
320+
}
321+
}
322+
323+
// ---------------------------------------------------------------------------
324+
// Rule export
325+
// ---------------------------------------------------------------------------
326+
327+
interface Frontmatter {
328+
contentType?: string
329+
type?: string
330+
[key: string]: unknown
331+
}
332+
333+
function isFileRaiCard(params: RuleParams): boolean {
334+
const fm: Frontmatter = (getFrontmatter(params.frontMatterLines) as Frontmatter) || {}
335+
return fm.contentType === 'rai' || fm.type === 'rai'
336+
}
337+
338+
export const raiAppCardStructure: Rule = {
339+
names: ['GHD064', 'rai-app-card-structure'],
340+
description: 'RAI application/platform card articles must follow the required template structure',
341+
tags: ['feature', 'rai'],
342+
function: (params: RuleParams, onError: RuleErrorCallback) => {
343+
if (!isFileRaiCard(params)) return
344+
345+
const template = getTemplate()
346+
const headings = extractHeadings(params.lines)
347+
validateH2Sections(headings, template, onError)
348+
validateH3Subsections(headings, template, onError)
349+
validateReusables(params.lines, template, onError)
350+
},
351+
}

0 commit comments

Comments
 (0)