Skip to content

Commit 2bc2b81

Browse files
📖 [Docs]: Style guides cover image accessibility, PowerShell matching, and requirement IDs (#16)
The coding and spec standards now answer four authoring questions they were silent on: how to make images accessible, which PowerShell operator to reach for when matching text, when to prefer .NET over cmdlets, and how requirement identifiers are written. This closes the gaps left when the PSModule instruction files were retired into a docs site, so contributors and agents keep one complete source of truth. ## New: Image accessibility and link conventions in the Markdown standard The [Markdown standard](https://github.com/MSXOrg/docs/blob/main/src/docs/Coding-Standards/Markdown.md) now states that every image carries descriptive alt text — so it serves screen readers and still says something when the image fails to load — and that a repeated or long link may be written reference-style. It also spells out that code, commands, filenames, and identifiers belong in backticks rather than emphasis. ## New: PowerShell standard — matching, constants, and preferring .NET The [PowerShell standard](https://github.com/MSXOrg/docs/blob/main/src/docs/Coding-Standards/PowerShell/index.md) now covers text matching — `-like` for wildcards, `-match` for regular expressions, and the `-c` prefix for case-sensitive comparison — how to declare a value that must not change with `Set-Variable -Option ReadOnly`, and a **Prefer .NET for the actual work** principle: reach for the .NET base class library on hot paths and where behaviour must be exact, and keep cmdlets for readable glue. This mirrors the ai-platform PowerShell standard. ## New: Requirement identifiers and normative language in Spec-Driven Development [Spec-Driven Development](https://github.com/MSXOrg/docs/blob/main/src/docs/Ways-of-Working/Spec-Driven-Development.md) now defines how requirements are identified and written: functional requirements are `FR1`, `FR2`, `FR3` and non-functional ones `NFR1`, `NFR2`, `NFR3`, each given its own heading with a stable, explicit anchor (`### FR1 — <statement> { #fr1 }`) so it can be referenced as `[FR1](#fr1)` and reworded without breaking the link. Identifiers are append-only — never renumbered or reused, and a removed requirement simply disappears (git holds the history). Requirements are written with the BCP 14 keywords (MUST / SHOULD / MAY, and the rest of the set) so obligations are unambiguous. ## Technical Details Additions land in existing pages, so the standards stay navigable and the generated index is unchanged: - `src/docs/Coding-Standards/Markdown.md` — the "Style beyond the linter" section. - `src/docs/Coding-Standards/PowerShell/index.md` — the "Idioms and pitfalls" section plus a new "Prefer .NET for the actual work" section. - `src/docs/Ways-of-Working/Spec-Driven-Development.md` — the "Requirements" section. - `.github/scripts/Test-DocumentationLink.ps1` — the link checker now recognises explicit `{ #id }` anchors and validates reference-style link definitions, and it uses native .NET (`[System.IO.File]::Exists`, `[System.IO.Path]::Combine`) on its per-link hot path, following the new principle. Requirement identifiers use the `FR`/`NFR` scheme with text-independent [attr_list](https://python-markdown.github.io/extensions/attr_list/) anchors (`{ #fr1 }`), which Zensical renders natively, and normative language anchored to [BCP 14](https://www.rfc-editor.org/info/bcp14) (RFC 2119 + RFC 8174). This supersedes the earlier `F1`/`N1` idea — the PSModule `FR-001`/`NFR-001` non-breaking-hyphen rule does not apply, since these anchors are text-independent. No lint configuration changes — the deliberate markdownlint relaxations (line length, list-marker style, blank-line rules) stand. Verified locally: markdownlint, PSScriptAnalyzer, `Update-DocumentationIndex.ps1 -Check`, and `Test-DocumentationLink.ps1` all pass. <details> <summary>Related issues</summary> - Closes #15 - Origin PSModule/Process-PSModule#349 - Origin PSModule/docs#43 </details>
1 parent dd17a44 commit 2bc2b81

7 files changed

Lines changed: 176 additions & 46 deletions

File tree

‎.github/scripts/Test-DocumentationLink.ps1‎

Lines changed: 154 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
- A heading anchor ('target.md#section', or a same-page '#section') must match
1515
a heading in the target file. Slugs are computed the same way the site's
1616
Markdown processor does, including the '_1', '_2' suffixes for duplicate
17-
headings.
17+
headings; an explicit attr_list id ('## Heading { #id }') is recognised as
18+
the heading's anchor.
1819
1920
External links (http, https, mailto, tel), absolute paths, links inside fenced
2021
code blocks, and links inside inline code spans are ignored on purpose.
@@ -37,27 +38,75 @@ $Root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
3738
$Docs = Join-Path $Root 'src/docs'
3839

3940
function ConvertTo-Slug {
40-
param([string]$Heading)
41-
# Mirror the site's Markdown TOC slugifier (python-markdown default): drop
42-
# non-ASCII, remove punctuation except word characters / whitespace / hyphen,
43-
# lowercase, then collapse whitespace and hyphen runs into a single hyphen.
44-
$ascii = -join ([char[]] $Heading | Where-Object { [int] $_ -lt 128 })
41+
<#
42+
.SYNOPSIS
43+
Convert a heading to the anchor slug the site's Markdown processor emits.
44+
45+
.DESCRIPTION
46+
Mirror python-markdown's default TOC slugifier: drop non-ASCII characters,
47+
remove punctuation except word characters, whitespace, and hyphens,
48+
lowercase the result, then collapse whitespace and hyphen runs into a
49+
single hyphen.
50+
51+
.EXAMPLE
52+
ConvertTo-Slug -Heading 'Prefer .NET for the actual work'
53+
Returns 'prefer-net-for-the-actual-work'.
54+
55+
.OUTPUTS
56+
[string]
57+
#>
58+
[CmdletBinding()]
59+
param(
60+
# The heading text to slugify.
61+
[Parameter(Mandatory)]
62+
[string] $Heading
63+
)
64+
$ascii = $Heading -replace '[^\x00-\x7F]', ''
4565
$clean = ($ascii -replace '[^\w\s-]', '').Trim().ToLowerInvariant()
4666
return ($clean -replace '[\s-]+', '-')
4767
}
4868

4969
function Get-HeadingSlug {
50-
param([string]$Path)
51-
# The anchor slugs a page exposes, matching the duplicate-slug suffixing
52-
# ('_1', '_2', ...) the Markdown processor applies to repeated headings.
70+
<#
71+
.SYNOPSIS
72+
Get the anchor slugs a Markdown file exposes.
73+
74+
.DESCRIPTION
75+
Return each heading's anchor, matching the duplicate-slug suffixing
76+
('_1', '_2', ...) the Markdown processor applies to repeated headings. A
77+
heading may also carry an explicit attr_list id ('## Heading { #id }'),
78+
which the site renderer uses as the anchor verbatim, overriding the text
79+
slug; those are recognised so links to '#id' validate. Fenced code blocks
80+
are skipped.
81+
82+
.EXAMPLE
83+
Get-HeadingSlug -Path ./src/docs/index.md
84+
Returns the anchor slugs and explicit ids defined in index.md.
85+
86+
.OUTPUTS
87+
[System.Collections.Generic.List[string]]
88+
#>
89+
[CmdletBinding()]
90+
param(
91+
# Path to the Markdown file to scan for heading anchors.
92+
[Parameter(Mandatory)]
93+
[string] $Path
94+
)
5395
$slugs = [System.Collections.Generic.List[string]]::new()
5496
$seen = @{}
5597
$inFence = $false
5698
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
5799
if ($line -match '^\s*```') { $inFence = -not $inFence; continue }
58100
if ($inFence) { continue }
59101
if ($line -match '^#{1,6}\s+(.+?)\s*$') {
60-
$base = ConvertTo-Slug $matches[1]
102+
$text = $matches[1]
103+
# An explicit attr_list id ('{ #id }' or '{: #id ... }') wins over
104+
# the text slug, exactly as python-markdown's attr_list assigns it.
105+
if ($text -match '\{\s*:?\s*#([-\w]+)[^}]*\}\s*$') {
106+
$slugs.Add($matches[1])
107+
continue
108+
}
109+
$base = ConvertTo-Slug $text
61110
if (-not $base) { continue }
62111
if ($seen.ContainsKey($base)) { $seen[$base]++; $slugs.Add("${base}_$($seen[$base])") }
63112
else { $seen[$base] = 0; $slugs.Add($base) }
@@ -66,15 +115,98 @@ function Get-HeadingSlug {
66115
return $slugs
67116
}
68117

69-
# Parse each target file's anchors once.
70118
$slugCache = @{}
71119
function Get-CachedSlug {
72-
param([string]$Path)
120+
<#
121+
.SYNOPSIS
122+
Get a file's heading slugs, parsing each file only once.
123+
124+
.DESCRIPTION
125+
Memoise Get-HeadingSlug in the script-scoped $slugCache so a file that is
126+
linked from many places is scanned a single time.
127+
128+
.EXAMPLE
129+
Get-CachedSlug -Path ./src/docs/index.md
130+
Returns index.md's anchor slugs, reading the file only on the first call.
131+
132+
.OUTPUTS
133+
[System.Collections.Generic.List[string]]
134+
#>
135+
[CmdletBinding()]
136+
param(
137+
# Path to the Markdown file whose slugs are wanted.
138+
[Parameter(Mandatory)]
139+
[string] $Path
140+
)
73141
if (-not $slugCache.ContainsKey($Path)) { $slugCache[$Path] = Get-HeadingSlug $Path }
74142
return $slugCache[$Path]
75143
}
76144

77-
$linkPattern = '\[[^\]]*\]\(([^)]+)\)'
145+
function Get-LinkTargetIssue {
146+
<#
147+
.SYNOPSIS
148+
Get the problem with a single relative Markdown link target, if any.
149+
150+
.DESCRIPTION
151+
Validate one inline or reference-style link target: external links,
152+
absolute site paths, and empty targets are ignored; a relative file must
153+
exist; and a '#fragment' must match a heading anchor (case-sensitively)
154+
either in the target file or on the same page. Return a human-readable
155+
message when the target does not resolve, or nothing when it is valid.
156+
157+
.EXAMPLE
158+
Get-LinkTargetIssue -Target '../reference/bar.md#setup' -File $file -Rel 'docs/foo.md' -LineNo 12
159+
Returns a message when bar.md or its '#setup' anchor is missing, otherwise nothing.
160+
161+
.OUTPUTS
162+
[string]
163+
#>
164+
[CmdletBinding()]
165+
param(
166+
# The raw link target - a destination and an optional '#fragment'.
167+
[Parameter(Mandatory)]
168+
[string] $Target,
169+
170+
# The Markdown file the link appears in, used to resolve relative paths.
171+
[Parameter(Mandatory)]
172+
[System.IO.FileInfo] $File,
173+
174+
# The file's repository-relative path, for the reported message.
175+
[Parameter(Mandatory)]
176+
[string] $Rel,
177+
178+
# The 1-based line number the link is on, for the reported message.
179+
[Parameter(Mandatory)]
180+
[int] $LineNo
181+
)
182+
$t = ($Target.Trim() -replace '\s+("[^"]*"|''[^'']*''|\([^)]*\))$', '') -replace '^<', '' -replace '>$', ''
183+
if (-not $t) { return }
184+
if ($t -match '^(https?:|mailto:|tel:|//)') { return }
185+
$path, $frag = $t -split '#', 2
186+
if (-not $path) {
187+
if ($frag -and ($frag -cnotin (Get-CachedSlug $File.FullName))) {
188+
"${Rel}:${LineNo}: '#$frag' - no heading with that anchor on this page"
189+
}
190+
return
191+
}
192+
if ($path.StartsWith('/')) { return } # absolute site path - not resolvable here
193+
$resolved = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($File.DirectoryName, $path))
194+
if (-not ([System.IO.File]::Exists($resolved) -or [System.IO.Directory]::Exists($resolved))) {
195+
"${Rel}:${LineNo}: '$t' - target does not exist"
196+
return
197+
}
198+
if ($frag -and $resolved.EndsWith('.md', [System.StringComparison]::OrdinalIgnoreCase) -and ($frag -cnotin (Get-CachedSlug $resolved))) {
199+
"${Rel}:${LineNo}: '$t' - no heading '#$frag' in the target file"
200+
}
201+
}
202+
203+
# Inline links '[text](target)' and reference-style definitions '[label]: target'.
204+
# The inline target may carry an optional title ("...", '...', or (...)); the
205+
# nested-paren alternative keeps a parenthesised title from being truncated. The
206+
# definition destination is either an angle-bracketed path (which may contain
207+
# spaces) or a bare non-whitespace token.
208+
$linkPattern = '\[[^\]]*\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)'
209+
$refDefPattern = '^\s*\[[^\]]+\]:\s+(<[^>]+>|\S+)'
78210
$broken = [System.Collections.Generic.List[string]]::new()
79211

80212
foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md | Sort-Object FullName)) {
@@ -87,27 +219,16 @@ foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md
87219
if ($inFence) { continue }
88220
# Remove inline code spans so links shown as examples are not validated.
89221
$scrubbed = $line -replace '`[^`]*`', ''
222+
$lineNo = $n + 1
90223
foreach ($m in [regex]::Matches($scrubbed, $linkPattern)) {
91-
$target = $m.Groups[1].Value.Trim() -replace '\s+"[^"]*"$', '' # strip optional link title
92-
if (-not $target) { continue }
93-
if ($target -match '^(https?:|mailto:|tel:|//)') { continue }
94-
$lineNo = $n + 1
95-
$path, $frag = $target -split '#', 2
96-
if (-not $path) {
97-
if ($frag -and ($frag -notin (Get-CachedSlug $file.FullName))) {
98-
$broken.Add("${rel}:${lineNo}: '#$frag' - no heading with that anchor on this page")
99-
}
100-
continue
101-
}
102-
if ($path.StartsWith('/')) { continue } # absolute site path - not resolvable here
103-
$resolved = [System.IO.Path]::GetFullPath((Join-Path $file.DirectoryName $path))
104-
if (-not (Test-Path -LiteralPath $resolved)) {
105-
$broken.Add("${rel}:${lineNo}: '$target' - target does not exist")
106-
continue
107-
}
108-
if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) {
109-
$broken.Add("${rel}:${lineNo}: '$target' - no heading '#$frag' in the target file")
110-
}
224+
$issue = Get-LinkTargetIssue -Target $m.Groups[1].Value -File $file -Rel $rel -LineNo $lineNo
225+
if ($issue) { $broken.Add($issue) }
226+
}
227+
# Reference-style link definitions ('[label]: target') carry a relative
228+
# target too; validate it the same way so those links do not slip past CI.
229+
if ($scrubbed -match $refDefPattern) {
230+
$issue = Get-LinkTargetIssue -Target $matches[1] -File $file -Rel $rel -LineNo $lineNo
231+
if ($issue) { $broken.Add($issue) }
111232
}
112233
}
113234
}

