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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ let package = Package(
dependencies: [
"MarkdownUtilitiesCore",
.target(name: "md-utils"),
],
resources: [
.copy("Fixtures/NonMDFrontmatter"),
]
),
]
Expand Down
53 changes: 53 additions & 0 deletions Sources/MarkdownUtilities/FrontMatter/FrontMatterFileWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation
import PathKit

/// Errors produced while safely replacing a frontmatter-bearing text file.
public enum FrontMatterFileWriteError: LocalizedError, Sendable {
/// The file no longer matches the source snapshot used to construct the edit.
case revisionMismatch

/// The updated Swift string could not be represented as UTF-8.
case invalidUTF8

/// A user-facing explanation of the failed safety check.
public var errorDescription: String? {
switch self {
case .revisionMismatch:
return "file changed since it was read; retry the command"
case .invalidUTF8:
return "updated content is not valid UTF-8"
}
}
}

/// Performs revision-checked atomic replacement for frontmatter text mutations.
///
/// The writer compares the latest text with the exact snapshot used to construct
/// an edit. A mismatch is never retried implicitly. A successful comparison is
/// followed by an atomic replacement, though uncoordinated external writers can
/// still race between the final comparison and replacement.
public enum FrontMatterFileWriter {
/// Replaces a file only if it still matches the snapshot used to build the edit.
///
/// - Parameters:
/// - content: The complete updated UTF-8 text.
/// - path: The native filesystem path to replace.
/// - expectedSource: The exact source snapshot used to construct `content`.
/// - Throws: ``FrontMatterFileWriteError/revisionMismatch`` if the latest text
/// differs, ``FrontMatterFileWriteError/invalidUTF8`` if conversion fails, or
/// an underlying filesystem error if the read or atomic replacement fails.
public static func write(
_ content: String,
to path: Path,
expectedSource: String
) throws {
let latest: String = try path.read()
guard latest == expectedSource else {
throw FrontMatterFileWriteError.revisionMismatch
}
guard let data = content.data(using: .utf8) else {
throw FrontMatterFileWriteError.invalidUTF8
}
try data.write(to: URL(fileURLWithPath: path.absolute().string), options: .atomic)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ Read, write, and convert YAML frontmatter while preserving the Markdown body.

Frontmatter support starts with `FrontMatterParser`, which detects YAML delimited by `---` markers and separates it from the body text. `MarkdownDocument` then parses that YAML into a Yams mapping for structured access.

Non-Markdown text uses ``WrappedFrontMatterParser`` with a ``FrontMatterSyntax``.
The parser scans LF text for complete host wrappers containing complete `---` YAML
blocks. It returns the first block's raw YAML and snapshot-relative source range,
plus the 1-based opening lines of later complete blocks. Incomplete candidates are
treated as absent. The parser does not model the host content as Markdown and does
not normalize YAML indentation.

Mutation helpers on `MarkdownDocument` update frontmatter values without changing the body. Conversion helpers in `YAMLConversion` translate YAML nodes and mappings to Swift values, JSON, Property List, and YAML output.

## Missing and Null Values
Expand Down
225 changes: 225 additions & 0 deletions Sources/MarkdownUtilitiesCore/FrontMatter/WrappedFrontMatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import Foundation
import Parsing

/// A host-language envelope used to contain YAML frontmatter in a text file.
public struct FrontMatterSyntax: Equatable, Sendable {
/// The stable name used in diagnostics and prompts.
public let name: String

/// The complete physical line that opens the host envelope.
public let openingWrapper: String

/// The complete physical line that closes the host envelope.
public let closingWrapper: String

/// Creates a frontmatter syntax.
///
/// - Parameters:
/// - name: The stable syntax name used in diagnostics and prompts.
/// - openingWrapper: The exact opening physical line.
/// - closingWrapper: The exact closing physical line.
public init(name: String, openingWrapper: String, closingWrapper: String) {
self.name = name
self.openingWrapper = openingWrapper
self.closingWrapper = closingWrapper
}

/// C-family block comments.
public static let cBlock = FrontMatterSyntax(
name: "c-block",
openingWrapper: "/*",
closingWrapper: "*/"
)

/// HTML/XML comments.
public static let htmlComment = FrontMatterSyntax(
name: "html-comment",
openingWrapper: "<!--",
closingWrapper: "-->"
)

/// Python triple-quoted strings.
public static let pythonDocstring = FrontMatterSyntax(
name: "python-docstring",
openingWrapper: "\"\"\"",
closingWrapper: "\"\"\""
)

/// PowerShell block comments.
public static let powershellBlock = FrontMatterSyntax(
name: "powershell-block",
openingWrapper: "<#",
closingWrapper: "#>"
)

/// Lua block comments.
public static let luaBlock = FrontMatterSyntax(
name: "lua-block",
openingWrapper: "--[[",
closingWrapper: "]]"
)

/// Returns the shipped syntax for a file extension, compared case-insensitively.
///
/// - Parameter fileExtension: An extension with or without a leading period.
/// - Returns: The shipped wrapper syntax, or `nil` when no mapping exists.
public static func shippedSyntax(forExtension fileExtension: String) -> FrontMatterSyntax? {
let normalized = fileExtension.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased()
if cBlockExtensions.contains(normalized) { return .cBlock }
if htmlCommentExtensions.contains(normalized) { return .htmlComment }
if pythonDocstringExtensions.contains(normalized) { return .pythonDocstring }
if powershellBlockExtensions.contains(normalized) { return .powershellBlock }
if luaBlockExtensions.contains(normalized) { return .luaBlock }
return nil
}

/// All non-Markdown extensions with shipped wrapper mappings.
public static let shippedExtensions: Set<String> =
cBlockExtensions
.union(htmlCommentExtensions)
.union(pythonDocstringExtensions)
.union(powershellBlockExtensions)
.union(luaBlockExtensions)
/// File extensions for languages that use C-style block comments (`/* ... */`).
private static let cBlockExtensions: Set<String> = [
"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", "jsonc",
]

/// File extensions for languages that use HTML-style comments (`<!-- ... -->`).
private static let htmlCommentExtensions: Set<String> = [
"html", "htm", "xhtml", "xml", "svg", "vue", "svelte",
]
/// File extensions for languages that use Python-style triple-quoted strings (`""" ... """`).
private static let pythonDocstringExtensions: Set<String> = ["py", "pyi"]
/// File extensions for languages that use PowerShell-style block comments (`<# ... #>`).
private static let powershellBlockExtensions: Set<String> = ["ps1", "psm1", "psd1"]
/// File extensions for languages that use Lua-style block comments (`--[[ ... ]]`).
private static let luaBlockExtensions: Set<String> = ["lua"]
}

/// A complete wrapped frontmatter block located in one exact source snapshot.
public struct WrappedFrontMatterBlock: Equatable, Sendable {
/// The raw YAML between the two `---` marker lines.
public let rawYAML: String

/// The source range spanning both host wrapper lines.
public let range: Range<String.Index>

/// The 1-based line containing the opening wrapper.
public let openingLine: Int
}

/// The result of scanning one source snapshot for wrapped frontmatter.
public struct WrappedFrontMatterScan: Equatable, Sendable {
/// The first complete block, if one exists.
public let firstBlock: WrappedFrontMatterBlock?

/// Opening locations for complete blocks after the first.
public let additionalOpeningLines: [Int]
}

/// Finds complete delimiter-wrapped YAML frontmatter blocks in LF text.
public struct WrappedFrontMatterParser: Sendable {
/// The host syntax recognized by this parser.
public let syntax: FrontMatterSyntax

/// Creates a parser configured for one host syntax.
public init(syntax: FrontMatterSyntax) {
self.syntax = syntax
}

/// Scans the whole snapshot, returning the first block and later opening lines.
///
/// The parser recognizes LF input and requires wrapper delimiters and both YAML
/// markers to occupy complete physical lines. Incomplete candidates are treated
/// as absent. Returned ranges are valid only for the supplied snapshot.
///
/// - Parameter source: Complete LF text to scan.
/// - Returns: The first complete block and locations of later complete blocks.
public func parse(_ source: String) -> WrappedFrontMatterScan {
let lines = physicalLines(in: source)
var blocks: [WrappedFrontMatterBlock] = []
var openingIndex = 0

while openingIndex < lines.count {
guard lines[openingIndex].text == syntax.openingWrapper,
openingIndex + 1 < lines.count,
lines[openingIndex + 1].text == "---"
else {
openingIndex += 1
continue
}

var yamlClosingIndex = openingIndex + 2
var matchedBlock: WrappedFrontMatterBlock?
while yamlClosingIndex + 1 < lines.count {
if lines[yamlClosingIndex].text == "---",
lines[yamlClosingIndex + 1].text == syntax.closingWrapper
{
let range = lines[openingIndex].start..<lines[yamlClosingIndex + 1].contentEnd
let candidate = String(source[range])
if let rawYAML = parseCompleteEnvelope(candidate) {
matchedBlock = WrappedFrontMatterBlock(
rawYAML: rawYAML,
range: range,
openingLine: openingIndex + 1
)
}
break
}
yamlClosingIndex += 1
}

if let matchedBlock {
blocks.append(matchedBlock)
openingIndex = yamlClosingIndex + 2
} else {
openingIndex += 1
}
}

return WrappedFrontMatterScan(
firstBlock: blocks.first,
additionalOpeningLines: blocks.dropFirst().map(\.openingLine)
)
}

private func parseCompleteEnvelope(_ candidate: String) -> String? {
var input = Substring(candidate)
let parser = Parse {
syntax.openingWrapper
"\n---\n"
PrefixUpTo("---\n\(syntax.closingWrapper)").map(String.init)
"---\n"
syntax.closingWrapper
End()
}
return try? parser.parse(&input)
}

private func physicalLines(in source: String) -> [PhysicalLine] {
var result: [PhysicalLine] = []
var start = source.startIndex
while start < source.endIndex {
let newline = source[start...].firstIndex(of: "\n")
let contentEnd = newline ?? source.endIndex
let nextStart = newline.map { source.index(after: $0) } ?? source.endIndex
result.append(PhysicalLine(
text: String(source[start..<contentEnd]),
start: start,
contentEnd: contentEnd
))
start = nextStart
}

return result
}

private struct PhysicalLine {
let text: String
let start: String.Index
let contentEnd: String.Index
}
}
29 changes: 21 additions & 8 deletions Sources/md-utils/FrontMatterCommands/ArrayAppend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands {
static let configuration = CommandConfiguration(
commandName: "append",
abstract: "Append a value to the end of an array in frontmatter",
discussion: """
discussion: NonMarkdownFrontMatterHelp.appending(to: """
Add a value to the end of an array in frontmatter. If the key doesn't
exist, it will be created as a new array with the value. If the key exists
but is not an array, an error will be thrown.
Expand All @@ -40,7 +40,7 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands {
CASE INSENSITIVE:
Use --case-insensitive for case-insensitive duplicate checking:
md-utils fm array append --key tags --value SWIFT --case-insensitive --skip-duplicates posts/*.md
"""
""")
)

@OptionGroup var options: GlobalOptions
Expand All @@ -56,12 +56,18 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands {

@Flag(name: .long, help: "Case-insensitive duplicate check")
var caseInsensitive: Bool = false

@Flag(name: .long, help: "Process mapped non-Markdown files")
var includeNonMD = false

@Flag(name: .long, help: "Authorize creation of wrapped frontmatter in non-Markdown files")
var createFrontmatter = false
/// Runs the command using the parsed command-line arguments.
///
/// See <doc:FrontmatterCommands> for workflow details.
mutating func run() async throws {
let timer = CommandTimer()
let paths = try options.resolvedPaths()
let paths = try options.resolvedFrontMatterPaths(includeNonMarkdown: includeNonMD)

guard !paths.isEmpty else {
throw ValidationError("No Markdown files found to process")
Expand All @@ -72,9 +78,11 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands {

for path in paths {
do {
// Parse file
let content: String = try path.read()
var doc = try MarkdownDocument(content: content)
let parsed = try FrontMatterCLIMutator.parsedFile(
at: path,
includeNonMarkdown: includeNonMD
)
var doc = parsed.document

// Get array (creates empty if doesn't exist, errors if not an array)
let sequence = try ArrayHelpers.getOrCreateArrayKey(key, in: doc, path: path)
Expand All @@ -86,13 +94,18 @@ extension CLIEntry.FrontMatterCommands.ArrayCommands {
}
}

try FrontMatterCLIMutator.authorizeCreationIfNeeded(
for: parsed,
options: options,
createFrontmatter: createFrontmatter
)

// Append value
let updatedSequence = ArrayHelpers.append(value: value, to: sequence)
doc.frontMatter[key] = .sequence(updatedSequence)

// Write back
let updatedContent = try doc.render()
try updatedContent.write(toFile: path.string, atomically: true, encoding: .utf8)
try FrontMatterCLIMutator.write(doc, parsed: parsed, to: path)
updatedCount += 1
} catch {
CLIStyle.writeError("\(CLIStyle.path(path.string)): \(error.localizedDescription)")
Expand Down
4 changes: 2 additions & 2 deletions Sources/md-utils/FrontMatterCommands/ArrayCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ extension CLIEntry.FrontMatterCommands {
static let configuration = CommandConfiguration(
commandName: "array",
abstract: "Array manipulation commands for frontmatter",
discussion: """
discussion: NonMarkdownFrontMatterHelp.appending(to: """
Manipulate arrays in YAML frontmatter with various subcommands.

SUBCOMMANDS:
Expand Down Expand Up @@ -49,7 +49,7 @@ extension CLIEntry.FrontMatterCommands {

# Find files and update them
md-utils fm array contains --key tags --value swift . | xargs md-utils fm set --key published --value true
""",
"""),
subcommands: [Contains.self, Append.self, Prepend.self, Remove.self]
)
}
Expand Down
Loading