diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 579cff9..2860f30 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -21,7 +21,7 @@ concurrency: cancel-in-progress: false env: - CURRENT_MD_UTILS_JSONSCHEMA_VERSION: 0.2.0 + CURRENT_MD_UTILS_JSONSCHEMA_VERSION: 0.2.1 jobs: deploy: diff --git a/.github/workflows/schema-publication.yml b/.github/workflows/schema-publication.yml index 6ff8029..a13e754 100644 --- a/.github/workflows/schema-publication.yml +++ b/.github/workflows/schema-publication.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Validate schema publication files run: python3 scripts/validate-schema-publication.py @@ -33,7 +35,6 @@ jobs: - name: Guard immutable published schema versions if: github.event_name == 'pull_request' run: | - git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" changed_files="$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD -- 'site/schemas/*/*.json')" if [ -z "$changed_files" ]; then exit 0 diff --git a/Package.swift b/Package.swift index b5afd50..d5c158f 100644 --- a/Package.swift +++ b/Package.swift @@ -114,6 +114,7 @@ let package = Package( .process("Resources/SKILL.md"), .process("Resources/0.1.0_md-utils.schema.json"), .process("Resources/0.2.0_md-utils.schema.json"), + .process("Resources/0.2.1_md-utils.schema.json"), .process("Resources/1_md-utils-type.schema.json"), .process("Resources/OKF-concept.schema.json"), ] diff --git a/README.md b/README.md index 873a315..9f7bd4f 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,9 @@ Compatibility: | CLI version | Supported config schema versions | Default generated config schema version | | --- | --- | --- | -| 0.1.0-alpha | 0.1.0, 0.2.0 | 0.2.0 | +| 0.1.0-alpha | 0.1.0, 0.2.0, 0.2.1 | 0.2.1 | -The config schema is published at `https://dandylyons.github.io/md-utils/schemas/0.2.0/md-utils.schema.json` for editor integration and IDE IntelliSense. The moving `https://dandylyons.github.io/md-utils/schemas/latest/md-utils.schema.json` alias points to the latest published schema, and `https://dandylyons.github.io/md-utils/md-utils.schema.json` is maintained as a root compatibility alias. Runtime config validation does not fetch these URLs; the CLI validates configs against bundled schemas selected by `configVersion`. +The config schema is published at `https://dandylyons.github.io/md-utils/schemas/0.2.1/md-utils.schema.json` for editor integration and IDE IntelliSense. The moving `https://dandylyons.github.io/md-utils/schemas/latest/md-utils.schema.json` alias points to the latest published schema, and `https://dandylyons.github.io/md-utils/md-utils.schema.json` is maintained as a root compatibility alias. Runtime config validation does not fetch these URLs; the CLI validates configs against bundled schemas selected by `configVersion`. Treat the directory containing `.md-utils/` as the md-utils project root. Commands that use project configuration read `.md-utils/md-utils.json` relative to the current working directory; md-utils does not search parent directories for project configuration. Run rules/config commands from the directory that contains `.md-utils/`: @@ -281,8 +281,8 @@ Example config: ```json { - "$schema": "https://dandylyons.github.io/md-utils/schemas/0.2.0/md-utils.schema.json", - "configVersion": "0.2.0", + "$schema": "https://dandylyons.github.io/md-utils/schemas/0.2.1/md-utils.schema.json", + "configVersion": "0.2.1", "schemaDirectory": ".md-utils/schemas/", "rules": [ { @@ -334,6 +334,7 @@ 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/`. +- `frontmatter`: Optional wrapped-frontmatter presets, named syntaxes, and extension mappings for explicitly opted-in non-Markdown scans. - `rules`: Rules that map Markdown files to one or more checks. - `rules[].name`: Unique rule name for `md-utils rules validate `. - `rules[].match.paths`: Glob patterns matched against project-relative Markdown paths. @@ -392,9 +393,12 @@ site/ 0.2.0/ md-utils.schema.json md-utils-0.2.0.schema.json + 0.2.1/ + md-utils.schema.json + md-utils-0.2.1.schema.json ``` -The bundled CLI schema in `Sources/md-utils/Resources/0.2.0_md-utils.schema.json` remains canonical for CLI behavior for config schema `0.2.0`. Public schema copies must match it exactly, so run `python3 scripts/validate-schema-publication.py` before publishing schema changes. +The bundled CLI schema in `Sources/md-utils/Resources/0.2.1_md-utils.schema.json` is canonical for CLI behavior for config schema `0.2.1`. Public schema copies must match it exactly, so run `python3 scripts/validate-schema-publication.py` before publishing schema changes. When the Pages workflow prepares its artifact, it copies `site/schemas/$CURRENT_MD_UTILS_JSONSCHEMA_VERSION/md-utils.schema.json` to both `md-utils.schema.json` at the site root and `schemas/latest/md-utils.schema.json`. Versioned schema URLs are immutable after release. For future schema releases, add a new versioned folder under `site/schemas/`, update `CURRENT_MD_UTILS_JSONSCHEMA_VERSION` in `.github/workflows/pages.yml`, and keep the canonical bundled schema synchronized with the new published copy. Do not edit already-published versioned schema files; publish a new version instead. diff --git a/Sources/MarkdownUtilitiesCore/Documentation.docc/RuleConfigurationVersions.md b/Sources/MarkdownUtilitiesCore/Documentation.docc/RuleConfigurationVersions.md index ab227e8..1c8c91f 100644 --- a/Sources/MarkdownUtilitiesCore/Documentation.docc/RuleConfigurationVersions.md +++ b/Sources/MarkdownUtilitiesCore/Documentation.docc/RuleConfigurationVersions.md @@ -4,7 +4,7 @@ Normalize supported serialized configurations into one executable model. ## Supported Versions -``MarkdownRuleConfigurationDecoder`` accepts configuration versions `0.1.0` and `0.2.0`. Legacy `schemaRules`, `schema`, and `frontmatterRequired` fields normalize into the same ``MarkdownRuleDefinition`` and ``MarkdownRuleCheck`` values as version `0.2.0` `rules` and `checks`. +``MarkdownRuleConfigurationDecoder`` accepts configuration versions `0.1.0`, `0.2.0`, and `0.2.1`. Legacy `schemaRules`, `schema`, and `frontmatterRequired` fields normalize into the same ``MarkdownRuleDefinition`` and ``MarkdownRuleCheck`` values as the `rules` and `checks` used by versions `0.2.0` and `0.2.1`. Version `0.2.1` additionally carries optional wrapped-frontmatter syntax and extension configuration. Unknown versions, unknown keys, invalid operands, and fields that cannot be represented without changing behavior fail with ``MarkdownRuleConfigurationError``. The decoder never drops an unsupported field. Grouping syntax and a `0.3.0` format are not part of this model. diff --git a/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatterConfiguration.swift b/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatterConfiguration.swift new file mode 100644 index 0000000..f3d9763 --- /dev/null +++ b/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatterConfiguration.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Project-level named syntaxes and extension mappings for wrapped frontmatter. +public struct WrappedFrontMatterProjectConfiguration: Equatable, Sendable { + public var useBuiltInPresets: Bool + public var syntaxes: [String: WrappedFrontMatterSyntax] + public var extensionMappings: [String: String] + + public init( + useBuiltInPresets: Bool = true, + syntaxes: [String: WrappedFrontMatterSyntax] = [:], + extensionMappings: [String: String] = [:] + ) throws { + let builtInNames = Set(WrappedFrontMatterPreset.allCases.map(\.rawValue)) + for name in syntaxes.keys { + guard name.isEmpty == false else { + throw WrappedFrontMatterConfigurationError.emptySyntaxName + } + guard builtInNames.contains(name) == false else { + throw WrappedFrontMatterConfigurationError.reservedSyntaxName(name) + } + } + + var normalizedMappings: [String: String] = [:] + for (pathExtension, syntaxName) in extensionMappings { + let normalizedExtension = Self.normalize(pathExtension) + guard normalizedExtension.isEmpty == false else { + throw WrappedFrontMatterConfigurationError.emptyExtension + } + guard syntaxName.isEmpty == false else { + throw WrappedFrontMatterConfigurationError.emptyMappedSyntax(pathExtension) + } + guard syntaxes[syntaxName] != nil || builtInNames.contains(syntaxName) else { + throw WrappedFrontMatterConfigurationError.unknownSyntax(syntaxName) + } + guard normalizedMappings[normalizedExtension] == nil else { + throw WrappedFrontMatterConfigurationError.duplicateExtension(normalizedExtension) + } + normalizedMappings[normalizedExtension] = syntaxName + } + + self.useBuiltInPresets = useBuiltInPresets + self.syntaxes = syntaxes + self.extensionMappings = normalizedMappings + } + + /// Resolves a built-in or project-defined syntax by name. + public func syntax(named name: String) -> WrappedFrontMatterSyntax? { + if let custom = syntaxes[name] { + return custom + } + return WrappedFrontMatterPreset(rawValue: name)?.syntax + } + + /// Resolves the project mapping first, then a built-in extension preset when enabled. + public func inferredSyntax(forExtension pathExtension: String) -> WrappedFrontMatterSyntax? { + let normalizedExtension = Self.normalize(pathExtension) + if let name = extensionMappings[normalizedExtension] { + return syntax(named: name) + } + guard useBuiltInPresets, + let preset = WrappedFrontMatterPreset.inferred(forExtension: normalizedExtension) + else { + return nil + } + return preset.syntax + } + + private static func normalize(_ pathExtension: String) -> String { + pathExtension + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + } +} + +/// Invalid project-level wrapped-frontmatter configuration. +public enum WrappedFrontMatterConfigurationError: Error, Equatable, LocalizedError { + case emptySyntaxName + case reservedSyntaxName(String) + case emptyExtension + case emptyMappedSyntax(String) + case unknownSyntax(String) + case duplicateExtension(String) + + public var errorDescription: String? { + switch self { + case .emptySyntaxName: + return "A wrapped frontmatter syntax name cannot be empty" + case .reservedSyntaxName(let name): + return "A project syntax cannot replace the built-in syntax \"\(name)\"" + case .emptyExtension: + return "A wrapped frontmatter extension mapping cannot use an empty extension" + case .emptyMappedSyntax(let pathExtension): + return "The wrapped frontmatter mapping for \"\(pathExtension)\" requires a syntax name" + case .unknownSyntax(let name): + return "Unknown wrapped frontmatter syntax \"\(name)\"" + case .duplicateExtension(let pathExtension): + return "Duplicate wrapped frontmatter extension mapping for \"\(pathExtension)\"" + } + } +} diff --git a/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatterParser.swift b/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatterParser.swift new file mode 100644 index 0000000..cdc5a0c --- /dev/null +++ b/Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatterParser.swift @@ -0,0 +1,300 @@ +import Foundation +import Parsing + +/// Delimiters that wrap one YAML frontmatter block in a non-Markdown text file. +/// +/// The inner YAML delimiters are always `---` and are intentionally not configurable. +public struct WrappedFrontMatterSyntax: Equatable, Sendable { + public let openingCommentDelimiter: String + public let closingCommentDelimiter: String + + public init( + openingCommentDelimiter: String, + closingCommentDelimiter: String + ) throws { + guard openingCommentDelimiter.isEmpty == false else { + throw WrappedFrontMatterSyntaxError.emptyOpeningDelimiter + } + guard closingCommentDelimiter.isEmpty == false else { + throw WrappedFrontMatterSyntaxError.emptyClosingDelimiter + } + guard openingCommentDelimiter.contains(where: \.isNewline) == false else { + throw WrappedFrontMatterSyntaxError.multilineOpeningDelimiter + } + guard closingCommentDelimiter.contains(where: \.isNewline) == false else { + throw WrappedFrontMatterSyntaxError.multilineClosingDelimiter + } + self.openingCommentDelimiter = openingCommentDelimiter + self.closingCommentDelimiter = closingCommentDelimiter + } + + private init(uncheckedOpening: String, uncheckedClosing: String) { + self.openingCommentDelimiter = uncheckedOpening + self.closingCommentDelimiter = uncheckedClosing + } + + public static let cBlock = Self( + uncheckedOpening: "/*", + uncheckedClosing: "*/" + ) + + public static let htmlComment = Self( + uncheckedOpening: "" + ) + + public static let pythonDocstring = Self( + uncheckedOpening: "\"\"\"", + uncheckedClosing: "\"\"\"" + ) + + public static let powershellBlock = Self( + uncheckedOpening: "<#", + uncheckedClosing: "#>" + ) + + public static let luaBlock = Self( + uncheckedOpening: "--[[", + uncheckedClosing: "]]" + ) +} + +/// Invalid host-language delimiters for wrapped frontmatter. +public enum WrappedFrontMatterSyntaxError: Error, Equatable, LocalizedError { + case emptyOpeningDelimiter + case emptyClosingDelimiter + case multilineOpeningDelimiter + case multilineClosingDelimiter + + public var errorDescription: String? { + switch self { + case .emptyOpeningDelimiter: + return "The opening frontmatter comment delimiter cannot be empty" + case .emptyClosingDelimiter: + return "The closing frontmatter comment delimiter cannot be empty" + case .multilineOpeningDelimiter: + return "The opening frontmatter comment delimiter must fit on one line" + case .multilineClosingDelimiter: + return "The closing frontmatter comment delimiter must fit on one line" + } + } +} + +/// Built-in host-language wrappers used by extension inference. +public enum WrappedFrontMatterPreset: String, CaseIterable, Sendable { + case cBlock = "c-block" + case htmlComment = "html-comment" + case pythonDocstring = "python-docstring" + case powershellBlock = "powershell-block" + case luaBlock = "lua-block" + + public var syntax: WrappedFrontMatterSyntax { + switch self { + case .cBlock: return .cBlock + case .htmlComment: return .htmlComment + case .pythonDocstring: return .pythonDocstring + case .powershellBlock: return .powershellBlock + case .luaBlock: return .luaBlock + } + } + + public var extensions: Set { + switch self { + case .cBlock: + return [ + "c", "h", "cc", "cpp", "cxx", "hpp", "hxx", "m", "mm", "swift", + "java", "kt", "kts", "scala", "js", "mjs", "cjs", "jsx", "ts", "mts", + "cts", "tsx", "cs", "go", "rs", "dart", "php", "css", "scss", "less", "sql", + ] + case .htmlComment: + return ["html", "htm", "xhtml", "xml", "svg", "vue", "svelte"] + case .pythonDocstring: + return ["py", "pyi"] + case .powershellBlock: + return ["ps1", "psm1", "psd1"] + case .luaBlock: + return ["lua"] + } + } + + public static func inferred(forExtension pathExtension: String) -> Self? { + let normalized = pathExtension + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return allCases.first { $0.extensions.contains(normalized) } + } +} + +/// A source location belonging to the exact source snapshot that was scanned. +public struct WrappedFrontMatterLocation: Equatable, Sendable { + /// Zero-based UTF-8 offsets containing the complete wrapped block. + public let utf8Range: Range + /// One-based line containing the opening comment delimiter. + public let openingLine: Int + /// One-based line containing the closing comment delimiter. + public let closingLine: Int +} + +/// The result of scanning one source snapshot for wrapped frontmatter. +public struct WrappedFrontMatterScanResult: Equatable, Sendable { + /// Normalized YAML from the first complete wrapped block, or `nil` when none was found. + public let rawFrontMatter: String? + /// Location of the first complete wrapped block. + public let firstBlock: WrappedFrontMatterLocation? + /// Locations of later complete blocks. Their YAML payloads are intentionally not exposed. + public let additionalBlocks: [WrappedFrontMatterLocation] + + public var hasMultipleBlocks: Bool { + additionalBlocks.isEmpty == false + } +} + +/// Parses one complete comment-wrapped `---` YAML `---` envelope. +/// +/// This parser must be invoked at the beginning of a physical line. It extracts text only; +/// `YAMLConversion` remains responsible for validating YAML and requiring a mapping root. +public struct WrappedFrontMatterParser: Parsing.Parser { + public typealias Input = Substring + public typealias Output = String + + public let syntax: WrappedFrontMatterSyntax + + public init(syntax: WrappedFrontMatterSyntax) { + self.syntax = syntax + } + + public var body: some Parsing.Parser { + Parse { + syntax.openingCommentDelimiter + "\n" + "---" + "\n" + Many(into: [String](), { lines, line in + lines.append(line) + }) { + Parse { + Not { + WrappedFrontMatterClosingBoundaryParser(syntax: syntax) + } + Prefix { character in + character.isNewline == false + } + .map(String.init) + "\n" + } + } terminator: { + WrappedFrontMatterClosingBoundaryParser(syntax: syntax) + } + } + .map { lines in + lines.joined(separator: "\n") + } + } +} + +/// Finds every complete wrapped block while exposing YAML only from the first one. +public struct WrappedFrontMatterScanner { + public let syntax: WrappedFrontMatterSyntax + + public init(syntax: WrappedFrontMatterSyntax) { + self.syntax = syntax + } + + public func scan(_ source: String) -> WrappedFrontMatterScanResult { + var rawFrontMatter: String? + var firstBlock: WrappedFrontMatterLocation? + var additionalBlocks: [WrappedFrontMatterLocation] = [] + var lineStart = source.startIndex + var lineNumber = 1 + + while lineStart < source.endIndex { + let candidate = source[lineStart...] + if candidate.starts(with: syntax.openingCommentDelimiter) { + var input = candidate + if let rawYAML = try? WrappedFrontMatterParser(syntax: syntax).parse(&input) { + let blockEnd = input.startIndex + let location = location( + in: source, + from: lineStart, + to: blockEnd, + openingLine: lineNumber + ) + + if firstBlock == nil { + rawFrontMatter = rawYAML + firstBlock = location + } else { + additionalBlocks.append(location) + } + + guard let next = nextLineStart(in: source, after: blockEnd) else { + break + } + lineNumber = location.closingLine + 1 + lineStart = next + continue + } + } + + guard let next = nextLineStart(in: source, after: lineStart) else { + break + } + lineNumber += 1 + lineStart = next + } + + return WrappedFrontMatterScanResult( + rawFrontMatter: rawFrontMatter, + firstBlock: firstBlock, + additionalBlocks: additionalBlocks + ) + } + + private func location( + in source: String, + from start: String.Index, + to end: String.Index, + openingLine: Int + ) -> WrappedFrontMatterLocation { + let newlineCount = source[start.. String.Index? { + guard index < source.endIndex, + let newline = source[index...].firstIndex(of: "\n") + else { + return nil + } + return source.index(after: newline) + } +} + +/// Parses the complete inner YAML and outer comment closing boundary. +private struct WrappedFrontMatterClosingBoundaryParser: Parsing.Parser { + let syntax: WrappedFrontMatterSyntax + + var body: some Parsing.Parser { + Parse { + "---" + "\n" + syntax.closingCommentDelimiter + Peek { + OneOf { + End() + "\n" + } + } + } + } +} diff --git a/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleConfiguration.swift b/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleConfiguration.swift index 41ed238..309a6fe 100644 --- a/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleConfiguration.swift +++ b/Sources/MarkdownUtilitiesCore/Rules/MarkdownRuleConfiguration.swift @@ -6,8 +6,9 @@ import Foundation /// See . public enum MarkdownRuleConfigurationSchemaVersion { public static let legacy = "0.1.0" - public static let current = "0.2.0" - public static let supported = [legacy, current] + public static let rules = "0.2.0" + public static let current = "0.2.1" + public static let supported = [legacy, rules, current] } /// One versioned project configuration normalized into reusable rule definitions. @@ -16,17 +17,20 @@ public struct MarkdownRuleConfiguration: Equatable, Sendable { public var schemaReference: String? public var schemaDirectory: String public var rules: [MarkdownRuleDefinition] + public var frontmatter: WrappedFrontMatterProjectConfiguration? public init( configVersion: String = MarkdownRuleConfigurationSchemaVersion.current, schemaReference: String? = nil, schemaDirectory: String = ".md-utils/schemas/", - rules: [MarkdownRuleDefinition] = [] + rules: [MarkdownRuleDefinition] = [], + frontmatter: WrappedFrontMatterProjectConfiguration? = nil ) { self.configVersion = configVersion self.schemaReference = schemaReference self.schemaDirectory = schemaDirectory self.rules = rules + self.frontmatter = frontmatter } } @@ -68,9 +72,15 @@ public enum MarkdownRuleConfigurationDecoder { throw MarkdownRuleConfigurationError.unsupportedVersion(version) } - let allowed = version == MarkdownRuleConfigurationSchemaVersion.legacy - ? Set(["$schema", "configVersion", "schemaDirectory", "schemaRules"]) - : Set(["$schema", "configVersion", "schemaDirectory", "rules"]) + let allowed: Set + switch version { + case MarkdownRuleConfigurationSchemaVersion.legacy: + allowed = ["$schema", "configVersion", "schemaDirectory", "schemaRules"] + case MarkdownRuleConfigurationSchemaVersion.rules: + allowed = ["$schema", "configVersion", "schemaDirectory", "rules"] + default: + allowed = ["$schema", "configVersion", "schemaDirectory", "rules", "frontmatter"] + } try validateKeys(object, allowed: allowed, context: "configuration") let schemaDirectory = object["schemaDirectory"] as? String ?? ".md-utils/schemas/" let rawRules: [[String: Any]] @@ -90,10 +100,101 @@ public enum MarkdownRuleConfigurationDecoder { configVersion: version, schemaReference: object["$schema"] as? String, schemaDirectory: schemaDirectory, - rules: rules + rules: rules, + frontmatter: try parseWrappedFrontMatterConfiguration(object["frontmatter"]) ) } + private static func parseWrappedFrontMatterConfiguration( + _ value: Any? + ) throws -> WrappedFrontMatterProjectConfiguration? { + guard let value else { return nil } + guard let object = value as? [String: Any] else { + throw MarkdownRuleConfigurationError.invalidField("frontmatter must be an object") + } + try validateKeys( + object, + allowed: ["useBuiltInPresets", "syntaxes", "extensionMappings"], + context: "frontmatter" + ) + + let useBuiltInPresets: Bool + if let rawValue = object["useBuiltInPresets"] { + guard let value = rawValue as? Bool else { + throw MarkdownRuleConfigurationError.invalidField( + "frontmatter.useBuiltInPresets must be a boolean" + ) + } + useBuiltInPresets = value + } else { + useBuiltInPresets = true + } + + var syntaxes: [String: WrappedFrontMatterSyntax] = [:] + if let rawSyntaxes = object["syntaxes"] { + guard let syntaxObjects = rawSyntaxes as? [String: Any] else { + throw MarkdownRuleConfigurationError.invalidField("frontmatter.syntaxes must be an object") + } + for (name, rawSyntax) in syntaxObjects { + guard let syntax = rawSyntax as? [String: Any] else { + throw MarkdownRuleConfigurationError.invalidField( + "frontmatter.syntaxes.\(name) must be an object" + ) + } + try validateKeys( + syntax, + allowed: ["commentOpen", "commentClose"], + context: "frontmatter.syntaxes.\(name)" + ) + let opening = try requiredString( + syntax, + key: "commentOpen", + context: "frontmatter.syntaxes.\(name)" + ) + let closing = try requiredString( + syntax, + key: "commentClose", + context: "frontmatter.syntaxes.\(name)" + ) + do { + syntaxes[name] = try WrappedFrontMatterSyntax( + openingCommentDelimiter: opening, + closingCommentDelimiter: closing + ) + } catch { + throw MarkdownRuleConfigurationError.invalidField(error.localizedDescription) + } + } + } + + var extensionMappings: [String: String] = [:] + if let rawMappings = object["extensionMappings"] { + guard let mappings = rawMappings as? [String: Any] else { + throw MarkdownRuleConfigurationError.invalidField( + "frontmatter.extensionMappings must be an object" + ) + } + for (pathExtension, rawName) in mappings { + guard let name = rawName as? String else { + throw MarkdownRuleConfigurationError.invalidField( + "frontmatter.extensionMappings.\(pathExtension) must be a syntax name" + ) + } + extensionMappings[pathExtension] = name + } + } + + do { + return try WrappedFrontMatterProjectConfiguration( + useBuiltInPresets: useBuiltInPresets, + syntaxes: syntaxes, + extensionMappings: extensionMappings + ) + } catch { + throw MarkdownRuleConfigurationError.invalidField(error.localizedDescription) + } + } + private static func parseRule( _ object: [String: Any], version: String, @@ -511,6 +612,14 @@ public enum MarkdownRuleConfigurationEncoder { } else { object["rules"] = rules } + if let frontmatter = configuration.frontmatter { + guard configuration.configVersion == MarkdownRuleConfigurationSchemaVersion.current else { + throw MarkdownRuleConfigurationError.unsupportedFeature( + "Wrapped frontmatter configuration requires configVersion \(MarkdownRuleConfigurationSchemaVersion.current)" + ) + } + object["frontmatter"] = wrappedFrontMatterObject(frontmatter) + } let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) guard let value = String(data: data, encoding: .utf8) else { throw MarkdownRuleConfigurationError.invalidSerialization("Could not encode UTF-8 JSON") @@ -518,6 +627,22 @@ public enum MarkdownRuleConfigurationEncoder { return value + "\n" } + private static func wrappedFrontMatterObject( + _ configuration: WrappedFrontMatterProjectConfiguration + ) -> [String: Any] { + let syntaxes = configuration.syntaxes.mapValues { syntax in + [ + "commentOpen": syntax.openingCommentDelimiter, + "commentClose": syntax.closingCommentDelimiter, + ] + } + return [ + "useBuiltInPresets": configuration.useBuiltInPresets, + "syntaxes": syntaxes, + "extensionMappings": configuration.extensionMappings, + ] + } + /// Returns the JSON-compatible representation of one normalized rule. public static func ruleObject( _ rule: MarkdownRuleDefinition, diff --git a/Sources/md-utils/ConfigCommands/ConfigMigrate.swift b/Sources/md-utils/ConfigCommands/ConfigMigrate.swift index 4ce97ca..901269a 100644 --- a/Sources/md-utils/ConfigCommands/ConfigMigrate.swift +++ b/Sources/md-utils/ConfigCommands/ConfigMigrate.swift @@ -5,6 +5,7 @@ import ArgumentParser import Foundation +import MarkdownUtilitiesCore import PathKit /// Adds config migration support to ``CLIEntry.ConfigCommands``. @@ -96,7 +97,12 @@ enum ConfigMigrator { ) } - guard sourceVersion == ConfigSchemaRegistry.legacyVersion && targetVersion == ConfigSchemaRegistry.defaultVersion else { + let supportedUpgrade = + (sourceVersion == ConfigSchemaRegistry.legacyVersion + && targetVersion != ConfigSchemaRegistry.legacyVersion) + || (sourceVersion == MarkdownRuleConfigurationSchemaVersion.rules + && targetVersion == MarkdownRuleConfigurationSchemaVersion.current) + guard supportedUpgrade else { throw ValidationError("Unsupported config migration path: \(sourceVersion) -> \(targetVersion)") } diff --git a/Sources/md-utils/Documentation.docc/FrontmatterCommands.md b/Sources/md-utils/Documentation.docc/FrontmatterCommands.md index 2cdea24..f807c3e 100644 --- a/Sources/md-utils/Documentation.docc/FrontmatterCommands.md +++ b/Sources/md-utils/Documentation.docc/FrontmatterCommands.md @@ -8,6 +8,40 @@ The `frontmatter` command group, also available as `fm`, provides CRUD operation Common operations include reading values, setting values, checking for keys, removing or renaming keys, replacing an entire frontmatter block, dumping frontmatter in multiple formats, searching with JMESPath, checking uniqueness, sorting keys, touching empty keys, and mutating array values. +## Wrapped Frontmatter in Non-Markdown Files + +The read-only `dump`, `get`, `has`, `list`, `search`, and `unique` commands can +scan non-Markdown text files after explicit opt-in with `--include-non-md`. +Without that flag, their Markdown-only file selection and parsing behavior is +unchanged. + +Wrapped metadata uses complete physical lines for the host-language wrapper and +fixed `---` YAML boundaries. Place the block at or near the beginning when +practical, although discovery works anywhere in the file. For example: + +```swift +/* +--- +title: Example +--- +*/ +``` + +The built-in `c-block`, `html-comment`, `python-docstring`, +`powershell-block`, and `lua-block` presets are inferred from common file +extensions. Select a preset or project-defined syntax with +`--frontmatter-syntax`, or provide a one-off wrapper using both +`--frontmatter-comment-open` and `--frontmatter-comment-close`. Use +`--no-frontmatter-presets` to disable built-in extension inference. Project +syntaxes and extension mappings live under `frontmatter` in config schema +`0.2.1`; configuration never enables non-Markdown scanning by itself. + +Prefer multiline comment wrappers where the host language supports them. +Python uses its conventional triple-quoted module string. Per-line comment +frontmatter, Pandoc-style `...` closers, and pure CRLF input are not supported. +If several complete blocks are discovered, the first YAML payload is the only +one parsed, and the later opening lines are reported as an invalid duplicate. + ## Uniqueness Checks Use `fm unique` to check that a scalar selected by JMESPath is unique across a diff --git a/Sources/md-utils/Documentation.docc/RulesValidationCommands.md b/Sources/md-utils/Documentation.docc/RulesValidationCommands.md index 2ae2bf7..f26e8fc 100644 --- a/Sources/md-utils/Documentation.docc/RulesValidationCommands.md +++ b/Sources/md-utils/Documentation.docc/RulesValidationCommands.md @@ -10,7 +10,7 @@ Use `md-utils config init` to create the project configuration along with empty Rules match Markdown files by project-relative glob patterns, optional file metadata conditions, optional frontmatter conditions, optional whole-frontmatter queries, and optional document conditions. Files can match more than one rule, in which case every matching check applies. -Version `0.2.0` configs use a `rules` array. Version `0.1.0` configs using `schemaRules` still load as legacy configs. Both versions normalize through `MarkdownUtilitiesCore` into one compiled registry before files are scanned; unknown versions or fields fail without being discarded. There is no version `0.3.0` syntax. +Versions `0.2.0` and `0.2.1` use a `rules` array; `0.2.1` additionally supports wrapped-frontmatter configuration. Version `0.1.0` configs using `schemaRules` still load as legacy configs. All supported versions normalize through `MarkdownUtilitiesCore` into one compiled registry before files are scanned; unknown versions or fields fail without being discarded. There is no version `0.3.0` syntax. ```bash md-utils config init diff --git a/Sources/md-utils/FrontMatterCommands/Dump.swift b/Sources/md-utils/FrontMatterCommands/Dump.swift index ee55b14..2e67b7e 100644 --- a/Sources/md-utils/FrontMatterCommands/Dump.swift +++ b/Sources/md-utils/FrontMatterCommands/Dump.swift @@ -77,6 +77,7 @@ extension CLIEntry.FrontMatterCommands { ) @OptionGroup var options: GlobalOptions + @OptionGroup var frontmatterSource: FrontMatterSourceOptions @Option(name: .shortAndLong, help: "Output format (json, yaml, raw, plist)") var format: OutputFormat = .json @@ -90,7 +91,8 @@ extension CLIEntry.FrontMatterCommands { /// /// See for workflow details. mutating func run() async throws { - let files = try options.resolvedPaths() + let reader = try frontmatterSource.makeReader() + let files = try options.resolvedPaths(includeAllExtensions: frontmatterSource.includeNonMarkdown) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -102,8 +104,7 @@ extension CLIEntry.FrontMatterCommands { if !isMultipleFiles { let file = files[0] do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) if includeDelimiters && (format == .yaml || format == .raw) { Swift.print("---") @@ -128,8 +129,7 @@ extension CLIEntry.FrontMatterCommands { for (index, file) in files.enumerated() { Swift.print("==> \(file) <==") do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) if includeDelimiters && (format == .yaml || format == .raw) { Swift.print("---") @@ -156,8 +156,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) let node = Yams.Node.mapping(doc.frontMatter) guard var dict = try YAMLConversion.safeNodeToSwiftValue(node) as? [String: Any] else { diff --git a/Sources/md-utils/FrontMatterCommands/FrontMatterReadSupport.swift b/Sources/md-utils/FrontMatterCommands/FrontMatterReadSupport.swift new file mode 100644 index 0000000..3b35db1 --- /dev/null +++ b/Sources/md-utils/FrontMatterCommands/FrontMatterReadSupport.swift @@ -0,0 +1,157 @@ +import ArgumentParser +import Foundation +import MarkdownUtilitiesCore +import PathKit + +/// Selects wrapped-frontmatter discovery for the read-only frontmatter commands. +struct FrontMatterSourceOptions: ParsableArguments { + @Flag( + name: .customLong("include-non-md"), + help: "Also scan non-Markdown text files for comment-wrapped YAML frontmatter" + ) + var includeNonMarkdown = false + + @Option( + name: .customLong("frontmatter-syntax"), + help: "Use a built-in or project-defined wrapped-frontmatter syntax" + ) + var syntaxName: String? + + @Option( + name: .customLong("frontmatter-comment-open"), + help: "Use this opening comment delimiter for wrapped frontmatter" + ) + var commentOpen: String? + + @Option( + name: .customLong("frontmatter-comment-close"), + help: "Use this closing comment delimiter for wrapped frontmatter" + ) + var commentClose: String? + + @Flag( + name: .customLong("no-frontmatter-presets"), + help: "Disable built-in extension-to-syntax inference" + ) + var noPresets = false + + func makeReader(root: Path = .current) throws -> FrontMatterFileReader { + guard (commentOpen == nil) == (commentClose == nil) else { + throw ValidationError( + "--frontmatter-comment-open and --frontmatter-comment-close must be provided together" + ) + } + guard syntaxName == nil || commentOpen == nil else { + throw ValidationError( + "--frontmatter-syntax cannot be combined with raw frontmatter comment delimiters" + ) + } + guard includeNonMarkdown || syntaxName == nil && commentOpen == nil && noPresets == false else { + throw ValidationError( + "Wrapped-frontmatter syntax options require --include-non-md" + ) + } + + let configPath = root + ".md-utils/md-utils.json" + let projectConfiguration: WrappedFrontMatterProjectConfiguration + if configPath.exists { + projectConfiguration = try MdUtilsConfig.load(from: configPath).frontmatter + ?? WrappedFrontMatterProjectConfiguration() + } else { + projectConfiguration = try WrappedFrontMatterProjectConfiguration() + } + + let rawSyntax: WrappedFrontMatterSyntax? + if let commentOpen, let commentClose { + do { + rawSyntax = try WrappedFrontMatterSyntax( + openingCommentDelimiter: commentOpen, + closingCommentDelimiter: commentClose + ) + } catch { + throw ValidationError(error.localizedDescription) + } + } else { + rawSyntax = nil + } + + let namedSyntax: WrappedFrontMatterSyntax? + if let syntaxName { + guard let syntax = projectConfiguration.syntax(named: syntaxName) else { + throw ValidationError("Unknown wrapped frontmatter syntax \"\(syntaxName)\"") + } + namedSyntax = syntax + } else { + namedSyntax = nil + } + + return FrontMatterFileReader( + includeNonMarkdown: includeNonMarkdown, + rawSyntax: rawSyntax, + namedSyntax: namedSyntax, + projectConfiguration: projectConfiguration, + useBuiltInPresets: noPresets == false + ) + } +} + +/// Reads Markdown frontmatter normally and wrapped frontmatter from opted-in non-Markdown files. +struct FrontMatterFileReader { + let includeNonMarkdown: Bool + let rawSyntax: WrappedFrontMatterSyntax? + let namedSyntax: WrappedFrontMatterSyntax? + let projectConfiguration: WrappedFrontMatterProjectConfiguration + let useBuiltInPresets: Bool + + func document(at path: Path) throws -> MarkdownDocument { + let content: String = try path.read(.utf8) + guard isMarkdown(path) == false else { + return try MarkdownDocument(content: content) + } + guard includeNonMarkdown else { + return try MarkdownDocument(content: content) + } + + guard let syntax = syntax(for: path) else { + return MarkdownDocument(frontMatter: .init(), body: content) + } + let scan = WrappedFrontMatterScanner(syntax: syntax).scan(content) + let mapping = try scan.rawFrontMatter.map(YAMLConversion.parse) ?? .init() + if scan.hasMultipleBlocks { + throw FrontMatterReadError.multipleBlocks( + path: path.string, + openingLines: scan.additionalBlocks.map(\.openingLine) + ) + } + return MarkdownDocument(frontMatter: mapping, body: content) + } + + private func syntax(for path: Path) -> WrappedFrontMatterSyntax? { + if let rawSyntax { return rawSyntax } + if let namedSyntax { return namedSyntax } + guard let pathExtension = path.extension else { return nil } + let normalized = pathExtension.lowercased() + if let mappedName = projectConfiguration.extensionMappings[normalized] { + return projectConfiguration.syntax(named: mappedName) + } + guard useBuiltInPresets, projectConfiguration.useBuiltInPresets else { return nil } + return WrappedFrontMatterPreset.inferred(forExtension: normalized)?.syntax + } + + private func isMarkdown(_ path: Path) -> Bool { + guard let pathExtension = path.extension?.lowercased() else { return false } + return pathExtension == "md" || pathExtension == "markdown" + } +} + +enum FrontMatterReadError: LocalizedError { + case multipleBlocks(path: String, openingLines: [Int]) + + var errorDescription: String? { + switch self { + case .multipleBlocks(let path, let openingLines): + let lines = openingLines.map(String.init).joined(separator: ", ") + return "Invalid wrapped frontmatter in \(path): additional blocks begin on line(s) \(lines); only one block is allowed" + } + } +} diff --git a/Sources/md-utils/FrontMatterCommands/Get.swift b/Sources/md-utils/FrontMatterCommands/Get.swift index 0bbfab0..e8c7954 100644 --- a/Sources/md-utils/FrontMatterCommands/Get.swift +++ b/Sources/md-utils/FrontMatterCommands/Get.swift @@ -43,6 +43,7 @@ extension CLIEntry.FrontMatterCommands { ) @OptionGroup var options: GlobalOptions + @OptionGroup var frontmatterSource: FrontMatterSourceOptions @Option(name: .long, help: "The frontmatter key to retrieve") var key: String @@ -63,7 +64,8 @@ extension CLIEntry.FrontMatterCommands { /// See for workflow details. mutating func run() async throws { let timer = CommandTimer() - let files = try options.resolvedPaths() + let reader = try frontmatterSource.makeReader() + let files = try options.resolvedPaths(includeAllExtensions: frontmatterSource.includeNonMarkdown) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -76,8 +78,7 @@ extension CLIEntry.FrontMatterCommands { var results: [[String: Any]] = [] for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) if let value = doc.getValue(forKey: key) { // Key found — include "value" (NSNull if YAML value is null) @@ -103,8 +104,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) processedCount += 1 guard let value = doc.getValue(forKey: key) else { diff --git a/Sources/md-utils/FrontMatterCommands/Has.swift b/Sources/md-utils/FrontMatterCommands/Has.swift index 14367f1..b09fb3e 100644 --- a/Sources/md-utils/FrontMatterCommands/Has.swift +++ b/Sources/md-utils/FrontMatterCommands/Has.swift @@ -26,6 +26,7 @@ extension CLIEntry.FrontMatterCommands { ) @OptionGroup var options: GlobalOptions + @OptionGroup var frontmatterSource: FrontMatterSourceOptions @Option(name: .long, help: "The frontmatter key to check") var key: String @@ -34,7 +35,8 @@ extension CLIEntry.FrontMatterCommands { /// See for workflow details. mutating func run() async throws { let timer = CommandTimer() - let files = try options.resolvedPaths() + let reader = try frontmatterSource.makeReader() + let files = try options.resolvedPaths(includeAllExtensions: frontmatterSource.includeNonMarkdown) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -45,8 +47,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) let exists = doc.hasKey(key) if files.count > 1 { diff --git a/Sources/md-utils/FrontMatterCommands/List.swift b/Sources/md-utils/FrontMatterCommands/List.swift index 995fa8d..06f49c6 100644 --- a/Sources/md-utils/FrontMatterCommands/List.swift +++ b/Sources/md-utils/FrontMatterCommands/List.swift @@ -29,9 +29,11 @@ extension CLIEntry.FrontMatterCommands { /// /// See for workflow details. @OptionGroup var options: GlobalOptions + @OptionGroup var frontmatterSource: FrontMatterSourceOptions mutating func run() async throws { - let files = try options.resolvedPaths() + let reader = try frontmatterSource.makeReader() + let files = try options.resolvedPaths(includeAllExtensions: frontmatterSource.includeNonMarkdown) guard !files.isEmpty else { throw ValidationError("No Markdown files found to process") @@ -41,8 +43,7 @@ extension CLIEntry.FrontMatterCommands { for file in files { do { - let content: String = try file.read() - let doc = try MarkdownDocument(content: content) + let doc = try reader.document(at: file) // Extract keys from frontmatter let keys = Array(doc.frontMatter.keys) diff --git a/Sources/md-utils/FrontMatterCommands/Search.swift b/Sources/md-utils/FrontMatterCommands/Search.swift index 196dcf6..54c218a 100644 --- a/Sources/md-utils/FrontMatterCommands/Search.swift +++ b/Sources/md-utils/FrontMatterCommands/Search.swift @@ -98,13 +98,15 @@ extension CLIEntry.FrontMatterCommands { name: [.short, .long], help: "File extensions to process (comma-separated, no spaces)" ) - var extensions: String = "md,markdown" + var extensions: String? + @OptionGroup var frontmatterSource: FrontMatterSourceOptions /// Runs the command using the parsed command-line arguments. /// /// See for workflow details. mutating func run() async throws { // Convert path strings to Path objects let paths = pathStrings.isEmpty ? [Path.current] : pathStrings.map { Path($0) } + let reader = try frontmatterSource.makeReader() // Compile the JMESPath expression once let expression: JMESExpression do { @@ -124,7 +126,8 @@ extension CLIEntry.FrontMatterCommands { let (matchingFiles, hadErrors) = processBatches( processedPaths, batchSize: 500, - using: expression + using: expression, + reader: reader ) // Output results based on format @@ -166,12 +169,12 @@ extension CLIEntry.FrontMatterCommands { } // Filter by extensions - let exts: [String] = self.extensions + let exts: [String] = (self.extensions ?? "md,markdown") .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } - if !exts.isEmpty { + if (!frontmatterSource.includeNonMarkdown || extensions != nil) && !exts.isEmpty { allPaths = allPaths.filter { path in guard let fileExt = path.extension?.lowercased() else { return false } return exts.contains(fileExt) @@ -186,7 +189,8 @@ extension CLIEntry.FrontMatterCommands { private func processBatches( _ paths: [Path], batchSize: Int = 500, - using expression: JMESExpression + using expression: JMESExpression, + reader: FrontMatterFileReader ) -> (matches: [String], hadErrors: Bool) { var allMatches: [String] = [] var hadErrors = false @@ -200,7 +204,7 @@ extension CLIEntry.FrontMatterCommands { CLIStyle.writeStderr(CLIStyle.metadata("Processing batch \(batchNumber)/\(totalBatches)...")) } - let (batchMatches, batchHadErrors) = processBatch(batch, using: expression) + let (batchMatches, batchHadErrors) = processBatch(batch, using: expression, reader: reader) allMatches.append(contentsOf: batchMatches) hadErrors = hadErrors || batchHadErrors } @@ -209,17 +213,19 @@ extension CLIEntry.FrontMatterCommands { } /// Process a single batch of files - private func processBatch(_ paths: [Path], using expression: JMESExpression) -> (matches: [String], hadErrors: Bool) { + private func processBatch( + _ paths: [Path], + using expression: JMESExpression, + reader: FrontMatterFileReader + ) -> (matches: [String], hadErrors: Bool) { var matches: [String] = [] var hadErrors = false for path in paths { // Parse the file - let content: String let doc: MarkdownDocument do { - content = try path.read(.utf8) - doc = try MarkdownDocument(content: content) + doc = try reader.document(at: path) } catch { CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)") hadErrors = true diff --git a/Sources/md-utils/FrontMatterCommands/Unique.swift b/Sources/md-utils/FrontMatterCommands/Unique.swift index 2f367df..5837948 100644 --- a/Sources/md-utils/FrontMatterCommands/Unique.swift +++ b/Sources/md-utils/FrontMatterCommands/Unique.swift @@ -65,6 +65,7 @@ extension CLIEntry.FrontMatterCommands { var expression: String @OptionGroup var options: GlobalOptions + @OptionGroup var frontmatterSource: FrontMatterSourceOptions @Option( name: .long, @@ -93,7 +94,8 @@ extension CLIEntry.FrontMatterCommands { """) } - let resolved = try options.resolvedPaths() + let reader = try frontmatterSource.makeReader() + let resolved = try options.resolvedPaths(includeAllExtensions: frontmatterSource.includeNonMarkdown) guard resolved.isEmpty == false else { throw ValidationError("No Markdown files found to process") } @@ -111,7 +113,8 @@ extension CLIEntry.FrontMatterCommands { expression: expression, compiledExpression: compiledExpression, files: comparisonFiles, - reference: referencePath + reference: referencePath, + reader: reader ) print(try UniqueRenderer.render(report, format: format, requireValue: requireValue)) @@ -309,9 +312,10 @@ enum UniqueAnalyzer { expression: String, compiledExpression: JMESExpression, files: [Path], - reference: Path? + reference: Path?, + reader: FrontMatterFileReader? = nil ) -> UniqueReport { - let evaluations = files.map { evaluate($0, using: compiledExpression) } + let evaluations = files.map { evaluate($0, using: compiledExpression, reader: reader) } let missingPaths = evaluations.compactMap { evaluation in evaluation.isMissing ? evaluation.path : nil } @@ -320,7 +324,7 @@ enum UniqueAnalyzer { let collisions: [UniqueCollision] if let reference { - let referenceEvaluation = evaluate(reference, using: compiledExpression) + let referenceEvaluation = evaluate(reference, using: compiledExpression, reader: reader) var combinedMissing = missingPaths var combinedDiagnostics = diagnostics if referenceEvaluation.isMissing { @@ -384,9 +388,17 @@ enum UniqueAnalyzer { ) } - private static func evaluate(_ path: Path, using expression: JMESExpression) -> UniqueFileEvaluation { + private static func evaluate( + _ path: Path, + using expression: JMESExpression, + reader: FrontMatterFileReader? + ) -> UniqueFileEvaluation { do { - let document = try MarkdownDocument(content: path.read(.utf8)) + let document = if let reader { + try reader.document(at: path) + } else { + try MarkdownDocument(content: path.read(.utf8)) + } let object = try FrontMatterJMESPath.object(from: document) let result = try expression.search(object: object) let scalar = try UniqueScalar.scalar(from: result) diff --git a/Sources/md-utils/GlobalOptions.swift b/Sources/md-utils/GlobalOptions.swift index d4be857..2efc101 100644 --- a/Sources/md-utils/GlobalOptions.swift +++ b/Sources/md-utils/GlobalOptions.swift @@ -37,7 +37,7 @@ struct GlobalOptions: ParsableArguments { name: .long, help: "File extensions to process (comma-separated, default: md,markdown)" ) - var extensions: String = "md,markdown" + var extensions: String? /// Disable alphabetical sorting of file paths. @Flag( @@ -62,12 +62,15 @@ struct GlobalOptions: ParsableArguments { /// /// - Returns: Array of file paths to process /// - Throws: If a specified path doesn't exist - func resolvedPaths() throws -> [Path] { + func resolvedPaths(includeAllExtensions: Bool = false) throws -> [Path] { // If no paths specified, use current directory let pathsToProcess = paths.isEmpty ? [Path.current] : paths var resolvedFiles: [Path] = [] - let allowedExtensions = Set(extensions.split(separator: ",").map(String.init)) + let extensionSelection = extensions ?? "md,markdown" + let allowedExtensions = includeAllExtensions && extensions == nil + ? nil + : Set(extensionSelection.split(separator: ",").map(String.init)) let excludePatterns = exclude.map { $0.absolute().string } for path in pathsToProcess { @@ -108,7 +111,7 @@ struct GlobalOptions: ParsableArguments { _ directory: Path, recursive: Bool, includeHidden: Bool, - extensions: Set, + extensions: Set?, excludePatterns: [String] ) throws -> [Path] { var files: [Path] = [] @@ -150,7 +153,8 @@ struct GlobalOptions: ParsableArguments { } /// Check if a file matches the allowed extensions. - private func matchesExtension(_ path: Path, allowedExtensions: Set) -> Bool { + private func matchesExtension(_ path: Path, allowedExtensions: Set?) -> Bool { + guard let allowedExtensions else { return true } guard let ext = path.extension else { return false } diff --git a/Sources/md-utils/Resources/0.2.1_md-utils.schema.json b/Sources/md-utils/Resources/0.2.1_md-utils.schema.json new file mode 100644 index 0000000..5e37dc4 --- /dev/null +++ b/Sources/md-utils/Resources/0.2.1_md-utils.schema.json @@ -0,0 +1,800 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dandylyons.github.io/md-utils/schemas/0.2.1/md-utils.schema.json", + "title": "md-utils rules configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for editor autocomplete. Runtime behavior is driven by configVersion, not this URL." + }, + "configVersion": { + "enum": [ + "0.2.1" + ], + "description": "md-utils config schema version. This is independent from the md-utils CLI version." + }, + "schemaDirectory": { + "type": "string", + "default": ".md-utils/schemas/", + "description": "Directory where JSON Schema files are stored. Relative paths are resolved from the project root." + }, + "rules": { + "type": "array", + "description": "Rules that decide which checks apply to which Markdown files.", + "items": { + "$ref": "#/$defs/rule" + }, + "default": [] + }, + "frontmatter": { + "$ref": "#/$defs/frontmatterConfiguration" + } + }, + "required": [ + "configVersion", + "schemaDirectory", + "rules" + ], + "$defs": { + "rule": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique rule name used by md-utils rules validate ." + }, + "match": { + "$ref": "#/$defs/ruleMatch" + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/ruleCheck" + } + } + }, + "required": [ + "name", + "match", + "checks" + ] + }, + "ruleMatch": { + "type": "object", + "additionalProperties": false, + "description": "A file matches this rule only when all configured match groups pass: path includes, path excludes, file metadata predicates, frontmatter predicates, whole-frontmatter query predicates, and document predicates. The paths array is any-of; excludePaths is none-of; predicate namespaces are all-of. Logical grouping predicates all/any/not are deferred to config schema 0.3.0.", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Any-of glob patterns matched against project-relative Markdown file paths. If present, a file path must match at least one pattern." + }, + "excludePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "None-of glob patterns checked after paths match. If a file path matches any exclude pattern, the rule is skipped." + }, + "file": { + "$ref": "#/$defs/fileMatcher" + }, + "frontmatter": { + "type": "object", + "description": "All-of frontmatter field matchers. Each key is a frontmatter key, and every configured key matcher must pass. Missing keys do not match value inequality; only doesntHaveKey intentionally matches a missing key.", + "additionalProperties": { + "$ref": "#/$defs/frontmatterMatcher" + } + }, + "frontmatterQuery": { + "$ref": "#/$defs/frontmatterQueryMatcher" + }, + "document": { + "$ref": "#/$defs/documentMatcher" + } + }, + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "file" + ] + }, + { + "required": [ + "frontmatter" + ] + }, + { + "required": [ + "frontmatterQuery" + ] + }, + { + "required": [ + "document" + ] + } + ] + }, + "fileMatcher": { + "type": "object", + "additionalProperties": false, + "description": "All-of project-relative path and file metadata predicates evaluated before file contents are parsed.", + "properties": { + "pathRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when the project-relative path matches this Swift NSRegularExpression pattern." + }, + "filenameEquals": { + "type": "string", + "minLength": 1, + "description": "Matches when the basename, including extension, equals this string." + }, + "extensionIn": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Matches when the file extension without leading dot is in this list. Comparison is case-insensitive." + }, + "modifiedAfter": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the filesystem modification date is after this date or date-time. Date-only operands compare at date precision; RFC 3339 date-time operands compare at date-time precision." + }, + "modifiedBefore": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the filesystem modification date is before this date or date-time. Date-only operands compare at date precision; RFC 3339 date-time operands compare at date-time precision." + } + }, + "anyOf": [ + { + "required": [ + "pathRegex" + ] + }, + { + "required": [ + "filenameEquals" + ] + }, + { + "required": [ + "extensionIn" + ] + }, + { + "required": [ + "modifiedAfter" + ] + }, + { + "required": [ + "modifiedBefore" + ] + } + ] + }, + "frontmatterQueryMatcher": { + "type": "object", + "additionalProperties": false, + "description": "Whole-frontmatter predicates. These are ANDed with field-level frontmatter predicates when both are present.", + "properties": { + "jmespath": { + "type": "string", + "minLength": 1, + "description": "JMESPath expression evaluated against parsed frontmatter. Truthiness matches md-utils fm search: non-empty strings, arrays, and objects are true; true is true; false, null, and empty values are false." + } + }, + "anyOf": [ + { + "required": [ + "jmespath" + ] + } + ] + }, + "documentMatcher": { + "type": "object", + "additionalProperties": false, + "description": "All-of Markdown body predicates. hasBrokenWikilink is intentionally deferred until resolver context and performance behavior are designed.", + "properties": { + "hasHeading": { + "type": "string", + "minLength": 1, + "description": "Matches when the Markdown body has an ATX heading with this exact text." + }, + "headingRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when any ATX heading text matches this Swift NSRegularExpression pattern." + }, + "hasHeadingAtLevel": { + "$ref": "#/$defs/headingAtLevelMatcher" + }, + "hasSection": { + "type": "string", + "minLength": 1, + "description": "Matches when the body has an ATX heading with this exact text and non-empty section content before the next same-or-higher-level heading." + }, + "bodyContains": { + "type": "string", + "minLength": 1, + "description": "Matches when the raw Markdown body contains this substring." + }, + "bodyRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when the raw Markdown body matches this Swift NSRegularExpression pattern." + }, + "hasWikilink": { + "oneOf": [ + { + "const": true + }, + { + "type": "string", + "minLength": 1 + } + ], + "description": "Matches when the body has any wikilink if true, or a wikilink with the configured target when a string is provided." + }, + "lineCount": { + "$ref": "#/$defs/countRange" + }, + "wordCount": { + "$ref": "#/$defs/countRange" + } + }, + "anyOf": [ + { + "required": [ + "hasHeading" + ] + }, + { + "required": [ + "headingRegex" + ] + }, + { + "required": [ + "hasHeadingAtLevel" + ] + }, + { + "required": [ + "hasSection" + ] + }, + { + "required": [ + "bodyContains" + ] + }, + { + "required": [ + "bodyRegex" + ] + }, + { + "required": [ + "hasWikilink" + ] + }, + { + "required": [ + "lineCount" + ] + }, + { + "required": [ + "wordCount" + ] + } + ] + }, + "headingAtLevelMatcher": { + "type": "object", + "additionalProperties": false, + "properties": { + "heading": { + "type": "string", + "minLength": 1 + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + "required": [ + "heading", + "level" + ], + "description": "Matches an exact ATX heading text at one configured heading level." + }, + "countRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "anyOf": [ + { + "required": [ + "min" + ] + }, + { + "required": [ + "max" + ] + } + ], + "description": "Inclusive integer range. At least one of min or max is required; when both are present min must be less than or equal to max." + }, + "frontmatterMatcher": { + "type": "object", + "additionalProperties": false, + "description": "Predicates for one frontmatter key. If multiple operators are specified, all operators must pass. Missing keys do not match value predicates, including doesntEqual, notIncludes, and notIn; only doesntHaveKey matches a missing key.", + "properties": { + "equals": { + "description": "Matches when the existing frontmatter value equals this JSON-compatible value." + }, + "doesntEqual": { + "description": "Matches when the key exists and its value does not equal this JSON-compatible value. Missing keys do not match." + }, + "includes": { + "description": "Matches when the existing frontmatter value is an array containing this JSON-compatible value." + }, + "notIncludes": { + "description": "Matches when the key exists, its value is an array, and the array does not contain this JSON-compatible value. Missing keys do not match." + }, + "hasKey": { + "const": true, + "description": "Matches when the frontmatter object contains this key." + }, + "doesntHaveKey": { + "const": true, + "description": "Matches when the frontmatter object does not contain this key. This is the only frontmatter field predicate that intentionally matches absence." + }, + "regex": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string matching this Swift NSRegularExpression pattern." + }, + "startsWith": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string starting with this prefix." + }, + "endsWith": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string ending with this suffix." + }, + "contains": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string containing this substring. Use includes for array membership." + }, + "empty": { + "const": true, + "description": "Matches when the existing value is an empty string, empty array, or empty object." + }, + "emptyString": { + "const": true, + "description": "Matches when the existing value is exactly an empty string." + }, + "emptyArray": { + "const": true, + "description": "Matches when the existing value is an empty array." + }, + "emptyObject": { + "const": true, + "description": "Matches when the existing value is an empty object." + }, + "notEmpty": { + "const": true, + "description": "Matches when the existing value is not an empty string, empty array, or empty object." + }, + "in": { + "type": "array", + "minItems": 1, + "description": "Matches when the existing scalar value equals one item in this array." + }, + "notIn": { + "type": "array", + "minItems": 1, + "description": "Matches when the key exists and the scalar value equals none of the items in this array. Missing keys do not match." + }, + "greaterThan": { + "type": "number", + "description": "Matches when the existing numeric value is greater than this number." + }, + "greaterThanOrEqual": { + "type": "number", + "description": "Matches when the existing numeric value is greater than or equal to this number." + }, + "lessThan": { + "type": "number", + "description": "Matches when the existing numeric value is less than this number." + }, + "lessThanOrEqual": { + "type": "number", + "description": "Matches when the existing numeric value is less than or equal to this number." + }, + "after": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is after this operand using precision-aware comparison." + }, + "onOrAfter": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is on or after this operand using precision-aware comparison." + }, + "before": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is before this operand using precision-aware comparison." + }, + "onOrBefore": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is on or before this operand using precision-aware comparison." + }, + "between": { + "$ref": "#/$defs/betweenRange", + "description": "Inclusive range. Numeric bounds compare numbers; date/date-time bounds use precision-aware comparison." + }, + "typeIs": { + "enum": [ + "string", + "boolean", + "number", + "array", + "object", + "null" + ], + "description": "Matches when the existing value has this JSON/YAML type." + } + }, + "anyOf": [ + { + "required": [ + "equals" + ] + }, + { + "required": [ + "doesntEqual" + ] + }, + { + "required": [ + "includes" + ] + }, + { + "required": [ + "notIncludes" + ] + }, + { + "required": [ + "hasKey" + ] + }, + { + "required": [ + "doesntHaveKey" + ] + }, + { + "required": [ + "regex" + ] + }, + { + "required": [ + "startsWith" + ] + }, + { + "required": [ + "endsWith" + ] + }, + { + "required": [ + "contains" + ] + }, + { + "required": [ + "empty" + ] + }, + { + "required": [ + "emptyString" + ] + }, + { + "required": [ + "emptyArray" + ] + }, + { + "required": [ + "emptyObject" + ] + }, + { + "required": [ + "notEmpty" + ] + }, + { + "required": [ + "in" + ] + }, + { + "required": [ + "notIn" + ] + }, + { + "required": [ + "greaterThan" + ] + }, + { + "required": [ + "greaterThanOrEqual" + ] + }, + { + "required": [ + "lessThan" + ] + }, + { + "required": [ + "lessThanOrEqual" + ] + }, + { + "required": [ + "after" + ] + }, + { + "required": [ + "onOrAfter" + ] + }, + { + "required": [ + "before" + ] + }, + { + "required": [ + "onOrBefore" + ] + }, + { + "required": [ + "between" + ] + }, + { + "required": [ + "typeIs" + ] + } + ] + }, + "dateTimeLiteral": { + "type": "string", + "pattern": "^([0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2}))$", + "description": "Date-only YYYY-MM-DD or RFC 3339 date-time with Z or numeric offset. Date-only operands compare at date precision. Date-time operands compare at date-time precision. A value with more precision can match a less precise rule; a value with less precision does not match a more precise rule." + }, + "betweenRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "from": { + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/dateTimeLiteral" + } + ] + }, + "to": { + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/dateTimeLiteral" + } + ] + } + }, + "required": [ + "from", + "to" + ], + "description": "Inclusive range. from and to must both be numbers or both be date/time literals; from must be less than or equal to to." + }, + "ruleCheck": { + "oneOf": [ + { + "$ref": "#/$defs/frontmatterSchemaCheck" + }, + { + "$ref": "#/$defs/requiredHeadingCheck" + }, + { + "$ref": "#/$defs/maxBodyLinesCheck" + }, + { + "$ref": "#/$defs/maxBodyWordsCheck" + } + ] + }, + "frontmatterSchemaCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "frontmatterSchema" + }, + "schema": { + "type": "string", + "minLength": 1 + }, + "frontmatterRequired": { + "type": "boolean", + "default": true + } + }, + "required": [ + "type", + "schema", + "frontmatterRequired" + ] + }, + "requiredHeadingCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "requiredHeading" + }, + "heading": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "heading" + ] + }, + "maxBodyLinesCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "maxBodyLines" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "max" + ] + }, + "maxBodyWordsCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "maxBodyWords" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "max" + ] + }, + "frontmatterConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "useBuiltInPresets": { + "type": "boolean", + "default": true, + "description": "Whether built-in extension-to-syntax mappings participate in non-Markdown frontmatter inference." + }, + "syntaxes": { + "type": "object", + "description": "Project-defined named host-language wrappers.", + "additionalProperties": { + "$ref": "#/$defs/frontmatterSyntax" + }, + "default": {} + }, + "extensionMappings": { + "type": "object", + "description": "File extensions without a leading dot mapped to built-in or project-defined syntax names.", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "default": {} + } + } + }, + "frontmatterSyntax": { + "type": "object", + "additionalProperties": false, + "properties": { + "commentOpen": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$", + "description": "Complete-line opening host-language delimiter." + }, + "commentClose": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$", + "description": "Complete-line closing host-language delimiter." + } + }, + "required": [ + "commentOpen", + "commentClose" + ] + } + } +} diff --git a/Sources/md-utils/Resources/SKILL.md b/Sources/md-utils/Resources/SKILL.md index 1c48d46..d6a4a24 100644 --- a/Sources/md-utils/Resources/SKILL.md +++ b/Sources/md-utils/Resources/SKILL.md @@ -58,7 +58,7 @@ md-utils rules validate ## Project Rules -Use `md-utils rules` when a repository has `.md-utils/md-utils.json` or needs Markdown linting. Config schema `0.2.0` uses `rules[]` with path/file, frontmatter, whole-frontmatter JMESPath, and document predicates. Supported checks include `frontmatterSchema`, `requiredHeading`, `maxBodyLines`, and `maxBodyWords`. +Use `md-utils rules` when a repository has `.md-utils/md-utils.json` or needs Markdown linting. Config schema `0.2.1` uses `rules[]` with path/file, frontmatter, whole-frontmatter JMESPath, and document predicates, and adds optional wrapped-frontmatter syntax and extension configuration. Supported checks include `frontmatterSchema`, `requiredHeading`, `maxBodyLines`, and `maxBodyWords`. Important predicate semantics: missing frontmatter keys are not inequality, so `doesntEqual`, `notIncludes`, and `notIn` do not match missing keys; use `doesntHaveKey` for absence. Date predicates support `YYYY-MM-DD` and RFC 3339 timestamps with `Z` or numeric offsets and compare at the operand's precision. Logical grouping predicates `all`, `any`, and `not`, plus `hasBrokenWikilink`, are not part of config schema `0.2.0`. diff --git a/Sources/md-utils/RulesCommands/RulesSupport.swift b/Sources/md-utils/RulesCommands/RulesSupport.swift index 3e21b52..79f1bd2 100644 --- a/Sources/md-utils/RulesCommands/RulesSupport.swift +++ b/Sources/md-utils/RulesCommands/RulesSupport.swift @@ -22,6 +22,7 @@ struct MdUtilsConfig { var schemaReference: String? var schemaDirectory: String var schemaRules: [Rule] + var frontmatter: WrappedFrontMatterProjectConfiguration? private var normalizedRules: [MarkdownRuleDefinition]? /// Creates a configured instance. /// @@ -30,12 +31,14 @@ struct MdUtilsConfig { configVersion: String = Self.defaultConfigVersion, schemaReference: String? = ConfigSchemaRegistry.publicSchemaURL(for: ConfigSchemaRegistry.defaultVersion), schemaDirectory: String = Self.defaultSchemaDirectory, - schemaRules: [Rule] = [] + schemaRules: [Rule] = [], + frontmatter: WrappedFrontMatterProjectConfiguration? = nil ) { self.configVersion = configVersion self.schemaReference = schemaReference self.schemaDirectory = schemaDirectory self.schemaRules = schemaRules + self.frontmatter = frontmatter self.normalizedRules = nil } /// Loads the requested data from disk. @@ -72,7 +75,8 @@ struct MdUtilsConfig { configVersion: normalized.configVersion, schemaReference: normalized.schemaReference, schemaDirectory: normalized.schemaDirectory, - schemaRules: rules + schemaRules: rules, + frontmatter: normalized.frontmatter ) config.normalizedRules = normalized.rules return config @@ -86,7 +90,8 @@ struct MdUtilsConfig { configVersion: configVersion, schemaReference: schemaReference, schemaDirectory: schemaDirectory, - rules: definitions + rules: definitions, + frontmatter: frontmatter ) try path.write(try MarkdownRuleConfigurationEncoder.encode(configuration)) } @@ -124,9 +129,9 @@ extension MdUtilsConfig { /// Selects bundled md-utils config schemas by config schema version. enum ConfigSchemaRegistry { - static let defaultVersion = "0.2.0" + static let defaultVersion = "0.2.1" static let legacyVersion = "0.1.0" - static let supportedVersions = ["0.1.0", "0.2.0"] + static let supportedVersions = ["0.1.0", "0.2.0", "0.2.1"] static func detectVersion(in object: [String: Any], path: Path) throws -> String { guard let rawVersion = object["configVersion"] else { diff --git a/Tests/MarkdownUtilitiesCoreTests/FrontMatter/WrappedFrontMatterParserTests.swift b/Tests/MarkdownUtilitiesCoreTests/FrontMatter/WrappedFrontMatterParserTests.swift new file mode 100644 index 0000000..06ff34b --- /dev/null +++ b/Tests/MarkdownUtilitiesCoreTests/FrontMatter/WrappedFrontMatterParserTests.swift @@ -0,0 +1,200 @@ +import Testing +@testable import MarkdownUtilitiesCore + +@Suite("Wrapped frontmatter parser") +struct WrappedFrontMatterParserTests { + @Test("Extracts HTML-wrapped YAML") + func extractsHTMLWrappedYAML() throws { + var input: Substring = """ + +
Example
+ """[...] + + let raw = try WrappedFrontMatterParser(syntax: .htmlComment).parse(&input) + + #expect(raw == "title: Example\ntags:\n - html") + #expect(input == "\n
Example
") + } + + @Test("Extracts an empty YAML block") + func extractsEmptyYAML() throws { + var input: Substring = """ + /* + --- + --- + */ + """[...] + + let raw = try WrappedFrontMatterParser(syntax: .cBlock).parse(&input) + + #expect(raw.isEmpty) + #expect(input.isEmpty) + } + + @Test("Does not recognize pure CRLF envelopes") + func doesNotRecognizeCRLF() { + let source = "/*\r\n---\r\ntitle: Example\r\n---\r\n*/\r\nbody" + + let result = WrappedFrontMatterScanner(syntax: .cBlock).scan(source) + + #expect(result.rawFrontMatter == nil) + #expect(result.firstBlock == nil) + } + + @Test("Scans for a complete block in the middle of source") + func scansMiddleOfSource() throws { + let source = """ + import Foundation + + /* + --- + title: Example + --- + */ + + struct Example {} + """ + + let result = WrappedFrontMatterScanner(syntax: .cBlock).scan(source) + let mapping = try YAMLConversion.parse(try #require(result.rawFrontMatter)) + + #expect(mapping["title"]?.string == "Example") + #expect(result.firstBlock?.openingLine == 3) + #expect(result.firstBlock?.closingLine == 7) + #expect(result.additionalBlocks.isEmpty) + } + + @Test("Ignores incomplete candidates and finds a later complete block") + func ignoresIncompleteCandidates() { + let source = """ + /* + not frontmatter + */ + + /* + --- + title: Complete + --- + */ + """ + + let result = WrappedFrontMatterScanner(syntax: .cBlock).scan(source) + + #expect(result.rawFrontMatter == "title: Complete") + #expect(result.firstBlock?.openingLine == 5) + #expect(result.additionalBlocks.isEmpty) + } + + @Test("Reports later blocks without exposing their YAML") + func reportsAdditionalBlocks() { + let source = [ + "\"\"\"", + "---", + "title: First", + "---", + "\"\"\"", + "", + "value = 1", + "", + "\"\"\"", + "---", + "this: is: not: valid: yaml", + "---", + "\"\"\"", + ].joined(separator: "\n") + + let result = WrappedFrontMatterScanner(syntax: .pythonDocstring).scan(source) + + #expect(result.rawFrontMatter == "title: First") + #expect(result.firstBlock?.openingLine == 1) + #expect(result.hasMultipleBlocks) + #expect(result.additionalBlocks.map(\.openingLine) == [9]) + } + + @Test("Does not close on delimiter text in a YAML value") + func delimiterTextInValue() throws { + var input: Substring = """ + + """[...] + + let raw = try WrappedFrontMatterParser(syntax: .htmlComment).parse(&input) + + #expect(raw == "title: \"contains --- text\"\ndescription: still YAML") + } + + @Test("Requires comment delimiters to occupy complete lines") + func requiresCompleteDelimiterLines() { + let source = """ + prefix suffix + """ + + let result = WrappedFrontMatterScanner(syntax: .htmlComment).scan(source) + + #expect(result.rawFrontMatter == nil) + #expect(result.firstBlock == nil) + } + + @Test("Does not recognize a Pandoc-style closing marker") + func doesNotRecognizePandocClosingMarker() { + let source = """ + /* + --- + title: Not Frontmatter + ... + */ + """ + + let result = WrappedFrontMatterScanner(syntax: .cBlock).scan(source) + + #expect(result.rawFrontMatter == nil) + #expect(result.firstBlock == nil) + } + + @Test("Built-in presets infer the approved common extensions case-insensitively") + func builtInPresetInference() { + #expect(WrappedFrontMatterPreset.inferred(forExtension: ".SWIFT") == .cBlock) + #expect(WrappedFrontMatterPreset.inferred(forExtension: "html") == .htmlComment) + #expect(WrappedFrontMatterPreset.inferred(forExtension: "PY") == .pythonDocstring) + #expect(WrappedFrontMatterPreset.inferred(forExtension: "ps1") == .powershellBlock) + #expect(WrappedFrontMatterPreset.inferred(forExtension: "lua") == .luaBlock) + #expect(WrappedFrontMatterPreset.inferred(forExtension: "json") == nil) + } + + @Test("Scans a representative directory-scale source workload") + func scansRepresentativeDirectoryScaleWorkload() { + let prefix = (0..<400).map { "let value\($0) = \($0)" }.joined(separator: "\n") + let source = """ + \(prefix) + /* + --- + title: Example + --- + */ + """ + let scanner = WrappedFrontMatterScanner(syntax: .cBlock) + var discovered = 0 + + for _ in 0..<250 { + if scanner.scan(source).rawFrontMatter == "title: Example" { + discovered += 1 + } + } + + #expect(discovered == 250) + } +} diff --git a/Tests/MarkdownUtilitiesCoreTests/Rules/MarkdownRuleConfigurationTests.swift b/Tests/MarkdownUtilitiesCoreTests/Rules/MarkdownRuleConfigurationTests.swift index f74949a..6315b96 100644 --- a/Tests/MarkdownUtilitiesCoreTests/Rules/MarkdownRuleConfigurationTests.swift +++ b/Tests/MarkdownUtilitiesCoreTests/Rules/MarkdownRuleConfigurationTests.swift @@ -75,4 +75,53 @@ struct MarkdownRuleConfigurationTests { let encoded = try MarkdownRuleConfigurationEncoder.encode(configuration) #expect(try MarkdownRuleConfigurationDecoder.decode(encoded) == configuration) } + + @Test + func `Current configuration round trips wrapped frontmatter settings`() throws { + let source = """ + { + "configVersion": "0.2.1", + "schemaDirectory": ".md-utils/schemas/", + "rules": [], + "frontmatter": { + "useBuiltInPresets": false, + "syntaxes": { + "erb-comment": { + "commentOpen": "<%#", + "commentClose": "%>" + } + }, + "extensionMappings": { + ".ERB": "erb-comment", + "swift": "c-block" + } + } + } + """ + + let configuration = try MarkdownRuleConfigurationDecoder.decode(source) + let frontmatter = try #require(configuration.frontmatter) + #expect(frontmatter.useBuiltInPresets == false) + #expect(frontmatter.extensionMappings == ["erb": "erb-comment", "swift": "c-block"]) + #expect(frontmatter.syntax(named: "erb-comment")?.openingCommentDelimiter == "<%#") + + let encoded = try MarkdownRuleConfigurationEncoder.encode(configuration) + #expect(try MarkdownRuleConfigurationDecoder.decode(encoded) == configuration) + } + + @Test + func `Rules schema version rejects wrapped frontmatter settings`() { + let source = """ + { + "configVersion": "0.2.0", + "schemaDirectory": ".md-utils/schemas/", + "rules": [], + "frontmatter": { "useBuiltInPresets": true } + } + """ + + #expect(throws: MarkdownRuleConfigurationError.self) { + try MarkdownRuleConfigurationDecoder.decode(source) + } + } } diff --git a/Tests/md-utilsTests/Commands/ConfigCommandsTests.swift b/Tests/md-utilsTests/Commands/ConfigCommandsTests.swift index c53f1fc..8a43e02 100644 --- a/Tests/md-utilsTests/Commands/ConfigCommandsTests.swift +++ b/Tests/md-utilsTests/Commands/ConfigCommandsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import MarkdownUtilitiesCore import PathKit import Testing @testable import md_utils @@ -54,7 +55,7 @@ struct ConfigCommandsTests { @Test func `config schema printer returns bundled schema`() throws { - let bundledURL = repoRoot().appending(path: "Sources/md-utils/Resources/0.2.0_md-utils.schema.json") + let bundledURL = repoRoot().appending(path: "Sources/md-utils/Resources/0.2.1_md-utils.schema.json") let expected = try String(contentsOf: bundledURL, encoding: .utf8) #expect(try ConfigSchemaPrinter.content() == expected) @@ -187,7 +188,7 @@ struct ConfigCommandsTests { try writeCurrentConfig(project) let configPath = project + ".md-utils/md-utils.json" - let result = try ConfigMigrator.migrate(configPath: configPath, to: "0.2.0") + let result = try ConfigMigrator.migrate(configPath: configPath, to: ConfigSchemaRegistry.defaultVersion) #expect(!result.changed) #expect(!result.updatedSchemaReference) @@ -222,8 +223,8 @@ struct ConfigCommandsTests { #expect(output.contains("You are using md-utils CLI version 0.1.0-alpha")) #expect(output.contains("Supported md-utils config schema versions:")) - #expect(output.contains(" 0.2.0\n 0.1.0")) - #expect(output.contains("Default generated config schema version: 0.2.0")) + #expect(output.contains(" 0.2.1\n 0.2.0\n 0.1.0")) + #expect(output.contains("Default generated config schema version: 0.2.1")) } @Test @@ -232,16 +233,16 @@ struct ConfigCommandsTests { let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) #expect(object["cliVersion"] as? String == "0.1.0-alpha") - #expect(object["defaultConfigVersion"] as? String == "0.2.0") - #expect(object["supportedConfigVersions"] as? [String] == ["0.1.0", "0.2.0"]) + #expect(object["defaultConfigVersion"] as? String == "0.2.1") + #expect(object["supportedConfigVersions"] as? [String] == ["0.1.0", "0.2.0", "0.2.1"]) } @Test func `public schema copies match bundled schema`() throws { let root = repoRoot() - let bundledURL = root.appending(path: "Sources/md-utils/Resources/0.2.0_md-utils.schema.json") - let versionedURL = root.appending(path: "site/schemas/0.2.0/md-utils.schema.json") - let namedVersionedURL = root.appending(path: "site/schemas/0.2.0/md-utils-0.2.0.schema.json") + let bundledURL = root.appending(path: "Sources/md-utils/Resources/0.2.1_md-utils.schema.json") + let versionedURL = root.appending(path: "site/schemas/0.2.1/md-utils.schema.json") + let namedVersionedURL = root.appending(path: "site/schemas/0.2.1/md-utils-0.2.1.schema.json") let bundled = try String(contentsOf: bundledURL, encoding: .utf8) #expect(try String(contentsOf: versionedURL, encoding: .utf8) == bundled) @@ -250,22 +251,22 @@ struct ConfigCommandsTests { @Test func `bundled schema id uses immutable versioned public URL`() throws { - let bundledURL = repoRoot().appending(path: "Sources/md-utils/Resources/0.2.0_md-utils.schema.json") + let bundledURL = repoRoot().appending(path: "Sources/md-utils/Resources/0.2.1_md-utils.schema.json") let data = try Data(contentsOf: bundledURL) let schema = try JSONSerialization.jsonObject(with: data) as? [String: Any] - #expect(schema?["$id"] as? String == "https://dandylyons.github.io/md-utils/schemas/0.2.0/md-utils.schema.json") + #expect(schema?["$id"] as? String == "https://dandylyons.github.io/md-utils/schemas/0.2.1/md-utils.schema.json") } @Test func `bundled schema requires current config version for IDE validation`() throws { - let bundledURL = repoRoot().appending(path: "Sources/md-utils/Resources/0.2.0_md-utils.schema.json") + let bundledURL = repoRoot().appending(path: "Sources/md-utils/Resources/0.2.1_md-utils.schema.json") let data = try Data(contentsOf: bundledURL) let schema = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) let properties = try #require(schema["properties"] as? [String: Any]) let configVersion = try #require(properties["configVersion"] as? [String: Any]) - #expect(configVersion["enum"] as? [String] == ["0.2.0"]) + #expect(configVersion["enum"] as? [String] == ["0.2.1"]) #expect(schema["required"] as? [String] == ["configVersion", "schemaDirectory", "rules"]) } @@ -289,6 +290,29 @@ struct ConfigCommandsTests { #expect(loaded.schemaRules.first?.match.excludePaths == expected.excludePaths) } + @Test + func `current schema accepts wrapped frontmatter configuration`() throws { + let project = try createTempProject() + defer { try? project.delete() } + let configPath = project + ".md-utils/md-utils.json" + try configPath.parent().mkpath() + let syntax = try WrappedFrontMatterSyntax( + openingCommentDelimiter: "<%#", + closingCommentDelimiter: "%>" + ) + let frontmatter = try WrappedFrontMatterProjectConfiguration( + useBuiltInPresets: false, + syntaxes: ["erb-comment": syntax], + extensionMappings: ["erb": "erb-comment"] + ) + try MdUtilsConfig(frontmatter: frontmatter).save(to: configPath) + + let loaded = try MdUtilsConfig.load(from: configPath) + + #expect(loaded.configVersion == "0.2.1") + #expect(loaded.frontmatter == frontmatter) + } + private func repoRoot() -> URL { let testFile = URL(filePath: #filePath) return testFile diff --git a/Tests/md-utilsTests/Commands/FrontMatterCommands/FrontMatterReadSupportTests.swift b/Tests/md-utilsTests/Commands/FrontMatterCommands/FrontMatterReadSupportTests.swift new file mode 100644 index 0000000..dc03597 --- /dev/null +++ b/Tests/md-utilsTests/Commands/FrontMatterCommands/FrontMatterReadSupportTests.swift @@ -0,0 +1,344 @@ +import ArgumentParser +import Foundation +import MarkdownUtilitiesCore +import PathKit +import Testing +@testable import md_utils + +@Suite("wrapped frontmatter CLI read support") +struct FrontMatterReadSupportTests { + @Test + func `Built-in extension preset reads a block anywhere in Swift source`() throws { + let file = try temporaryFile( + named: "example.swift", + content: """ + import Foundation + + /* + --- + title: Embedded metadata + --- + */ + + print("hello") + """ + ) + defer { try? file.parent().delete() } + let reader = try makeReader() + + let document = try reader.document(at: file) + + #expect(document.getValue(forKey: "title")?.string == "Embedded metadata") + } + + @Test + func `Additional complete blocks produce a line diagnostic without parsing later YAML`() throws { + let file = try temporaryFile( + named: "duplicate.swift", + content: """ + /* + --- + title: First + --- + */ + let value = 1 + /* + --- + definitely: [invalid + --- + */ + """ + ) + defer { try? file.parent().delete() } + let reader = try makeReader() + + do { + _ = try reader.document(at: file) + Issue.record("Expected duplicate wrapped frontmatter to fail") + } catch { + #expect(error.localizedDescription.contains(file.string)) + #expect(error.localizedDescription.contains("additional blocks begin on line(s) 7")) + } + } + + @Test + func `Malformed YAML in the first complete block remains a YAML diagnostic`() throws { + let file = try temporaryFile( + named: "invalid.swift", + content: """ + /* + --- + title: [invalid + --- + */ + """ + ) + defer { try? file.parent().delete() } + let reader = try makeReader() + + #expect(throws: YAMLConversionError.self) { + try reader.document(at: file) + } + } + + @Test + func `A file without a complete block has empty frontmatter without error`() throws { + let file = try temporaryFile( + named: "plain.swift", + content: "let value = 1\n" + ) + defer { try? file.parent().delete() } + + let document = try makeReader().document(at: file) + + #expect(document.frontMatter.isEmpty) + } + + @Test + func `Non-Markdown wrapped frontmatter is ignored without explicit opt-in`() throws { + let file = try temporaryFile( + named: "not-opted-in.swift", + content: """ + /* + --- + title: Ignored + --- + */ + """ + ) + defer { try? file.parent().delete() } + let reader = FrontMatterFileReader( + includeNonMarkdown: false, + rawSyntax: nil, + namedSyntax: nil, + projectConfiguration: try WrappedFrontMatterProjectConfiguration(), + useBuiltInPresets: true + ) + + let document = try reader.document(at: file) + + #expect(document.frontMatter.isEmpty) + } + + @Test + func `One-off delimiters take precedence over extension inference`() throws { + let file = try temporaryFile( + named: "raw.swift", + content: """ + + """ + ) + defer { try? file.parent().delete() } + let reader = FrontMatterFileReader( + includeNonMarkdown: true, + rawSyntax: .htmlComment, + namedSyntax: nil, + projectConfiguration: try WrappedFrontMatterProjectConfiguration(), + useBuiltInPresets: true + ) + + let document = try reader.document(at: file) + + #expect(document.getValue(forKey: "title")?.string == "One-off") + } + + @Test + func `Explicit syntax takes precedence over project and built-in extension inference`() throws { + let file = try temporaryFile( + named: "precedence.swift", + content: #""" + """ + --- + title: Explicit + --- + """ + """# + ) + defer { try? file.parent().delete() } + let projectConfiguration = try WrappedFrontMatterProjectConfiguration( + extensionMappings: ["swift": "html-comment"] + ) + let reader = FrontMatterFileReader( + includeNonMarkdown: true, + rawSyntax: nil, + namedSyntax: .pythonDocstring, + projectConfiguration: projectConfiguration, + useBuiltInPresets: true + ) + + let document = try reader.document(at: file) + + #expect(document.getValue(forKey: "title")?.string == "Explicit") + } + + @Test + func `Project extension mapping takes precedence and survives disabled presets`() throws { + let file = try temporaryFile( + named: "mapping.swift", + content: """ + + """ + ) + defer { try? file.parent().delete() } + let projectConfiguration = try WrappedFrontMatterProjectConfiguration( + extensionMappings: ["swift": "html-comment"] + ) + let reader = FrontMatterFileReader( + includeNonMarkdown: true, + rawSyntax: nil, + namedSyntax: nil, + projectConfiguration: projectConfiguration, + useBuiltInPresets: false + ) + + let document = try reader.document(at: file) + + #expect(document.getValue(forKey: "title")?.string == "Project mapping") + } + + @Test + func `Project-defined syntax is resolved through its extension mapping`() throws { + let file = try temporaryFile( + named: "template.erb", + content: """ + <%# + --- + title: Custom syntax + --- + %> + """ + ) + defer { try? file.parent().delete() } + let syntax = try WrappedFrontMatterSyntax( + openingCommentDelimiter: "<%#", + closingCommentDelimiter: "%>" + ) + let projectConfiguration = try WrappedFrontMatterProjectConfiguration( + syntaxes: ["erb-comment": syntax], + extensionMappings: ["erb": "erb-comment"] + ) + let reader = FrontMatterFileReader( + includeNonMarkdown: true, + rawSyntax: nil, + namedSyntax: nil, + projectConfiguration: projectConfiguration, + useBuiltInPresets: true + ) + + let document = try reader.document(at: file) + + #expect(document.getValue(forKey: "title")?.string == "Custom syntax") + } + + @Test + func `CLI exposes approved wrapped frontmatter options`() throws { + let parsed = try CLIEntry.FrontMatterCommands.Has.parseAsRoot([ + "--key", "title", + "--include-non-md", + "--frontmatter-comment-open", "/*", + "--frontmatter-comment-close", "*/", + "--no-frontmatter-presets", + "example.swift", + ]) + let command = try #require(parsed as? CLIEntry.FrontMatterCommands.Has) + + #expect(command.frontmatterSource.includeNonMarkdown) + #expect(command.frontmatterSource.commentOpen == "/*") + #expect(command.frontmatterSource.commentClose == "*/") + #expect(command.frontmatterSource.noPresets) + } + + @Test + func `Explicit extensions still constrain an opted-in directory scan`() throws { + let directory = Path.current + "tmp/wrapped-frontmatter-paths-\(UUID().uuidString)/" + try directory.mkpath() + defer { try? directory.delete() } + try (directory + "included.swift").write("") + try (directory + "excluded.py").write("") + let parsed = try CLIEntry.FrontMatterCommands.Has.parseAsRoot([ + "--key", "title", + "--include-non-md", + "--extensions", "swift", + directory.string, + ]) + let command = try #require(parsed as? CLIEntry.FrontMatterCommands.Has) + + let paths = try command.options.resolvedPaths( + includeAllExtensions: command.frontmatterSource.includeNonMarkdown + ) + + #expect(paths.map(\.lastComponent) == ["included.swift"]) + } + + @Test + func `Raw comment delimiters and named syntax are mutually exclusive`() throws { + let parsed = try CLIEntry.FrontMatterCommands.Has.parseAsRoot([ + "--key", "title", + "--include-non-md", + "--frontmatter-syntax", "c-block", + "--frontmatter-comment-open", "/*", + "--frontmatter-comment-close", "*/", + "example.swift", + ]) + let command = try #require(parsed as? CLIEntry.FrontMatterCommands.Has) + + #expect(throws: ValidationError.self) { + try command.frontmatterSource.makeReader() + } + } + + @Test + func `All approved read-only commands expose non-Markdown opt-in`() throws { + let dump = try CLIEntry.FrontMatterCommands.Dump.parseAsRoot([ + "--include-non-md", "example.swift", + ]) + let get = try CLIEntry.FrontMatterCommands.Get.parseAsRoot([ + "--key", "title", "--include-non-md", "example.swift", + ]) + let has = try CLIEntry.FrontMatterCommands.Has.parseAsRoot([ + "--key", "title", "--include-non-md", "example.swift", + ]) + let list = try CLIEntry.FrontMatterCommands.List.parseAsRoot([ + "--include-non-md", "example.swift", + ]) + let search = try CLIEntry.FrontMatterCommands.Search.parseAsRoot([ + "title", "--include-non-md", "example.swift", + ]) + let unique = try CLIEntry.FrontMatterCommands.Unique.parseAsRoot([ + "title", "--include-non-md", "example.swift", + ]) + + #expect((dump as? CLIEntry.FrontMatterCommands.Dump)?.frontmatterSource.includeNonMarkdown == true) + #expect((get as? CLIEntry.FrontMatterCommands.Get)?.frontmatterSource.includeNonMarkdown == true) + #expect((has as? CLIEntry.FrontMatterCommands.Has)?.frontmatterSource.includeNonMarkdown == true) + #expect((list as? CLIEntry.FrontMatterCommands.List)?.frontmatterSource.includeNonMarkdown == true) + #expect((search as? CLIEntry.FrontMatterCommands.Search)?.frontmatterSource.includeNonMarkdown == true) + #expect((unique as? CLIEntry.FrontMatterCommands.Unique)?.frontmatterSource.includeNonMarkdown == true) + } + + private func makeReader() throws -> FrontMatterFileReader { + FrontMatterFileReader( + includeNonMarkdown: true, + rawSyntax: nil, + namedSyntax: nil, + projectConfiguration: try WrappedFrontMatterProjectConfiguration(), + useBuiltInPresets: true + ) + } + + private func temporaryFile(named name: String, content: String) throws -> Path { + let directory = Path.current + "tmp/wrapped-frontmatter-tests-\(UUID().uuidString)/" + try directory.mkpath() + let file = directory + name + try file.write(content) + return file + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 1917722..bbf17ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ A document is a successfully parsed content view and deliberately has no persist - `MarkdownDiagnostic` distinguishes errors from advisories and can carry structured fix-its. - `MarkdownTypeFixer` applies selected edits in memory; native and CLI layers own persistence and interaction. -Types are structural and non-exclusive. Rules and types remain distinct domain models even where they share analysis and predicate evaluation. Config versions `0.1.0` and `0.2.0` normalize into the same Core definitions; there is no `0.3.0` grouping syntax. +Types are structural and non-exclusive. Rules and types remain distinct domain models even where they share analysis and predicate evaluation. Config versions `0.1.0`, `0.2.0`, and `0.2.1` normalize into the same Core rule definitions; `0.2.1` also carries wrapped-frontmatter settings. There is no `0.3.0` grouping syntax. The normative mdtype v1 design is documented in [RFC 0001](rfcs/0001-mdtype.md). diff --git a/scripts/validate-schema-publication.py b/scripts/validate-schema-publication.py index 1f2980d..613cae2 100644 --- a/scripts/validate-schema-publication.py +++ b/scripts/validate-schema-publication.py @@ -10,7 +10,7 @@ from pathlib import Path -CURRENT_VERSION = "0.2.0" +CURRENT_VERSION = "0.2.1" SCHEMA_FILE = "md-utils.schema.json" BUNDLED_SCHEMA_FILE = f"{CURRENT_VERSION}_md-utils.schema.json" VERSIONED_SCHEMA_FILE = f"md-utils-{CURRENT_VERSION}.schema.json" diff --git a/site/schemas/0.2.1/md-utils-0.2.1.schema.json b/site/schemas/0.2.1/md-utils-0.2.1.schema.json new file mode 100644 index 0000000..5e37dc4 --- /dev/null +++ b/site/schemas/0.2.1/md-utils-0.2.1.schema.json @@ -0,0 +1,800 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dandylyons.github.io/md-utils/schemas/0.2.1/md-utils.schema.json", + "title": "md-utils rules configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for editor autocomplete. Runtime behavior is driven by configVersion, not this URL." + }, + "configVersion": { + "enum": [ + "0.2.1" + ], + "description": "md-utils config schema version. This is independent from the md-utils CLI version." + }, + "schemaDirectory": { + "type": "string", + "default": ".md-utils/schemas/", + "description": "Directory where JSON Schema files are stored. Relative paths are resolved from the project root." + }, + "rules": { + "type": "array", + "description": "Rules that decide which checks apply to which Markdown files.", + "items": { + "$ref": "#/$defs/rule" + }, + "default": [] + }, + "frontmatter": { + "$ref": "#/$defs/frontmatterConfiguration" + } + }, + "required": [ + "configVersion", + "schemaDirectory", + "rules" + ], + "$defs": { + "rule": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique rule name used by md-utils rules validate ." + }, + "match": { + "$ref": "#/$defs/ruleMatch" + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/ruleCheck" + } + } + }, + "required": [ + "name", + "match", + "checks" + ] + }, + "ruleMatch": { + "type": "object", + "additionalProperties": false, + "description": "A file matches this rule only when all configured match groups pass: path includes, path excludes, file metadata predicates, frontmatter predicates, whole-frontmatter query predicates, and document predicates. The paths array is any-of; excludePaths is none-of; predicate namespaces are all-of. Logical grouping predicates all/any/not are deferred to config schema 0.3.0.", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Any-of glob patterns matched against project-relative Markdown file paths. If present, a file path must match at least one pattern." + }, + "excludePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "None-of glob patterns checked after paths match. If a file path matches any exclude pattern, the rule is skipped." + }, + "file": { + "$ref": "#/$defs/fileMatcher" + }, + "frontmatter": { + "type": "object", + "description": "All-of frontmatter field matchers. Each key is a frontmatter key, and every configured key matcher must pass. Missing keys do not match value inequality; only doesntHaveKey intentionally matches a missing key.", + "additionalProperties": { + "$ref": "#/$defs/frontmatterMatcher" + } + }, + "frontmatterQuery": { + "$ref": "#/$defs/frontmatterQueryMatcher" + }, + "document": { + "$ref": "#/$defs/documentMatcher" + } + }, + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "file" + ] + }, + { + "required": [ + "frontmatter" + ] + }, + { + "required": [ + "frontmatterQuery" + ] + }, + { + "required": [ + "document" + ] + } + ] + }, + "fileMatcher": { + "type": "object", + "additionalProperties": false, + "description": "All-of project-relative path and file metadata predicates evaluated before file contents are parsed.", + "properties": { + "pathRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when the project-relative path matches this Swift NSRegularExpression pattern." + }, + "filenameEquals": { + "type": "string", + "minLength": 1, + "description": "Matches when the basename, including extension, equals this string." + }, + "extensionIn": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Matches when the file extension without leading dot is in this list. Comparison is case-insensitive." + }, + "modifiedAfter": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the filesystem modification date is after this date or date-time. Date-only operands compare at date precision; RFC 3339 date-time operands compare at date-time precision." + }, + "modifiedBefore": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the filesystem modification date is before this date or date-time. Date-only operands compare at date precision; RFC 3339 date-time operands compare at date-time precision." + } + }, + "anyOf": [ + { + "required": [ + "pathRegex" + ] + }, + { + "required": [ + "filenameEquals" + ] + }, + { + "required": [ + "extensionIn" + ] + }, + { + "required": [ + "modifiedAfter" + ] + }, + { + "required": [ + "modifiedBefore" + ] + } + ] + }, + "frontmatterQueryMatcher": { + "type": "object", + "additionalProperties": false, + "description": "Whole-frontmatter predicates. These are ANDed with field-level frontmatter predicates when both are present.", + "properties": { + "jmespath": { + "type": "string", + "minLength": 1, + "description": "JMESPath expression evaluated against parsed frontmatter. Truthiness matches md-utils fm search: non-empty strings, arrays, and objects are true; true is true; false, null, and empty values are false." + } + }, + "anyOf": [ + { + "required": [ + "jmespath" + ] + } + ] + }, + "documentMatcher": { + "type": "object", + "additionalProperties": false, + "description": "All-of Markdown body predicates. hasBrokenWikilink is intentionally deferred until resolver context and performance behavior are designed.", + "properties": { + "hasHeading": { + "type": "string", + "minLength": 1, + "description": "Matches when the Markdown body has an ATX heading with this exact text." + }, + "headingRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when any ATX heading text matches this Swift NSRegularExpression pattern." + }, + "hasHeadingAtLevel": { + "$ref": "#/$defs/headingAtLevelMatcher" + }, + "hasSection": { + "type": "string", + "minLength": 1, + "description": "Matches when the body has an ATX heading with this exact text and non-empty section content before the next same-or-higher-level heading." + }, + "bodyContains": { + "type": "string", + "minLength": 1, + "description": "Matches when the raw Markdown body contains this substring." + }, + "bodyRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when the raw Markdown body matches this Swift NSRegularExpression pattern." + }, + "hasWikilink": { + "oneOf": [ + { + "const": true + }, + { + "type": "string", + "minLength": 1 + } + ], + "description": "Matches when the body has any wikilink if true, or a wikilink with the configured target when a string is provided." + }, + "lineCount": { + "$ref": "#/$defs/countRange" + }, + "wordCount": { + "$ref": "#/$defs/countRange" + } + }, + "anyOf": [ + { + "required": [ + "hasHeading" + ] + }, + { + "required": [ + "headingRegex" + ] + }, + { + "required": [ + "hasHeadingAtLevel" + ] + }, + { + "required": [ + "hasSection" + ] + }, + { + "required": [ + "bodyContains" + ] + }, + { + "required": [ + "bodyRegex" + ] + }, + { + "required": [ + "hasWikilink" + ] + }, + { + "required": [ + "lineCount" + ] + }, + { + "required": [ + "wordCount" + ] + } + ] + }, + "headingAtLevelMatcher": { + "type": "object", + "additionalProperties": false, + "properties": { + "heading": { + "type": "string", + "minLength": 1 + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + "required": [ + "heading", + "level" + ], + "description": "Matches an exact ATX heading text at one configured heading level." + }, + "countRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "anyOf": [ + { + "required": [ + "min" + ] + }, + { + "required": [ + "max" + ] + } + ], + "description": "Inclusive integer range. At least one of min or max is required; when both are present min must be less than or equal to max." + }, + "frontmatterMatcher": { + "type": "object", + "additionalProperties": false, + "description": "Predicates for one frontmatter key. If multiple operators are specified, all operators must pass. Missing keys do not match value predicates, including doesntEqual, notIncludes, and notIn; only doesntHaveKey matches a missing key.", + "properties": { + "equals": { + "description": "Matches when the existing frontmatter value equals this JSON-compatible value." + }, + "doesntEqual": { + "description": "Matches when the key exists and its value does not equal this JSON-compatible value. Missing keys do not match." + }, + "includes": { + "description": "Matches when the existing frontmatter value is an array containing this JSON-compatible value." + }, + "notIncludes": { + "description": "Matches when the key exists, its value is an array, and the array does not contain this JSON-compatible value. Missing keys do not match." + }, + "hasKey": { + "const": true, + "description": "Matches when the frontmatter object contains this key." + }, + "doesntHaveKey": { + "const": true, + "description": "Matches when the frontmatter object does not contain this key. This is the only frontmatter field predicate that intentionally matches absence." + }, + "regex": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string matching this Swift NSRegularExpression pattern." + }, + "startsWith": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string starting with this prefix." + }, + "endsWith": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string ending with this suffix." + }, + "contains": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string containing this substring. Use includes for array membership." + }, + "empty": { + "const": true, + "description": "Matches when the existing value is an empty string, empty array, or empty object." + }, + "emptyString": { + "const": true, + "description": "Matches when the existing value is exactly an empty string." + }, + "emptyArray": { + "const": true, + "description": "Matches when the existing value is an empty array." + }, + "emptyObject": { + "const": true, + "description": "Matches when the existing value is an empty object." + }, + "notEmpty": { + "const": true, + "description": "Matches when the existing value is not an empty string, empty array, or empty object." + }, + "in": { + "type": "array", + "minItems": 1, + "description": "Matches when the existing scalar value equals one item in this array." + }, + "notIn": { + "type": "array", + "minItems": 1, + "description": "Matches when the key exists and the scalar value equals none of the items in this array. Missing keys do not match." + }, + "greaterThan": { + "type": "number", + "description": "Matches when the existing numeric value is greater than this number." + }, + "greaterThanOrEqual": { + "type": "number", + "description": "Matches when the existing numeric value is greater than or equal to this number." + }, + "lessThan": { + "type": "number", + "description": "Matches when the existing numeric value is less than this number." + }, + "lessThanOrEqual": { + "type": "number", + "description": "Matches when the existing numeric value is less than or equal to this number." + }, + "after": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is after this operand using precision-aware comparison." + }, + "onOrAfter": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is on or after this operand using precision-aware comparison." + }, + "before": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is before this operand using precision-aware comparison." + }, + "onOrBefore": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is on or before this operand using precision-aware comparison." + }, + "between": { + "$ref": "#/$defs/betweenRange", + "description": "Inclusive range. Numeric bounds compare numbers; date/date-time bounds use precision-aware comparison." + }, + "typeIs": { + "enum": [ + "string", + "boolean", + "number", + "array", + "object", + "null" + ], + "description": "Matches when the existing value has this JSON/YAML type." + } + }, + "anyOf": [ + { + "required": [ + "equals" + ] + }, + { + "required": [ + "doesntEqual" + ] + }, + { + "required": [ + "includes" + ] + }, + { + "required": [ + "notIncludes" + ] + }, + { + "required": [ + "hasKey" + ] + }, + { + "required": [ + "doesntHaveKey" + ] + }, + { + "required": [ + "regex" + ] + }, + { + "required": [ + "startsWith" + ] + }, + { + "required": [ + "endsWith" + ] + }, + { + "required": [ + "contains" + ] + }, + { + "required": [ + "empty" + ] + }, + { + "required": [ + "emptyString" + ] + }, + { + "required": [ + "emptyArray" + ] + }, + { + "required": [ + "emptyObject" + ] + }, + { + "required": [ + "notEmpty" + ] + }, + { + "required": [ + "in" + ] + }, + { + "required": [ + "notIn" + ] + }, + { + "required": [ + "greaterThan" + ] + }, + { + "required": [ + "greaterThanOrEqual" + ] + }, + { + "required": [ + "lessThan" + ] + }, + { + "required": [ + "lessThanOrEqual" + ] + }, + { + "required": [ + "after" + ] + }, + { + "required": [ + "onOrAfter" + ] + }, + { + "required": [ + "before" + ] + }, + { + "required": [ + "onOrBefore" + ] + }, + { + "required": [ + "between" + ] + }, + { + "required": [ + "typeIs" + ] + } + ] + }, + "dateTimeLiteral": { + "type": "string", + "pattern": "^([0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2}))$", + "description": "Date-only YYYY-MM-DD or RFC 3339 date-time with Z or numeric offset. Date-only operands compare at date precision. Date-time operands compare at date-time precision. A value with more precision can match a less precise rule; a value with less precision does not match a more precise rule." + }, + "betweenRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "from": { + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/dateTimeLiteral" + } + ] + }, + "to": { + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/dateTimeLiteral" + } + ] + } + }, + "required": [ + "from", + "to" + ], + "description": "Inclusive range. from and to must both be numbers or both be date/time literals; from must be less than or equal to to." + }, + "ruleCheck": { + "oneOf": [ + { + "$ref": "#/$defs/frontmatterSchemaCheck" + }, + { + "$ref": "#/$defs/requiredHeadingCheck" + }, + { + "$ref": "#/$defs/maxBodyLinesCheck" + }, + { + "$ref": "#/$defs/maxBodyWordsCheck" + } + ] + }, + "frontmatterSchemaCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "frontmatterSchema" + }, + "schema": { + "type": "string", + "minLength": 1 + }, + "frontmatterRequired": { + "type": "boolean", + "default": true + } + }, + "required": [ + "type", + "schema", + "frontmatterRequired" + ] + }, + "requiredHeadingCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "requiredHeading" + }, + "heading": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "heading" + ] + }, + "maxBodyLinesCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "maxBodyLines" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "max" + ] + }, + "maxBodyWordsCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "maxBodyWords" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "max" + ] + }, + "frontmatterConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "useBuiltInPresets": { + "type": "boolean", + "default": true, + "description": "Whether built-in extension-to-syntax mappings participate in non-Markdown frontmatter inference." + }, + "syntaxes": { + "type": "object", + "description": "Project-defined named host-language wrappers.", + "additionalProperties": { + "$ref": "#/$defs/frontmatterSyntax" + }, + "default": {} + }, + "extensionMappings": { + "type": "object", + "description": "File extensions without a leading dot mapped to built-in or project-defined syntax names.", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "default": {} + } + } + }, + "frontmatterSyntax": { + "type": "object", + "additionalProperties": false, + "properties": { + "commentOpen": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$", + "description": "Complete-line opening host-language delimiter." + }, + "commentClose": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$", + "description": "Complete-line closing host-language delimiter." + } + }, + "required": [ + "commentOpen", + "commentClose" + ] + } + } +} diff --git a/site/schemas/0.2.1/md-utils.schema.json b/site/schemas/0.2.1/md-utils.schema.json new file mode 100644 index 0000000..5e37dc4 --- /dev/null +++ b/site/schemas/0.2.1/md-utils.schema.json @@ -0,0 +1,800 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dandylyons.github.io/md-utils/schemas/0.2.1/md-utils.schema.json", + "title": "md-utils rules configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for editor autocomplete. Runtime behavior is driven by configVersion, not this URL." + }, + "configVersion": { + "enum": [ + "0.2.1" + ], + "description": "md-utils config schema version. This is independent from the md-utils CLI version." + }, + "schemaDirectory": { + "type": "string", + "default": ".md-utils/schemas/", + "description": "Directory where JSON Schema files are stored. Relative paths are resolved from the project root." + }, + "rules": { + "type": "array", + "description": "Rules that decide which checks apply to which Markdown files.", + "items": { + "$ref": "#/$defs/rule" + }, + "default": [] + }, + "frontmatter": { + "$ref": "#/$defs/frontmatterConfiguration" + } + }, + "required": [ + "configVersion", + "schemaDirectory", + "rules" + ], + "$defs": { + "rule": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique rule name used by md-utils rules validate ." + }, + "match": { + "$ref": "#/$defs/ruleMatch" + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/ruleCheck" + } + } + }, + "required": [ + "name", + "match", + "checks" + ] + }, + "ruleMatch": { + "type": "object", + "additionalProperties": false, + "description": "A file matches this rule only when all configured match groups pass: path includes, path excludes, file metadata predicates, frontmatter predicates, whole-frontmatter query predicates, and document predicates. The paths array is any-of; excludePaths is none-of; predicate namespaces are all-of. Logical grouping predicates all/any/not are deferred to config schema 0.3.0.", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Any-of glob patterns matched against project-relative Markdown file paths. If present, a file path must match at least one pattern." + }, + "excludePaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "None-of glob patterns checked after paths match. If a file path matches any exclude pattern, the rule is skipped." + }, + "file": { + "$ref": "#/$defs/fileMatcher" + }, + "frontmatter": { + "type": "object", + "description": "All-of frontmatter field matchers. Each key is a frontmatter key, and every configured key matcher must pass. Missing keys do not match value inequality; only doesntHaveKey intentionally matches a missing key.", + "additionalProperties": { + "$ref": "#/$defs/frontmatterMatcher" + } + }, + "frontmatterQuery": { + "$ref": "#/$defs/frontmatterQueryMatcher" + }, + "document": { + "$ref": "#/$defs/documentMatcher" + } + }, + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "file" + ] + }, + { + "required": [ + "frontmatter" + ] + }, + { + "required": [ + "frontmatterQuery" + ] + }, + { + "required": [ + "document" + ] + } + ] + }, + "fileMatcher": { + "type": "object", + "additionalProperties": false, + "description": "All-of project-relative path and file metadata predicates evaluated before file contents are parsed.", + "properties": { + "pathRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when the project-relative path matches this Swift NSRegularExpression pattern." + }, + "filenameEquals": { + "type": "string", + "minLength": 1, + "description": "Matches when the basename, including extension, equals this string." + }, + "extensionIn": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Matches when the file extension without leading dot is in this list. Comparison is case-insensitive." + }, + "modifiedAfter": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the filesystem modification date is after this date or date-time. Date-only operands compare at date precision; RFC 3339 date-time operands compare at date-time precision." + }, + "modifiedBefore": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the filesystem modification date is before this date or date-time. Date-only operands compare at date precision; RFC 3339 date-time operands compare at date-time precision." + } + }, + "anyOf": [ + { + "required": [ + "pathRegex" + ] + }, + { + "required": [ + "filenameEquals" + ] + }, + { + "required": [ + "extensionIn" + ] + }, + { + "required": [ + "modifiedAfter" + ] + }, + { + "required": [ + "modifiedBefore" + ] + } + ] + }, + "frontmatterQueryMatcher": { + "type": "object", + "additionalProperties": false, + "description": "Whole-frontmatter predicates. These are ANDed with field-level frontmatter predicates when both are present.", + "properties": { + "jmespath": { + "type": "string", + "minLength": 1, + "description": "JMESPath expression evaluated against parsed frontmatter. Truthiness matches md-utils fm search: non-empty strings, arrays, and objects are true; true is true; false, null, and empty values are false." + } + }, + "anyOf": [ + { + "required": [ + "jmespath" + ] + } + ] + }, + "documentMatcher": { + "type": "object", + "additionalProperties": false, + "description": "All-of Markdown body predicates. hasBrokenWikilink is intentionally deferred until resolver context and performance behavior are designed.", + "properties": { + "hasHeading": { + "type": "string", + "minLength": 1, + "description": "Matches when the Markdown body has an ATX heading with this exact text." + }, + "headingRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when any ATX heading text matches this Swift NSRegularExpression pattern." + }, + "hasHeadingAtLevel": { + "$ref": "#/$defs/headingAtLevelMatcher" + }, + "hasSection": { + "type": "string", + "minLength": 1, + "description": "Matches when the body has an ATX heading with this exact text and non-empty section content before the next same-or-higher-level heading." + }, + "bodyContains": { + "type": "string", + "minLength": 1, + "description": "Matches when the raw Markdown body contains this substring." + }, + "bodyRegex": { + "type": "string", + "minLength": 1, + "description": "Matches when the raw Markdown body matches this Swift NSRegularExpression pattern." + }, + "hasWikilink": { + "oneOf": [ + { + "const": true + }, + { + "type": "string", + "minLength": 1 + } + ], + "description": "Matches when the body has any wikilink if true, or a wikilink with the configured target when a string is provided." + }, + "lineCount": { + "$ref": "#/$defs/countRange" + }, + "wordCount": { + "$ref": "#/$defs/countRange" + } + }, + "anyOf": [ + { + "required": [ + "hasHeading" + ] + }, + { + "required": [ + "headingRegex" + ] + }, + { + "required": [ + "hasHeadingAtLevel" + ] + }, + { + "required": [ + "hasSection" + ] + }, + { + "required": [ + "bodyContains" + ] + }, + { + "required": [ + "bodyRegex" + ] + }, + { + "required": [ + "hasWikilink" + ] + }, + { + "required": [ + "lineCount" + ] + }, + { + "required": [ + "wordCount" + ] + } + ] + }, + "headingAtLevelMatcher": { + "type": "object", + "additionalProperties": false, + "properties": { + "heading": { + "type": "string", + "minLength": 1 + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + "required": [ + "heading", + "level" + ], + "description": "Matches an exact ATX heading text at one configured heading level." + }, + "countRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "anyOf": [ + { + "required": [ + "min" + ] + }, + { + "required": [ + "max" + ] + } + ], + "description": "Inclusive integer range. At least one of min or max is required; when both are present min must be less than or equal to max." + }, + "frontmatterMatcher": { + "type": "object", + "additionalProperties": false, + "description": "Predicates for one frontmatter key. If multiple operators are specified, all operators must pass. Missing keys do not match value predicates, including doesntEqual, notIncludes, and notIn; only doesntHaveKey matches a missing key.", + "properties": { + "equals": { + "description": "Matches when the existing frontmatter value equals this JSON-compatible value." + }, + "doesntEqual": { + "description": "Matches when the key exists and its value does not equal this JSON-compatible value. Missing keys do not match." + }, + "includes": { + "description": "Matches when the existing frontmatter value is an array containing this JSON-compatible value." + }, + "notIncludes": { + "description": "Matches when the key exists, its value is an array, and the array does not contain this JSON-compatible value. Missing keys do not match." + }, + "hasKey": { + "const": true, + "description": "Matches when the frontmatter object contains this key." + }, + "doesntHaveKey": { + "const": true, + "description": "Matches when the frontmatter object does not contain this key. This is the only frontmatter field predicate that intentionally matches absence." + }, + "regex": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string matching this Swift NSRegularExpression pattern." + }, + "startsWith": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string starting with this prefix." + }, + "endsWith": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string ending with this suffix." + }, + "contains": { + "type": "string", + "minLength": 1, + "description": "Matches when the existing value is a string containing this substring. Use includes for array membership." + }, + "empty": { + "const": true, + "description": "Matches when the existing value is an empty string, empty array, or empty object." + }, + "emptyString": { + "const": true, + "description": "Matches when the existing value is exactly an empty string." + }, + "emptyArray": { + "const": true, + "description": "Matches when the existing value is an empty array." + }, + "emptyObject": { + "const": true, + "description": "Matches when the existing value is an empty object." + }, + "notEmpty": { + "const": true, + "description": "Matches when the existing value is not an empty string, empty array, or empty object." + }, + "in": { + "type": "array", + "minItems": 1, + "description": "Matches when the existing scalar value equals one item in this array." + }, + "notIn": { + "type": "array", + "minItems": 1, + "description": "Matches when the key exists and the scalar value equals none of the items in this array. Missing keys do not match." + }, + "greaterThan": { + "type": "number", + "description": "Matches when the existing numeric value is greater than this number." + }, + "greaterThanOrEqual": { + "type": "number", + "description": "Matches when the existing numeric value is greater than or equal to this number." + }, + "lessThan": { + "type": "number", + "description": "Matches when the existing numeric value is less than this number." + }, + "lessThanOrEqual": { + "type": "number", + "description": "Matches when the existing numeric value is less than or equal to this number." + }, + "after": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is after this operand using precision-aware comparison." + }, + "onOrAfter": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is on or after this operand using precision-aware comparison." + }, + "before": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is before this operand using precision-aware comparison." + }, + "onOrBefore": { + "$ref": "#/$defs/dateTimeLiteral", + "description": "Matches when the existing date/date-time value is on or before this operand using precision-aware comparison." + }, + "between": { + "$ref": "#/$defs/betweenRange", + "description": "Inclusive range. Numeric bounds compare numbers; date/date-time bounds use precision-aware comparison." + }, + "typeIs": { + "enum": [ + "string", + "boolean", + "number", + "array", + "object", + "null" + ], + "description": "Matches when the existing value has this JSON/YAML type." + } + }, + "anyOf": [ + { + "required": [ + "equals" + ] + }, + { + "required": [ + "doesntEqual" + ] + }, + { + "required": [ + "includes" + ] + }, + { + "required": [ + "notIncludes" + ] + }, + { + "required": [ + "hasKey" + ] + }, + { + "required": [ + "doesntHaveKey" + ] + }, + { + "required": [ + "regex" + ] + }, + { + "required": [ + "startsWith" + ] + }, + { + "required": [ + "endsWith" + ] + }, + { + "required": [ + "contains" + ] + }, + { + "required": [ + "empty" + ] + }, + { + "required": [ + "emptyString" + ] + }, + { + "required": [ + "emptyArray" + ] + }, + { + "required": [ + "emptyObject" + ] + }, + { + "required": [ + "notEmpty" + ] + }, + { + "required": [ + "in" + ] + }, + { + "required": [ + "notIn" + ] + }, + { + "required": [ + "greaterThan" + ] + }, + { + "required": [ + "greaterThanOrEqual" + ] + }, + { + "required": [ + "lessThan" + ] + }, + { + "required": [ + "lessThanOrEqual" + ] + }, + { + "required": [ + "after" + ] + }, + { + "required": [ + "onOrAfter" + ] + }, + { + "required": [ + "before" + ] + }, + { + "required": [ + "onOrBefore" + ] + }, + { + "required": [ + "between" + ] + }, + { + "required": [ + "typeIs" + ] + } + ] + }, + "dateTimeLiteral": { + "type": "string", + "pattern": "^([0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2}))$", + "description": "Date-only YYYY-MM-DD or RFC 3339 date-time with Z or numeric offset. Date-only operands compare at date precision. Date-time operands compare at date-time precision. A value with more precision can match a less precise rule; a value with less precision does not match a more precise rule." + }, + "betweenRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "from": { + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/dateTimeLiteral" + } + ] + }, + "to": { + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/dateTimeLiteral" + } + ] + } + }, + "required": [ + "from", + "to" + ], + "description": "Inclusive range. from and to must both be numbers or both be date/time literals; from must be less than or equal to to." + }, + "ruleCheck": { + "oneOf": [ + { + "$ref": "#/$defs/frontmatterSchemaCheck" + }, + { + "$ref": "#/$defs/requiredHeadingCheck" + }, + { + "$ref": "#/$defs/maxBodyLinesCheck" + }, + { + "$ref": "#/$defs/maxBodyWordsCheck" + } + ] + }, + "frontmatterSchemaCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "frontmatterSchema" + }, + "schema": { + "type": "string", + "minLength": 1 + }, + "frontmatterRequired": { + "type": "boolean", + "default": true + } + }, + "required": [ + "type", + "schema", + "frontmatterRequired" + ] + }, + "requiredHeadingCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "requiredHeading" + }, + "heading": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "heading" + ] + }, + "maxBodyLinesCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "maxBodyLines" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "max" + ] + }, + "maxBodyWordsCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "maxBodyWords" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "max" + ] + }, + "frontmatterConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "useBuiltInPresets": { + "type": "boolean", + "default": true, + "description": "Whether built-in extension-to-syntax mappings participate in non-Markdown frontmatter inference." + }, + "syntaxes": { + "type": "object", + "description": "Project-defined named host-language wrappers.", + "additionalProperties": { + "$ref": "#/$defs/frontmatterSyntax" + }, + "default": {} + }, + "extensionMappings": { + "type": "object", + "description": "File extensions without a leading dot mapped to built-in or project-defined syntax names.", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "default": {} + } + } + }, + "frontmatterSyntax": { + "type": "object", + "additionalProperties": false, + "properties": { + "commentOpen": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$", + "description": "Complete-line opening host-language delimiter." + }, + "commentClose": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$", + "description": "Complete-line closing host-language delimiter." + } + }, + "required": [ + "commentOpen", + "commentClose" + ] + } + } +} diff --git a/site/schemas/CHANGELOG.md b/site/schemas/CHANGELOG.md new file mode 100644 index 0000000..b80897c --- /dev/null +++ b/site/schemas/CHANGELOG.md @@ -0,0 +1,65 @@ +# Config Schema Changelog + +This changelog describes changes between versions of the md-utils project +configuration schema, from most recent to least recent. Versioned schema URLs +are immutable after publication. + +## 0.2.1 — Pending publication + +[View the 0.2.1 schema](./0.2.1/md-utils.schema.json) + +Changes from 0.2.0: + +- Adds the optional top-level `frontmatter` configuration introduced for + delimiter-wrapped YAML metadata in non-Markdown files + ([issue #98](https://github.com/DandyLyons/md-utils/issues/98)). +- Adds `frontmatter.useBuiltInPresets` for enabling or disabling the shipped + extension-to-syntax mappings. +- Adds `frontmatter.syntaxes` for reusable project-defined opening and closing + comment delimiters. +- Adds `frontmatter.extensionMappings` for mapping file extensions to built-in + or project-defined syntax names. +- Leaves the `rules` model and its matching and checking semantics unchanged + from 0.2.0. + +## 0.2.0 — 2026-07-10 + +[View the 0.2.0 schema](./0.2.0/md-utils.schema.json) + +Changes from 0.1.0: + +- Replaces `schemaRules` with the more general `rules` array as part of the + schema-to-rules command redesign + ([issue #66](https://github.com/DandyLyons/md-utils/issues/66)). +- Replaces each rule's single schema fields with a `checks` array. +- Adds the `frontmatterSchema`, `requiredHeading`, `maxBodyLines`, and + `maxBodyWords` check types. +- Expands rule applicability beyond paths and basic frontmatter matching to + include excluded paths, file metadata predicates, whole-frontmatter JMESPath + queries, and Markdown document predicates. +- Adds richer frontmatter operators, including presence, equality, collection, + string, numeric, type, and precision-aware date/time comparisons. +- Keeps 0.1.0 available as a separately supported legacy schema; migration to + 0.2.0 rewrites legacy `schemaRules` into the generalized rule model. + +Relevant implementation history: + +- [Introduce schema 0.2.0](https://github.com/DandyLyons/md-utils/commit/2677e978f2172da4acd36b3269d0277a35841772) +- [Add document matchers](https://github.com/DandyLyons/md-utils/commit/897bd20) +- [Expand rule matchers and schema documentation](https://github.com/DandyLyons/md-utils/commit/66ee9e250875ad2bf63e04e93c7bf69c75a60e63) + +## 0.1.0 — 2026-07-05 + +[View the 0.1.0 schema](./0.1.0/md-utils.schema.json) + +- Establishes the first explicitly versioned md-utils project configuration + schema. +- Defines project-level `schemaDirectory` and `schemaRules` fields. +- Supports path selection and basic frontmatter matchers for schema validation. +- Separates the config schema version from the md-utils CLI release version. +- Establishes bundled-schema validation and immutable, versioned website paths. + +Relevant implementation history: + +- [Add versioned config schema support](https://github.com/DandyLyons/md-utils/commit/a4a3ff260f7a9a350bfbc2313eac9c4de057569a) +- [Publish versioned config schemas](https://github.com/DandyLyons/md-utils/commit/e8e57fa10bba6c6f2b1c9e188e972de7106cd452) diff --git a/skill/markdown-utilities/skills/markdown-utilities/SKILL.md b/skill/markdown-utilities/skills/markdown-utilities/SKILL.md index 1c48d46..d6a4a24 100644 --- a/skill/markdown-utilities/skills/markdown-utilities/SKILL.md +++ b/skill/markdown-utilities/skills/markdown-utilities/SKILL.md @@ -58,7 +58,7 @@ md-utils rules validate ## Project Rules -Use `md-utils rules` when a repository has `.md-utils/md-utils.json` or needs Markdown linting. Config schema `0.2.0` uses `rules[]` with path/file, frontmatter, whole-frontmatter JMESPath, and document predicates. Supported checks include `frontmatterSchema`, `requiredHeading`, `maxBodyLines`, and `maxBodyWords`. +Use `md-utils rules` when a repository has `.md-utils/md-utils.json` or needs Markdown linting. Config schema `0.2.1` uses `rules[]` with path/file, frontmatter, whole-frontmatter JMESPath, and document predicates, and adds optional wrapped-frontmatter syntax and extension configuration. Supported checks include `frontmatterSchema`, `requiredHeading`, `maxBodyLines`, and `maxBodyWords`. Important predicate semantics: missing frontmatter keys are not inequality, so `doesntEqual`, `notIncludes`, and `notIn` do not match missing keys; use `doesntHaveKey` for absence. Date predicates support `YYYY-MM-DD` and RFC 3339 timestamps with `Z` or numeric offsets and compare at the operand's precision. Logical grouping predicates `all`, `any`, and `not`, plus `hasBrokenWikilink`, are not part of config schema `0.2.0`. diff --git a/skill/markdown-utilities/skills/markdown-utilities/references/frontmatter.md b/skill/markdown-utilities/skills/markdown-utilities/references/frontmatter.md index 1a8350d..46eb878 100644 --- a/skill/markdown-utilities/skills/markdown-utilities/references/frontmatter.md +++ b/skill/markdown-utilities/skills/markdown-utilities/references/frontmatter.md @@ -76,6 +76,28 @@ md-utils fm dump posts/ --cat-headers **Formats:** `json` (default), `yaml`, `raw`, `plist` +## Read Wrapped Frontmatter from Non-Markdown Files + +The read-only `dump`, `get`, `has`, `list`, `search`, and `unique` commands can +scan comment-wrapped YAML frontmatter in non-Markdown text files after explicit +opt-in: + +```bash +md-utils fm get --key title --include-non-md Sources/Example.swift +md-utils fm search 'status == `"published"`' --include-non-md Sources/ +``` + +Common file extensions infer built-in C-style block, HTML comment, Python +triple-quoted string, PowerShell block, and Lua block wrappers. Override +inference with `--frontmatter-syntax`, or supply both +`--frontmatter-comment-open` and `--frontmatter-comment-close` for a one-off +wrapper. `--no-frontmatter-presets` disables built-in extension inference; +project-defined extension mappings can still apply. + +The YAML boundaries are always `---`. Place metadata near the beginning when +practical. Pure CRLF input, `...` YAML closers, per-line comment envelopes, and +wrapped-frontmatter mutation are unsupported. + ## Search with JMESPath `fm search` filters files using a JMESPath expression evaluated against each file's frontmatter. Outputs matching file paths. diff --git a/treedocs.yaml b/treedocs.yaml index 16129ac..b744353 100644 --- a/treedocs.yaml +++ b/treedocs.yaml @@ -1,10 +1,10 @@ # yaml-language-server: $schema=https://dandylyons.github.io/treedocs/schemas/0.2.0/treedocs.schema.json schema_version: 0.2.0 project: - last_updated: "2026-07-18" + last_updated: "2026-08-05" name: md-utils version: 0.0.0 -signature: sha256:32d6338411c68d542a0609a72b07b00ae07cc4841b0255f2c272175700d0a2fd +signature: sha256:cf6256ea41b64d89ae3e2bf748b49fe59055d90eb3a55aa02a51800fa7b15c2d tree: .dockerignore: Docker build-context exclusions for local build artifacts and repository metadata. @@ -58,6 +58,8 @@ tree: workflows. CSVConversion.md: DocC article for CSV conversion through the native MarkdownUtilities layer. + LoadingRuleContext.md: DocC article for loading filesystem record metadata + and schema resources before portable rule evaluation. MarkdownUtilities.md: DocC landing page for the MarkdownUtilities library. FileMetadata: _doc: Library types for reading filesystem metadata and extended attributes. @@ -90,6 +92,8 @@ tree: Documentation.docc: _doc: DocC documentation catalog for the portable MarkdownUtilitiesCore API and workflows. + CompilingMarkdownRules.md: DocC article for validating rule definitions and + compiling their dependencies into an immutable registry. FormatConversionWorkflows.md: DocC article for converting Markdown documents to plain text. FrontmatterWorkflows.md: DocC article for reading and modifying YAML frontmatter. @@ -99,8 +103,14 @@ tree: conformance, type hints, diagnostics, and fix-its. MarkdownUtilitiesCore.md: DocC landing page for the MarkdownUtilitiesCore library. + RecordIdentity.md: DocC article for stable record identity policies, collision + diagnostics, and logical-path fallback lookup. Resources: _doc: Resource directory for MarkdownUtilitiesCore DocC assets. + RuleConfigurationVersions.md: DocC article for decoding and encoding supported + project configuration schema versions through one normalized rule model. + RuleRuntimeCapabilities.md: DocC article separating portable rule semantics + from filesystem metadata and query capabilities supplied by hosts. SectionWorkflows.md: DocC article for extracting, inserting, replacing, and reordering Markdown sections. WorkingWithMarkdownDocuments.md: DocC article introducing MarkdownDocument @@ -147,6 +157,10 @@ tree: frontmatter values. MarkdownDocument+FrontMatterMutation.swift: Mutation APIs for setting, removing, and renaming frontmatter keys. + WrappedFrontMatterConfiguration.swift: Defines built-in and project-configured + wrapped-frontmatter syntaxes and extension mappings. + WrappedFrontMatterParser.swift: Discovers LF-delimited, comment-wrapped YAML + frontmatter blocks and reports duplicate-block source locations. YAMLConversion.swift: Conversion helpers between Yams nodes and Swift values. HeadingAdjustment: _doc: Heading promotion and demotion logic that preserves Markdown section @@ -166,10 +180,14 @@ tree: Rules: _doc: Portable Markdown rule models and check evaluation shared by library and CLI clients. - MarkdownRuleCheckEvaluator.swift: Evaluates portable frontmatter-schema, heading, - line, and word-count rule checks. - MarkdownRuleDefinition.swift: Defines rule applicability, requirements, recommendations, - and type-conformance predicates. + MarkdownRuleChecker.swift: Evaluates compiled rule applicability and checks + against analyzed Markdown records. + MarkdownRuleCompiler.swift: Validates rule definitions, resolves schemas and + type references, and produces immutable compiled rule registries. + MarkdownRuleConfiguration.swift: Decodes and encodes supported md-utils config + schema versions into normalized Core rule definitions. + MarkdownRuleModel.swift: Defines portable rule predicates, applicability, + checks, evidence, assessments, and runtime capabilities. SectionExtraction: _doc: Logic for finding, extracting, inserting, and replacing heading-delimited Markdown sections. @@ -214,6 +232,8 @@ tree: logical path, and type-hint models. MarkdownRecordAnalyzer.swift: Parses canonical record content once into reusable frontmatter, body, AST, and outline analysis. + MarkdownRecordIdentityIndex.swift: Derives configured record identities, + reports collisions, and provides primary and logical-path lookups. MarkdownTypeChecker.swift: Assesses records against named types, predicates, recommendations, and declared type hints. MarkdownTypeDefinition.swift: Defines named versioned type contracts across @@ -232,6 +252,27 @@ tree: WikilinkAnchor.swift: Model for heading or block anchors inside wikilinks. WikilinkParser.swift: Parser for Obsidian-style double-bracket wikilink syntax. WikilinkScanner.swift: Scans Markdown text for wikilink occurrences. + MarkdownUtilitiesServer: + _doc: Transport-neutral endpoint planning, record storage contracts, and immutable + read snapshots for serving Markdown resources. + Documentation.docc: + _doc: DocC documentation catalog for the MarkdownUtilitiesServer library. + MarkdownUtilitiesServer.md: DocC landing page for transport-neutral Markdown + resource serving and record storage. + ReadSnapshots.md: DocC article for building immutable resource collections + and collision-safe record lookup snapshots. + EndpointConfiguration.swift: Codable configuration models for resources, + selection, projection, operations, and operation-ID overrides. + EndpointPlan.swift: Immutable compiled resource, route, search-root, and operation + models shared by serving and OpenAPI layers. + EndpointPlanCompiler.swift: Validates server resource configuration and compiles + deterministic endpoint plans with aggregated diagnostics. + InMemoryRecordStore.swift: Actor-backed in-memory RecordStore implementation + with revision-aware create, replace, delete, and paged queries. + MarkdownServerReadSnapshot.swift: Builds immutable analyzed resource snapshots, + memberships, assessments, identities, diagnostics, and lookups. + RecordStore.swift: Storage-neutral asynchronous contract and error models for + canonical Markdown record persistence and enumeration. md-utils: _doc: Command-line executable target built on the MarkdownUtilities library. AgentCommands: @@ -261,6 +302,8 @@ tree: ConfigCommands.swift: Parent command for configuration-related subcommands. ConfigInfo.swift: Command that reports md-utils CLI and configuration schema information. + ConfigInit.swift: Command that initializes versioned project configuration, + local schemas, and supporting directories. ConfigMigrate.swift: Command that migrates older md-utils configuration files to the current schema. ConfigSchema.swift: Embedded schema model for md-utils configuration files. @@ -314,6 +357,11 @@ tree: ArrayRemove.swift: Command that removes values from frontmatter arrays. Dump.swift: Command that dumps complete frontmatter in supported formats. FrontMatterCommands.swift: Parent command for frontmatter subcommands. + FrontMatterJMESPath.swift: Shared JMESPath compilation, conversion, and error + handling for CLI frontmatter queries. + FrontMatterReadSupport.swift: Resolves wrapped-frontmatter CLI options, project + configuration, presets, file selection, and read diagnostics for non-Markdown + files. Get.swift: Command that reads a frontmatter value by key. Has.swift: Command that checks whether a frontmatter key exists. List.swift: Command that lists frontmatter keys and values. @@ -325,6 +373,8 @@ tree: SortKeys.swift: Command that sorts frontmatter keys. Touch.swift: Command that creates frontmatter if missing or updates document metadata. + Unique.swift: Command, analyzer, and renderers for checking uniqueness of + scalar values selected from frontmatter with JMESPath. GlobalOptions.swift: Shared command-line options used across md-utils commands. HeadingCommands: _doc: CLI commands for promoting and demoting Markdown heading levels. @@ -357,19 +407,21 @@ tree: 0.1.0. 0.2.0_md-utils.schema.json: Bundled JSON Schema for md-utils rules config version 0.2.0. + 0.2.1_md-utils.schema.json: Bundled JSON Schema for md-utils config version + 0.2.1, including wrapped-frontmatter syntax and extension configuration. 1_md-utils-type.schema.json: Bundled JSON Schema for Markdown type-definition format version 1. OKF-concept.schema.json: Bundled JSON Schema for OKF concept metadata validation. SKILL.md: Embedded copy of the markdown-utilities agent skill instructions. RulesCommands: _doc: CLI rules command group implementation for project-level Markdown validation. + JMESPathRuleCapabilityProvider.swift: Serialized CLI capability provider for + compiling and evaluating whole-frontmatter JMESPath rule predicates. RulesAdd.swift: Command that adds frontmatter schema-backed rules to configuration. RulesCommands.swift: Parent command for rules-related subcommands. RulesDescribe.swift: Command and renderers for describing configured rules and JSON Schema checks. RulesFilesMatching.swift: Command that lists files matched by configured rules. - RulesInit.swift: Command that bootstraps rules configuration and an initial - frontmatter schema check. RulesList.swift: Command that lists configured rules and checks. RulesMatching.swift: Command that reports which configured rules match a file. RulesRemove.swift: Command that removes configured rules and optionally deletes @@ -436,6 +488,8 @@ tree: body text. SafeNodeToSwiftValueTests.swift: Tests safe conversion from Yams nodes to Swift values. + WrappedFrontMatterParserTests.swift: Tests LF-only wrapped-frontmatter extraction, + discovery throughout source files, incomplete candidates, and duplicate locations. HeadingAdjustment: _doc: Tests for heading promotion, demotion, reconstruction, and scopes. EdgeCaseTests.swift: Tests heading-adjustment edge cases. @@ -455,6 +509,10 @@ tree: _doc: Tests for portable Markdown rule applicability and check evaluation. MarkdownRuleCheckerTests.swift: Verifies rule matching, type predicates, checks, and advisory semantics. + MarkdownRuleCompilerTests.swift: Tests rule compilation validation, capability + requirements, schema resolution, and registry construction. + MarkdownRuleConfigurationTests.swift: Tests normalization and round trips for + supported versioned rule and wrapped-frontmatter configuration. SectionExtraction: _doc: Tests for section boundary detection, extraction, insertion, and replacement. MarkdownDocumentSectionExtractionTests.swift: Tests MarkdownDocument section-extraction @@ -483,6 +541,8 @@ tree: Types: _doc: Tests for portable Markdown type definitions, assessment, diagnostics, hints, and fixes. + MarkdownRecordIdentityIndexTests.swift: Tests record identity derivation, + collisions, diagnostics, fallback paths, lookup, and Codable behavior. MarkdownTypeCheckerTests.swift: Verifies structural conformance, schema checks, body and context predicates, diagnostics, hints, and fixes. MarkdownTypeDefinitionTests.swift: Verifies YAML and JSON definition decoding, @@ -493,6 +553,15 @@ tree: APIs. WikilinkParserTests.swift: Tests parsing double-bracket wikilink syntax. WikilinkScannerTests.swift: Tests scanning Markdown text for wikilinks. + MarkdownUtilitiesServerTests: + _doc: Unit tests for endpoint planning, record storage, and read snapshots in + MarkdownUtilitiesServer. + EndpointPlanCompilerTests.swift: Tests deterministic route planning, configuration + validation, diagnostics, and dependency drift detection. + InMemoryRecordStoreTests.swift: Tests record-store revisions, paging, conflicts, + cancellation, and concurrency-safe mutations. + MarkdownServerReadSnapshotTests.swift: Tests record selection, shared analysis, + resource membership, identity indexes, diagnostics, and snapshot lookup. MarkdownUtilitiesTests: _doc: Unit tests for the MarkdownUtilities library target. FileMetadata: @@ -532,6 +601,8 @@ tree: ArrayPrependTests.swift: Tests prepending values to frontmatter arrays. ArrayRemoveTests.swift: Tests removing values from frontmatter arrays. DumpTests.swift: Tests dumping frontmatter in supported formats. + FrontMatterReadSupportTests.swift: Tests non-Markdown frontmatter presets, + duplicate diagnostics, CLI options, and extension filtering. GetTests.swift: Tests reading frontmatter values. HasTests.swift: Tests frontmatter key existence checks. ListTests.swift: Tests listing frontmatter keys and values. @@ -541,6 +612,8 @@ tree: SearchTests.swift: Tests JMESPath frontmatter searches. SetTests.swift: Tests setting frontmatter values. SortKeysTests.swift: Tests sorting frontmatter keys. + UniqueTests.swift: Tests frontmatter uniqueness analysis, reference mode, + typed scalars, missing values, diagnostics, rendering, and CLI behavior. LinesTests.swift: Tests line range extraction command behavior. LinkCommandsTests.swift: Tests wikilink command behavior. OKFCommandsTests.swift: Tests Open Knowledge Format command behavior. @@ -566,6 +639,10 @@ tree: portability-audit.md: Audit of Core portability boundaries, platform dependencies, and Linux validation. release-procedures.md: Versioning and release process documentation. + rfcs: + _doc: Versioned design specifications for md-utils domain models and behavior. + 0001-mdtype.md: Implemented RFC for structural, non-exclusive Markdown record + types spanning frontmatter, body, and host-provided context. server-architecture.md: Proposed architecture for server-side and embedded md-utils integrations. swift-coding-standards.md: Swift style and safety rules used in this package. @@ -604,6 +681,14 @@ tree: config version 0.2.0. md-utils.schema.json: Public editor schema for md-utils rules config version 0.2.0. + 0.2.1: + _doc: Public JSON Schema files for md-utils config schema version 0.2.1. + md-utils-0.2.1.schema.json: Named immutable public schema copy for md-utils + config version 0.2.1. + md-utils.schema.json: Public editor schema for md-utils config version 0.2.1, + including wrapped-frontmatter configuration. + CHANGELOG.md: Reverse-chronological changes between published md-utils config + schema versions, with links to schemas and relevant implementation artifacts. styles.css: Stylesheet for the md-utils GitHub Pages site. skill: _doc: Packaged markdown-utilities agent skill distribution files.