‎src/docs/Coding-Standards/Documentation.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Every public function, command, module, or API carries documentation at its boun
4747

4848
Include, at minimum: what it does, its parameters, what it returns, and at least one example. Examples are worth a paragraph of prose each.
4949

50+
This is a floor, not a ceiling. Internal and private units — helper functions and the scripts that call them — carry the same native-format documentation, so the next maintainer or agent can understand a helper without reading its whole implementation.
51+
5052
## The README is the front door
5153

5254
Every repository has a README that is the single source of truth for what the repository is and does. It is **evergreen** — updated in the same pull request that changes behavior, never as a separate task. A feature that ships without a README update is not done.

‎src/docs/Coding-Standards/Markdown.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ These rules are disabled or widened so they do not flag valid documentation —
5353
- **Use sentence-style headings.**
5454
- **Surround headings, lists, and fenced blocks with a blank line** for readability, even though the linter no longer enforces it.
5555
- **Prefer relative links** within a repository; use the canonical published URL for cross-repository references.
56+
- **Give a repeated or long link a reference-style definition** (`[text][ref]`, with `[ref]: url` listed below) so the prose stays readable and one edit updates every use.
5657
- **Tag every code fence with a language** (` ```bash `, ` ```yaml `) so it is highlighted and converts cleanly when published.
58+
- **Wrap code, commands, filenames, and identifiers in backticks** rather than bold or italic, so they read as code and do not lean on the emphasis the linter now allows freely.
59+
- **Give every image descriptive alt text** — `![what the image shows](diagram.png)` — so it serves screen readers and still says something when the image fails to load; use a relative path for images kept in the repository.
5760

