Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ let package = Package(
],
resources: [
.copy("Fixtures/NonMDFrontmatter"),
.copy("Fixtures/RulesNonMD"),
]
),
]
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ The package also bundles `OKF-concept.schema.json` for OKF v0.1 draft concept fr

## Project Configuration

Project-level md-utils settings live in `.md-utils/md-utils.json`. The rules command group creates and uses this folder to validate Markdown files. JSON Schema validation is one supported rule check, alongside document checks such as required headings and body length limits.
Project-level md-utils settings live in `.md-utils/md-utils.json`. The rules command group creates and uses this folder to validate files. JSON Schema validation is one supported rule check, alongside document checks such as required headings and body length limits.

The md-utils CLI version and md-utils config schema version are independent. `md-utils --version` reports the installed CLI version. The `configVersion` field in `.md-utils/md-utils.json` selects the config schema version used for parsing, validation, and behavior. Existing unversioned configs are treated as legacy config schema `0.1.0`.

Expand Down Expand Up @@ -334,9 +334,9 @@ Config fields:
- `$schema`: Optional editor hint for autocomplete, validation, and IDE IntelliSense. Runtime behavior is not driven by this URL.
- `configVersion`: md-utils config schema version. This is independent from the md-utils CLI version.
- `schemaDirectory`: Directory for JSON Schema files. Defaults to `.md-utils/schemas/`.
- `rules`: Rules that map Markdown files to one or more checks.
- `rules`: Rules that map files to one or more checks.
- `rules[].name`: Unique rule name for `md-utils rules validate <rule-name>`.
- `rules[].match.paths`: Glob patterns matched against project-relative Markdown paths.
- `rules[].match.paths`: Glob patterns matched against project-relative file paths.
- `rules[].match.excludePaths`: Glob patterns excluded after paths match.
- `rules[].match.file`: File metadata predicates. Supported operators are `pathRegex`, `filenameEquals`, `extensionIn`, `modifiedAfter`, and `modifiedBefore`.
- `rules[].match.frontmatter`: Frontmatter field predicates. Supported operators are `equals`, `doesntEqual`, `includes`, `notIncludes`, `hasKey`, `doesntHaveKey`, `regex`, `startsWith`, `endsWith`, `contains`, `empty`, `emptyString`, `emptyArray`, `emptyObject`, `notEmpty`, `in`, `notIn`, numeric comparisons, date/time comparisons, inclusive `between`, and `typeIs`.
Expand Down Expand Up @@ -367,12 +367,26 @@ md-utils rules describe books --format markdown
md-utils rules describe books --format json
md-utils rules validate
md-utils rules validate books
md-utils rules validate --include-non-md
md-utils rules files-matching swift-components --include-non-md
md-utils rules matching Sources/APIClient.swift
md-utils rules remove books
md-utils rules remove books --delete-schema
```

`config init` bootstraps `.md-utils/`, including empty `.md-utils/schemas/` and `.md-utils/types/` directories, without adding a rule or type. `rules add` adds a frontmatter schema rule to existing config. `rules describe` explains which files a rule affects and summarizes every field in the referenced JSON Schema when the rule has one; `--format markdown` emits a docs-friendly summary and `--format json` emits the rule configuration with the embedded schema definition. `rules remove` removes a rule; `--delete-schema` also deletes that rule's schema file when it is not shared by another rule.

Rules project scans remain Markdown-only by default. Use `--include-non-md` with
`rules validate` or `rules files-matching` to include other files selected by
configured paths. An explicit non-Markdown file passed to `rules matching` is
selected automatically, except `.txt`, which requires `--include-non-md`.
Mapped extensions use the same `c-block`, `html-comment`, `python-docstring`,
`powershell-block`, and `lua-block` wrappers documented in
[Frontmatter in Non-Markdown Text Files](docs/common-use-cases.md#frontmatter-in-non-markdown-text-files).
Wrapped YAML supports frontmatter predicates, JMESPath, type hints, and JSON
Schema checks. Raw body predicates operate on wrapper-excluded host text, while
Markdown headings, sections, and wikilinks are explicitly unsupported.

If a file matches multiple rules, all matching checks apply. Files matching no rules are ignored. Invalid YAML frontmatter is reported as an error for matched rules because frontmatter predicates and schema checks cannot proceed.

## GitHub Pages
Expand Down
100 changes: 100 additions & 0 deletions Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ public struct MarkdownRuleChecker: Sendable {
for check in rule.definition.checks {
switch check.predicate {
case .frontmatterSchema(_, let presence):
if let fileExtension = record.unavailableFrontmatterExtension {
diagnostics.append(frontmatterSyntaxUnavailableDiagnostic(
fileExtension: fileExtension,
constraintID: check.id,
severity: check.severity
))
continue
}
guard record.hasFrontmatter else {
if presence == .optional {
skippedChecks += 1
Expand Down Expand Up @@ -339,6 +347,9 @@ public struct MarkdownRuleChecker: Sendable {
unavailableMessage = nil
detail = matched ? "modification date is before \(operand.rawValue)" : "modification date is not before \(operand.rawValue)"
case .frontmatterField(let key, let operation):
if let fileExtension = record.unavailableFrontmatterExtension {
return unavailableFrontmatter(requirement.id, fileExtension: fileExtension)
}
guard record.hasFrontmatter else {
matched = false
unavailableMessage = nil
Expand All @@ -360,6 +371,9 @@ public struct MarkdownRuleChecker: Sendable {
unavailableMessage = nil
detail = matched ? "frontmatter \"\(key)\" matched" : "frontmatter \"\(key)\" did not match"
case .frontmatterJMESPath(let expression):
if let fileExtension = record.unavailableFrontmatterExtension {
return unavailableFrontmatter(requirement.id, fileExtension: fileExtension)
}
guard record.hasFrontmatter else {
matched = false
unavailableMessage = nil
Expand All @@ -377,16 +391,25 @@ public struct MarkdownRuleChecker: Sendable {
unavailableMessage = nil
detail = matched ? "frontmatterQuery matched" : "frontmatterQuery did not match"
case .heading(let predicate):
guard record.supportsMarkdownStructure else {
return unsupportedMarkdownStructure(requirement.id)
}
matched = record.headings.contains { heading in
heading.text == predicate.text && (predicate.level == nil || predicate.level == heading.level)
}
unavailableMessage = nil
detail = matched ? "document heading matched" : "document heading did not match"
case .headingRegularExpression(let pattern):
guard record.supportsMarkdownStructure else {
return unsupportedMarkdownStructure(requirement.id)
}
matched = record.headings.contains { regularExpression(pattern, matches: $0.text) }
unavailableMessage = nil
detail = matched ? "document heading matched regular expression" : "document heading did not match regular expression"
case .section(let heading):
guard record.supportsMarkdownStructure else {
return unsupportedMarkdownStructure(requirement.id)
}
matched = record.headings.contains { $0.text == heading && $0.directContentIsEmpty == false }
unavailableMessage = nil
detail = matched ? "document section matched" : "document section did not match"
Expand All @@ -399,6 +422,9 @@ public struct MarkdownRuleChecker: Sendable {
unavailableMessage = nil
detail = matched ? "document body matched regular expression" : "document body did not match regular expression"
case .wikilink(let target):
guard record.supportsMarkdownStructure else {
return unsupportedMarkdownStructure(requirement.id)
}
let links = WikilinkScanner.scan(record.body)
matched = target.map { expected in links.contains { $0.target == expected } }
?? (links.isEmpty == false)
Expand Down Expand Up @@ -436,6 +462,12 @@ public struct MarkdownRuleChecker: Sendable {
record: AnalyzedMarkdownRecord,
ruleName: String
) throws -> [MarkdownDiagnostic] {
if predicate.requiresMarkdownStructure && record.supportsMarkdownStructure == false {
return [markdownStructureUnsupportedDiagnostic(
constraintID: id,
severity: severity
)]
}
switch predicate {
case .heading(let heading):
guard record.headings.contains(where: {
Expand Down Expand Up @@ -516,6 +548,63 @@ public struct MarkdownRuleChecker: Sendable {
(MarkdownRulePredicateEvidence(id: id, status: .unavailable, message: message), nil)
}

private func unavailableFrontmatter(
_ id: String,
fileExtension: String
) -> (evidence: MarkdownRulePredicateEvidence, diagnostic: MarkdownDiagnostic?) {
let diagnostic = frontmatterSyntaxUnavailableDiagnostic(
fileExtension: fileExtension,
constraintID: id,
severity: .error
)
return (
MarkdownRulePredicateEvidence(id: id, status: .unavailable, message: diagnostic.message),
diagnostic
)
}

private func unsupportedMarkdownStructure(
_ id: String
) -> (evidence: MarkdownRulePredicateEvidence, diagnostic: MarkdownDiagnostic?) {
let diagnostic = markdownStructureUnsupportedDiagnostic(
constraintID: id,
severity: .error
)
return (
MarkdownRulePredicateEvidence(id: id, status: .unavailable, message: diagnostic.message),
diagnostic
)
}

private func frontmatterSyntaxUnavailableDiagnostic(
fileExtension: String,
constraintID: String,
severity: MarkdownDiagnosticSeverity
) -> MarkdownDiagnostic {
MarkdownDiagnostic(
code: "record.frontmatter.syntax-unavailable",
severity: severity,
domain: .frontmatter,
constraintID: constraintID,
location: "frontmatter",
message: "no frontmatter syntax mapping for extension \"\(fileExtension)\""
)
}

private func markdownStructureUnsupportedDiagnostic(
constraintID: String,
severity: MarkdownDiagnosticSeverity
) -> MarkdownDiagnostic {
MarkdownDiagnostic(
code: "record.markdown-structure.unsupported",
severity: severity,
domain: .body,
constraintID: constraintID,
location: "body.structure",
message: "Markdown structural predicates are unsupported for non-Markdown files"
)
}

private func regularExpression(_ pattern: String, matches value: String) -> Bool {
guard let expression = try? NSRegularExpression(pattern: pattern) else { return false }
let range = NSRange(value.startIndex..., in: value)
Expand Down Expand Up @@ -623,6 +712,17 @@ public struct MarkdownRuleChecker: Sendable {
}
}

private extension MarkdownPredicate {
var requiresMarkdownStructure: Bool {
switch self {
case .heading, .headingRelationship, .section:
return true
case .path, .maxBodyLines, .maxBodyWords:
return false
}
}
}

/// Errors raised while selecting a compiled rule for assessment.
public enum MarkdownRuleCheckerError: Error, Equatable, LocalizedError {
case unknownRule(String)
Expand Down
Loading