5861
## PowerShell code samples
5962

‎src/docs/Coding-Standards/PowerShell/Functions.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ function Get-UserData {
5454
## Parameters
5555

5656
- **Type every parameter** and validate at the boundary — `[Parameter(Mandatory)]`, `[ValidateSet(...)]`, `[ValidateNotNullOrEmpty()]` — so bad input is rejected early, not deep in the call stack.
57+
- **Give every parameter a `[Parameter()]` attribute**, even when it carries no arguments — it is where `Mandatory`, `ValueFromPipeline`, and the rest attach, and it keeps every parameter declared the same way.
5758
- **Attribute order**, each on its own line: `[Parameter()]`, then validation attributes, then `[ArgumentCompleter()]`, then `[Alias()]`, then the typed declaration.
59+
- **Separate parameters with a blank line**, so each one's inline doc comment, attributes, and typed declaration read as a single block.
5860
- **`[switch]` for boolean flags** — never a `[bool]` parameter.
5961
- **Name every parameter set** with an intent-revealing name when a function has more than one mode; never `Default` or `__AllParameterSets`. Set `DefaultParameterSetName` to the most common intent.
6062

@@ -84,4 +86,4 @@ Send each kind of message to the stream built for it, so a caller can capture, r
8486

8587
## Comment-based help (required)
8688

87-
Every public function carries comment-based help, first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*.
89+
Every function carries comment-based help — including internal and private helpers, not only the public surface. It is what lets a reader or an agent understand what the function does and how to call it without reading its body, and a private helper needs that as much as a public command does. Put it first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*.

‎src/docs/Coding-Standards/PowerShell/Scripts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ A script (`.ps1`) is an entry point, not a home for logic. Keep scripts **thin**
1212
A script file is laid out top to bottom in this order:
1313

1414
1. **`#Requires`** statements — PowerShell version and module dependencies with minimum versions.
15-
2. **Comment-based help** — `.SYNOPSIS`, `.DESCRIPTION`, and at least one `.EXAMPLE`.
15+
2. **Comment-based help** — the same sections and order as a [function's](Functions.md#comment-based-help-required), only without the enclosing `function` block: `.SYNOPSIS`, `.DESCRIPTION`, at least one `.EXAMPLE`, then `.INPUTS`, `.OUTPUTS`, `.NOTES`, and `.LINK` as they apply. Document each parameter with an inline comment above it, just as a function does.
1616
3. **`[CmdletBinding()]` + `param()`** — typed and validated, mandatory first; add `SupportsShouldProcess` when the script changes state.
1717
4. **`$ErrorActionPreference = 'Stop'`**.
1818
5. **Body** — the thin orchestration.

‎src/docs/Coding-Standards/PowerShell/index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ Beyond the basics, these language-specific habits keep PowerShell correct and fa
5757
- **Single-quote strings unless you need expansion.** Use `'literal'` by default; reserve `"...$var..."` for interpolation or escape sequences, and here-strings (`@'...'@`, `@"..."@`) for multi-line text — literal-versus-interpolated intent then stays obvious.
5858
- **Splat calls that carry many parameters.** Build a `@{}` of parameters and splat it (`Get-Thing @params`) instead of a long line of `-Param value` pairs or backtick continuations; it reads better and diffs cleanly.
5959
- **Put `$null` on the left of a comparison** — `$null -eq $x`, never `$x -eq $null`. Against a collection the right-hand form *filters* rather than tests. Use `-contains` / `-in` for membership, never `-eq`.
60+
- **Match text with the operator built for it.** Use `-like` for wildcard patterns and `-match` for regular expressions instead of hand-rolled string surgery; both default to case-insensitive, so add the `-c` prefix (`-clike`, `-cmatch`, `-ceq`) when a comparison must be case-sensitive.
6061
- **Reuse before you build.** Work down the [reuse order](../Functions.md#reuse-before-you-build) — a built-in cmdlet or operator, then an existing function (public or private), then a trusted module (`#Requires -Modules` / `RequiredModules`), then your own code (small logic inline, a larger capability as its own module).
6162
- **PowerShell already *is* .NET; work at that level rather than wrapping it.** Casts, type accelerators (`[datetime]`, `[int]`), the `-split` / `-replace` / `-match` operators, and member methods (`.Trim()`, `.Where()`) all resolve to the base class library — using .NET means reaching for BCL types and methods for the computation, not restating everything as `[Namespace.Type]::Method(...)`. Where idiomatic PowerShell already resolves to the same .NET call, leave it; reach for explicit .NET only where it is measurably faster or more precise, and keep cmdlets and the pipeline where you need them for glue or readability.
6263
- **Do the work in .NET when you implement it.** When you write the logic yourself — or fix an internal function that is too slow or imprecise on a hot path — call the .NET base class library directly instead of a cmdlet pipeline: `[System.IO.File]::ReadAllText($path)` over `Get-Content -Raw`, `[System.IO.Path]::Combine(...)` for paths, `[System.Text.StringBuilder]` for repeated concatenation, `[int]::TryParse(...)` for parsing. .NET methods are faster and their contracts are precise; keep cmdlets where their clarity is worth more than the speed. The next two rules are specific cases.
6364
- **Suppress unwanted output with `$null = ...`** (or `[void]` for method calls), not `| Out-Null` — the pipeline form is markedly slower on hot paths.
6465
- **Build collections with a typed list, not `+=` in a loop.** `$a += $x` reallocates the whole array every iteration; use `[System.Collections.Generic.List[T]]` with `.Add()`, and prefer a cmdlet's `-Filter` over piping to `Where-Object` on large sets.
66+
- **Guard a value that must not change.** Declare it with `Set-Variable -Name Pi -Value 3.14159 -Option ReadOnly` — or `-Option Constant` for one that can never be reassigned or removed — so an accidental write fails loudly instead of quietly winning.
6567
- **Keep secrets out of source, and never `Invoke-Expression` untrusted input.** Accept credentials as a `[PSCredential]` parameter with the `[Credential()]` attribute rather than calling `Get-Credential` inside a reusable function, so a caller can pass one they already hold, and take other sensitive values as `[securestring]`. Guard state-changing commands with `ShouldProcess` (see [Functions](Functions.md)); the wider rules live in the [Security](../Security.md) baseline.
6668

6769
## Toolchain

0 commit comments

Comments
 (0)