diff --git a/.gitignore b/.gitignore index 6f2876b..d8e1c88 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ DerivedData/ artifacts/ .vscode .cache +.swiftpm diff --git a/Package.resolved b/Package.resolved index e743f7d..d8775f0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c6ea4512e1e16b3ab843cc12fd09fe4f7046ae7901467803dc11fdd59feafad2", + "originHash" : "8b4f0636246db8e20a2d4049f8c471fde2c5538e097f97a2b517e4848503f9d7", "pins" : [ { "identity" : "pathkit", @@ -81,6 +81,15 @@ "branch" : "main", "revision" : "cc57a1b3f850ddbbd3848fb422addada324fab9b" } + }, + { + "identity" : "swift-yyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/swift-yyjson.git", + "state" : { + "revision" : "6514373242de8a0765556dc54efdfd8a9567d71a", + "version" : "0.5.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 48fab83..0e04d3d 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ let package = Package( .library(name: "AsciiDocExtensions", targets: ["AsciiDocExtensions"]), .library(name: "AsciiDocTools", targets: ["AsciiDocTools"]), .library(name: "AsciiDocAntora", targets: ["AsciiDocAntora"]), + .library(name: "AsciiDocPagedRendering", targets: ["AsciiDocPagedRendering"]), .executable(name: "asciidoc-swift", targets: ["asciidoc-swift"]) ], @@ -28,12 +29,15 @@ let package = Package( .package(url: "https://github.com/openorbit/swift-hunspell", branch: "main"), .package(url: "https://github.com/openorbit/swift-yaml", branch: "main"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/mattt/swift-yyjson.git", from: "0.5.0"), ], targets: [ // Core parsing/semantic engine (Foundation + RegexBuilder only) .target( name: "AsciiDocCore", - dependencies: [], + dependencies: [ + .product(name: "YYJSON", package: "swift-yyjson") + ], path: "Sources/AsciiDocCore", swiftSettings: [ // Keep indexes Unicode-safe, discourage unsafe operations @@ -45,6 +49,7 @@ let package = Package( dependencies: [ .product(name: "Stencil", package: "Stencil"), "AsciiDocCore", + "AsciiDocPagedRendering", ], path: "Sources/AsciiDocRender", swiftSettings: [ @@ -81,6 +86,11 @@ let package = Package( path: "Sources/AsciiDocAntora" ), + .target( + name: "AsciiDocPagedRendering", + dependencies: ["AsciiDocCore"], + path: "Sources/AsciiDocPagedRendering" + ), // CLI executable that the TCK will invoke .executableTarget( @@ -91,6 +101,7 @@ let package = Package( "AsciiDocExtensions", "AsciiDocTools", "AsciiDocAntora", + "AsciiDocPagedRendering", .product(name: "ArgumentParser", package: "swift-argument-parser") ], path: "Sources/asciidoc-swift", @@ -105,7 +116,7 @@ let package = Package( // Unit tests for the core; add fixture files under Tests/AsciiDocCoreTests/Fixtures as needed .testTarget( name: "AsciiDocCoreTests", - dependencies: ["AsciiDocCore", "AsciiDocTools", "AsciiDocRender"], + dependencies: ["AsciiDocCore", "AsciiDocTools", "AsciiDocRender", "AsciiDocPagedRendering"], path: "Tests/AsciiDocCoreTests", swiftSettings: [ .interoperabilityMode(.Cxx) diff --git a/README.adoc b/README.adoc index 13edbf1..695cef8 100644 --- a/README.adoc +++ b/README.adoc @@ -2,6 +2,32 @@ A swift implementation of AsciiDoc +== XAD Overview + +XAD is an opt-in set of extensions that adds structured, JSON-like attributes and layout helpers for paged media output. +Enable it by passing the `--xad` flag or by using a backend that requires XAD. + +Current additions include: + +* Typed attributes parsed from JSON/JSON5 values (dictionaries and arrays) in header attribute entries. +* Multi-line JSON/JSON5 attribute values without explicit continuation escapes. +* Attribute accessors for nested values (e.g. `foo.bar` and bracket indexing). +* XAD access macros for nested values (`get::path[]` and `set::path[value]`), plus shorthand access with `{foo.bar}`. +* User-defined macros (block and inline) with parameters, defaults, and `{body}` substitution for block macros. +* Macro recursion detection (direct and indirect) with warnings. +* Macro side-effect allow list via `effects="docpush docset docput"` (or `effects=all`). +* Control directives: `if`/`elif`/`else`/`end` and `for` loops, with expression validation warnings. +* Document variables (`docset`, `docpush`, `docput`) for building registries under `doc.*`. +* Layout-oriented attributes intended for paged and PDF-style renderers. +* Scoped attributes for block- and section-level overrides. + +Scoped attributes: + +* `attrpush::[]` and `attrpop::[]` push/pop the attribute environment. +* `blockattr::[foo=bar]` applies attributes to the next block only. +* `[blockattr]` followed by a listing-style block of `:name:` entries applies to the next block only. +* `attrs::[foo=bar]` (or `[attrs]` + listing block) immediately after a section title scopes attributes to that section. + == License Licensed under the Apache License, Version 2.0. diff --git a/Sources/AsciiDocCore/Attributes.swift b/Sources/AsciiDocCore/Attributes.swift index 54ca967..d3dc6f7 100644 --- a/Sources/AsciiDocCore/Attributes.swift +++ b/Sources/AsciiDocCore/Attributes.swift @@ -3,22 +3,182 @@ // SPDX-License-Identifier: Apache-2.0 // +import Foundation +import YYJSON + +public enum XADAttributeValue: Sendable, Equatable { + case dictionary([String: XADAttributeValue]) + case array([XADAttributeValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + static func parse(from raw: String, xadOptions: XADOptions) -> XADAttributeValue? { + guard xadOptions.enabled else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let first = trimmed.first, first == "{" || first == "[" else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + + var options: YYJSONSerialization.ReadingOptions = [] + options.insert(.json5Allowed) + + do { + let obj = try YYJSONSerialization.jsonObject(with: data, options: options) + return XADAttributeValue.fromJSON(obj) + } catch { + return nil + } + } + + static func fromJSON(_ value: Any) -> XADAttributeValue? { + switch value { + case let dict as [String: Any]: + var mapped: [String: XADAttributeValue] = [:] + for (key, val) in dict { + if let converted = fromJSON(val) { + mapped[key] = converted + } else { + return nil + } + } + return .dictionary(mapped) + case let array as [Any]: + var mapped: [XADAttributeValue] = [] + mapped.reserveCapacity(array.count) + for val in array { + guard let converted = fromJSON(val) else { return nil } + mapped.append(converted) + } + return .array(mapped) + case let str as String: + return .string(str) + case let bool as Bool: + return .bool(bool) + case let int as Int: + return .number(Double(int)) + case let double as Double: + return .number(double) + case let float as Float: + return .number(Double(float)) + case is NSNull: + return .null + default: + return nil + } + } + + + public func toJSONCompatible() -> Any { + switch self { + case .dictionary(let dict): + var out: [String: Any] = [:] + for (key, value) in dict { + out[key] = value.toJSONCompatible() + } + return out + case .array(let array): + return array.map { $0.toJSONCompatible() } + case .string(let value): + return value + case .number(let value): + return value + case .bool(let value): + return value + case .null: + return NSNull() + } + } +} + package struct AttrEnv { // name → optional value (nil means unset) private(set) var values: [String: String?] + private(set) var typedValues: [String: XADAttributeValue] + private var scopeStack: [ScopeSnapshot] + let xadOptions: XADOptions - package init(initial: [String: String?] = [:]) { + private struct ScopeSnapshot { + let values: [String: String?] + let typedValues: [String: XADAttributeValue] + } + + package init( + initial: [String: String?] = [:], + typedAttributes: [String: XADAttributeValue] = [:], + xadOptions: XADOptions = .init(enabled: false) + ) { self.values = initial + self.typedValues = typedAttributes + self.scopeStack = [] + self.xadOptions = xadOptions } mutating func set(_ name: String, to value: String?) { values[name] = value } + mutating func pushScope() { + let snapshot = ScopeSnapshot(values: values, typedValues: typedValues) + scopeStack.append(snapshot) + } + + @discardableResult + mutating func popScope() -> Bool { + guard let snapshot = scopeStack.popLast() else { return false } + values = snapshot.values + typedValues = snapshot.typedValues + return true + } + + mutating func applyAttributeSet(name: String, value: String?) { + values[name] = value + guard xadOptions.enabled else { return } + let rawValue = value ?? "" + if isXADPathName(name) { + let normalizedScalar = stripSurroundingQuotes(rawValue) + let typed = XADAttributeValue.parse(from: normalizedScalar, xadOptions: xadOptions) ?? .string(normalizedScalar) + setTypedPath(name, value: typed) + return + } + if let value, let typed = XADAttributeValue.parse(from: value, xadOptions: xadOptions) { + typedValues[name] = typed + } else { + typedValues.removeValue(forKey: name) + } + } + + mutating func applyAttributeUnset(name: String) { + values[name] = nil + guard xadOptions.enabled else { return } + if isXADPathName(name) { + setTypedPath(name, value: .null) + } else { + typedValues.removeValue(forKey: name) + } + } + func value(for name: String) -> String? { values[name] ?? nil } + func resolveAttribute(_ name: String, join: String? = nil) -> String? { + if xadOptions.enabled, isXADPathName(name), let typed = resolveTypedPath(name) { + return stringify(typed, join: join) + } + if let raw = value(for: name) { + return raw + } + if xadOptions.enabled, let typed = typedValues[name] { + return stringify(typed, join: join) + } + return nil + } + + package func resolveTypedValue(_ path: String) -> XADAttributeValue? { + resolveTypedPath(path) + } + /// Expand {attr} in a string using the *current* environment. /// No re-parsing of markup is done; this is purely textual. package func expand(_ s: String) -> String { @@ -37,7 +197,7 @@ package struct AttrEnv { } if j < end, s[j] == "}" { let name = String(s[nameStart.. Bool { + name.contains(".") || name.contains("[") +} + +private func parseXADPathSegments(_ path: String, env: AttrEnv) -> [XADPathSegment]? { + var segments: [XADPathSegment] = [] + var token = "" + var index = path.startIndex + + func appendToken() -> Bool { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + token.removeAll(keepingCapacity: true) + guard !trimmed.isEmpty else { return true } + if trimmed.allSatisfy({ $0.isNumber }), let value = Int(trimmed) { + segments.append(.index(value)) + } else { + segments.append(.key(trimmed)) + } + return true + } + + while index < path.endIndex { + let ch = path[index] + if ch == "." { + _ = appendToken() + index = path.index(after: index) + continue + } + if ch == "[" { + _ = appendToken() + let close = path[index...].firstIndex(of: "]") + guard let close else { return nil } + let content = path[path.index(after: index).. String? { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("{") && trimmed.hasSuffix("}") { + let start = trimmed.index(after: trimmed.startIndex) + let end = trimmed.index(before: trimmed.endIndex) + let name = String(trimmed[start.. String { + guard value.count >= 2 else { return value } + let first = value.first + let last = value.last + guard let first, let last, first == last, (first == "\"" || first == "'") else { + return value + } + return String(value.dropFirst().dropLast()) +} + +private extension AttrEnv { + func resolveTypedPath(_ path: String) -> XADAttributeValue? { + guard let segments = parseXADPathSegments(path, env: self), !segments.isEmpty else { + return nil + } + guard case .key(let root) = segments[0], let rootValue = typedValues[root] else { + return nil + } + var current: XADAttributeValue? = rootValue + for segment in segments.dropFirst() { + guard let value = current else { return nil } + switch (value, segment) { + case (.dictionary(let dict), .key(let key)): + current = dict[key] + case (.array(let array), .index(let idx)): + guard idx >= 0, idx < array.count else { return nil } + current = array[idx] + default: + return nil + } + } + return current + } + + func stringify(_ value: XADAttributeValue, join: String?) -> String { + switch value { + case .string(let s): + return s + case .number(let n): + return String(n) + case .bool(let b): + return b ? "true" : "false" + case .null: + return "null" + case .array(let array): + if let join { + return array.map { stringify($0, join: nil) }.joined(separator: join) + } + return jsonString(from: value) + case .dictionary: + return jsonString(from: value) + } + } + + func jsonString(from value: XADAttributeValue) -> String { + let obj = value.toJSONCompatible() + guard YYJSONSerialization.isValidJSONObject(obj), + let data = try? YYJSONSerialization.data(withJSONObject: obj, options: []) else { + return "" + } + return String(data: data, encoding: .utf8) ?? "" + } + + mutating func setTypedPath(_ path: String, value: XADAttributeValue) { + guard let segments = parseXADPathSegments(path, env: self), !segments.isEmpty else { return } + guard case .key(let root) = segments[0] else { return } + let updated = setTypedValue( + current: typedValues[root], + segments: segments.dropFirst(), + value: value + ) + typedValues[root] = updated + } + + func setTypedValue( + current: XADAttributeValue?, + segments: ArraySlice, + value: XADAttributeValue + ) -> XADAttributeValue { + guard let first = segments.first else { return value } + let rest = segments.dropFirst() + + switch first { + case .key(let key): + var dict: [String: XADAttributeValue] + if case .dictionary(let existing)? = current { + dict = existing + } else { + dict = [:] + } + let updated = setTypedValue(current: dict[key], segments: rest, value: value) + dict[key] = updated + return .dictionary(dict) + case .index(let idx): + var array: [XADAttributeValue] + if case .array(let existing)? = current { + array = existing + } else { + array = [] + } + if idx >= array.count { + array.append(contentsOf: repeatElement(.null, count: idx - array.count + 1)) + } + let updated = setTypedValue(current: array[idx], segments: rest, value: value) + array[idx] = updated + return .array(array) + } + } +} + package extension AdocInline { func applyingAttributes(using env: AttrEnv) -> AdocInline { switch self { @@ -140,6 +476,11 @@ package extension AdocText { } } +private func expandRawText(_ text: AdocText, using env: AttrEnv) -> AdocText { + let expanded = env.expand(text.plain) + return AdocText(inlines: [.text(expanded, span: text.span)], span: text.span) +} + package extension AdocParagraph { func applyingAttributes(using env: AttrEnv) -> AdocParagraph { var copy = self @@ -154,7 +495,7 @@ package extension AdocParagraph { package extension AdocListing { func applyingAttributes(using env: AttrEnv) -> AdocListing { var copy = self - copy.text = copy.text.applyingAttributes(using: env) + copy.text = expandRawText(copy.text, using: env) if let t = copy.title { copy.title = t.applyingAttributes(using: env) } if let r = copy.reftext { copy.reftext = r.applyingAttributes(using: env) } return copy @@ -255,7 +596,7 @@ package extension AdocBlock { return .verse(vv) case .literalBlock(let lb): var ll = lb - ll.text = ll.text.applyingAttributes(using: env) + ll.text = expandRawText(ll.text, using: env) if let t = ll.title { ll.title = t.applyingAttributes(using: env) } if let r = ll.reftext { ll.reftext = r.applyingAttributes(using: env) } return .literalBlock(ll) diff --git a/Sources/AsciiDocCore/ISM.swift b/Sources/AsciiDocCore/ISM.swift index 616fbd9..33d0978 100644 --- a/Sources/AsciiDocCore/ISM.swift +++ b/Sources/AsciiDocCore/ISM.swift @@ -387,32 +387,74 @@ public struct AdocAuthor: Sendable, Equatable { public var address: String? } +public struct AdocRevision: Sendable, Equatable { + public var number: String? + public var date: String? + public var remark: String? +} public struct AdocHeader: Sendable, Equatable { public var title: AdocText? public var authors: [AdocAuthor]? // ISM-native author model + public var revisions: [AdocRevision]? // XAD: multiple revision lines public var location: AdocLocation? // ISM-native location } +public struct AdocWarning: Sendable, Equatable { + public var message: String + public var span: AdocRange? + public init(message: String, span: AdocRange? = nil) { + self.message = message + self.span = span + } +} public struct AdocDocument: Sendable, Equatable { public var attributes: [String: String?] = [:] + public var typedAttributes: [String: XADAttributeValue] = [:] public var header: AdocHeader? = nil public var blocks: [AdocBlock] = [] + public var warnings: [AdocWarning] = [] public var span: AdocRange? + public var xadOptions: XADOptions = .init() public init( attributes: [String: String?] = [:], + typedAttributes: [String: XADAttributeValue] = [:], header: AdocHeader? = nil, blocks: [AdocBlock] = [], - span: AdocRange? = nil + warnings: [AdocWarning] = [], + span: AdocRange? = nil, + xadOptions: XADOptions = .init() ) { self.attributes = attributes + self.typedAttributes = typedAttributes self.header = header self.blocks = blocks + self.warnings = warnings self.span = span + self.xadOptions = xadOptions + } + + public init( + attributes: [String: String?] = [:], + header: AdocHeader? = nil, + blocks: [AdocBlock] = [], + warnings: [AdocWarning] = [], + span: AdocRange? = nil, + xadOptions: XADOptions = .init() + ) { + self.init( + attributes: attributes, + typedAttributes: [:], + header: header, + blocks: blocks, + warnings: warnings, + span: span, + xadOptions: xadOptions + ) } } diff --git a/Sources/AsciiDocCore/ISM_BlockMacro.swift b/Sources/AsciiDocCore/ISM_BlockMacro.swift index 8462e6e..e3bafc2 100644 --- a/Sources/AsciiDocCore/ISM_BlockMacro.swift +++ b/Sources/AsciiDocCore/ISM_BlockMacro.swift @@ -6,6 +6,7 @@ public struct AdocBlockMacro: Sendable, Equatable { public var name: String // "include", "image", etc public var target: String? + public var attributes: [String: String] public var id: String? public var title: AdocText? public var reftext: AdocText? @@ -15,6 +16,7 @@ public struct AdocBlockMacro: Sendable, Equatable { public init( name: String, target: String? = nil, + attributes: [String: String] = [:], id: String? = nil, title: AdocText? = nil, reftext: AdocText? = nil, @@ -22,6 +24,7 @@ public struct AdocBlockMacro: Sendable, Equatable { span: AdocRange? = nil) { self.name = name self.target = target + self.attributes = attributes self.id = id self.title = title self.reftext = reftext diff --git a/Sources/AsciiDocCore/LayoutDSL.swift b/Sources/AsciiDocCore/LayoutDSL.swift new file mode 100644 index 0000000..27e4735 --- /dev/null +++ b/Sources/AsciiDocCore/LayoutDSL.swift @@ -0,0 +1,607 @@ +// +// Copyright (c) 2026 Mattias Holm +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public enum LayoutExpr: Sendable, Equatable { + case node(LayoutNode) + case value(LayoutValue) +} + +public struct LayoutProgram: Sendable, Equatable { + public var expressions: [LayoutExpr] + + public init(expressions: [LayoutExpr]) { + self.expressions = expressions + } +} + +public struct LayoutNode: Sendable, Equatable { + public var name: String + public var args: [LayoutArg] + public var children: [LayoutExpr] + public var span: AdocRange? + + public init(name: String, args: [LayoutArg], children: [LayoutExpr], span: AdocRange?) { + self.name = name + self.args = args + self.children = children + self.span = span + } +} + +public struct LayoutArg: Sendable, Equatable { + public var name: String? + public var value: LayoutExpr + public var span: AdocRange? + + public init(name: String?, value: LayoutExpr, span: AdocRange?) { + self.name = name + self.value = value + self.span = span + } +} + +public enum LayoutValue: Sendable, Equatable { + case string(String) + case number(Double) + case boolean(Bool) + case null + case array([LayoutValue]) + case dict([String: LayoutValue]) + case ref(LayoutRef) +} + +public struct LayoutRef: Sendable, Equatable { + public var parts: [String] + public var index: LayoutIndex? + + public init(parts: [String], index: LayoutIndex?) { + self.parts = parts + self.index = index + } +} + +public enum LayoutIndex: Sendable, Equatable { + case number(Double) + case string(String) + case identifier(String) +} + +public struct LayoutDSLParser: Sendable { + public init() {} + + public func parse(text: String, span: AdocRange? = nil) -> (LayoutProgram?, [AdocWarning]) { + let tokenizer = LayoutDSLTokenizer(text: text) + var parser = LayoutDSLParserState(tokenizer: tokenizer, span: span) + let program = parser.parseProgram() + return (program, parser.warnings) + } +} + +private enum LayoutDSLTokenKind: Equatable { + case identifier(String) + case string(String) + case number(Double) + case boolean(Bool) + case null + case lparen + case rparen + case lbracket + case rbracket + case lbrace + case rbrace + case colon + case comma + case semicolon + case dot + case eof +} + +private struct LayoutDSLToken: Equatable { + let kind: LayoutDSLTokenKind + let span: AdocRange? +} + +private struct LayoutDSLTokenizer { + private let text: String + private var index: String.Index + private var line: Int + private var column: Int + + init(text: String) { + self.text = text + self.index = text.startIndex + self.line = 1 + self.column = 1 + } + + mutating func nextToken() -> LayoutDSLToken { + skipWhitespaceAndComments() + guard index < text.endIndex else { + return LayoutDSLToken(kind: .eof, span: nil) + } + + let start = position() + let ch = text[index] + + switch ch { + case "(": + advance() + return token(.lparen, start: start) + case ")": + advance() + return token(.rparen, start: start) + case "[": + advance() + return token(.lbracket, start: start) + case "]": + advance() + return token(.rbracket, start: start) + case "{": + advance() + return token(.lbrace, start: start) + case "}": + advance() + return token(.rbrace, start: start) + case ":": + advance() + return token(.colon, start: start) + case ",": + advance() + return token(.comma, start: start) + case ";": + advance() + return token(.semicolon, start: start) + case ".": + advance() + return token(.dot, start: start) + case "\"", "'": + return readString(quote: ch, start: start) + case "-": + return readNumber(start: start) + default: + if ch.isNumber { + return readNumber(start: start) + } + if isIdentifierStart(ch) { + return readIdentifier(start: start) + } + } + + advance() + return token(.eof, start: start) + } + + private mutating func skipWhitespaceAndComments() { + while index < text.endIndex { + let ch = text[index] + if ch == "/" { + let next = text.index(after: index) + if next < text.endIndex, text[next] == "/" { + advance() + advance() + while index < text.endIndex, text[index] != "\n" { + advance() + } + continue + } + } + if ch == " " || ch == "\t" || ch == "\r" || ch == "\n" { + advance() + continue + } + break + } + } + + private mutating func readIdentifier(start: AdocPos) -> LayoutDSLToken { + var value = String() + while index < text.endIndex { + let ch = text[index] + if isIdentifierPart(ch) { + value.append(ch) + advance() + } else { + break + } + } + + switch value { + case "true": + return token(.boolean(true), start: start) + case "false": + return token(.boolean(false), start: start) + case "null": + return token(.null, start: start) + default: + return token(.identifier(value), start: start) + } + } + + private mutating func readNumber(start: AdocPos) -> LayoutDSLToken { + var value = String() + if index < text.endIndex, text[index] == "-" { + value.append("-") + advance() + } + while index < text.endIndex, text[index].isNumber { + value.append(text[index]) + advance() + } + if index < text.endIndex, text[index] == "." { + value.append(".") + advance() + while index < text.endIndex, text[index].isNumber { + value.append(text[index]) + advance() + } + } + let number = Double(value) ?? 0 + return token(.number(number), start: start) + } + + private mutating func readString(quote: Character, start: AdocPos) -> LayoutDSLToken { + advance() + var value = String() + while index < text.endIndex { + let ch = text[index] + if ch == quote { + advance() + return token(.string(value), start: start) + } + if ch == "\\" { + let next = text.index(after: index) + if next < text.endIndex { + value.append(text[next]) + advance() + advance() + continue + } + } + value.append(ch) + advance() + } + return token(.string(value), start: start) + } + + private func isIdentifierStart(_ ch: Character) -> Bool { + return ch.isLetter || ch == "_" + } + + private func isIdentifierPart(_ ch: Character) -> Bool { + return ch.isLetter || ch.isNumber || ch == "_" || ch == "-" + } + + private mutating func advance() { + guard index < text.endIndex else { return } + if text[index] == "\n" { + line += 1 + column = 1 + } else { + column += 1 + } + index = text.index(after: index) + } + + private func position() -> AdocPos { + AdocPos(offset: index, line: line, column: column) + } + + private func token(_ kind: LayoutDSLTokenKind, start: AdocPos) -> LayoutDSLToken { + let end = position() + let span = AdocRange(start: start, end: end) + return LayoutDSLToken(kind: kind, span: span) + } +} + +private struct LayoutDSLParserState { + var tokenizer: LayoutDSLTokenizer + var lookahead: LayoutDSLToken? + var warnings: [AdocWarning] = [] + let baseSpan: AdocRange? + + init(tokenizer: LayoutDSLTokenizer, span: AdocRange?) { + self.tokenizer = tokenizer + self.baseSpan = span + } + + mutating func parseProgram() -> LayoutProgram? { + let exprs = parseExprList(terminators: [.eof]) + if exprs.isEmpty { + return nil + } + return LayoutProgram(expressions: exprs) + } + + mutating func parseExprList(terminators: [LayoutDSLTokenKind]) -> [LayoutExpr] { + var exprs: [LayoutExpr] = [] + while true { + let token = peek() + if token.kind == .eof { + break + } + if terminators.contains(where: { $0 == token.kind }) { + break + } + if let expr = parseExpr() { + exprs.append(expr) + } else { + _ = advance() + } + if consumeIf(.comma) || consumeIf(.semicolon) { + continue + } + } + return exprs + } + + mutating func parseExpr() -> LayoutExpr? { + guard let token = peekIdentifierOrValueStart() else { + warn("expected expression", span: peek().span) + return nil + } + switch token.kind { + case .identifier(let name): + let saved = token + _ = advance() + if consumeIf(.lparen) { + let args = parseArgList(closing: .rparen) + _ = consumeExpected(.rparen, message: "expected ')' to close arguments") + let children = parseChildrenIfPresent() + let span = mergeSpan(saved.span, children.span) + let node = LayoutNode(name: name, args: args, children: children.nodes, span: span) + return .node(node) + } + if consumeIf(.lbracket) { + let children = parseExprList(terminators: [.rbracket, .eof]) + _ = consumeExpected(.rbracket, message: "expected ']' to close children") + let span = mergeSpan(saved.span, peek().span) + let node = LayoutNode(name: name, args: [], children: children, span: span) + return .node(node) + } + return .value(parseRefFromFirst(name: name, span: saved.span)) + default: + return parseValue().map { .value($0) } + } + } + + mutating func parseChildrenIfPresent() -> (nodes: [LayoutExpr], span: AdocRange?) { + guard consumeIf(.lbracket) else { return ([], nil) } + let children = parseExprList(terminators: [.rbracket, .eof]) + let closing = consumeExpected(.rbracket, message: "expected ']' to close children") + let span = mergeSpan(children.first?.span, closing?.span) + return (children, span) + } + + mutating func parseArgList(closing: LayoutDSLTokenKind) -> [LayoutArg] { + var args: [LayoutArg] = [] + while true { + let next = peek() + if next.kind == .eof { break } + if next.kind == closing { break } + if let arg = parseArg() { + args.append(arg) + } else { + _ = advance() + } + if consumeIf(.comma) { continue } + } + return args + } + + mutating func parseArg() -> LayoutArg? { + let token = peek() + if case .identifier(let name) = token.kind { + let next = peekSecond() + if next.kind == .colon { + let saved = token + _ = advance() + _ = consumeExpected(.colon, message: "expected ':' after argument name") + if let expr = parseExpr() { + return LayoutArg(name: name, value: expr, span: mergeSpan(saved.span, expr.span)) + } + warn("expected expression after ':'", span: peek().span) + return LayoutArg(name: name, value: .value(.null), span: saved.span) + } + } + + if let expr = parseExpr() { + return LayoutArg(name: nil, value: expr, span: expr.span) + } + return nil + } + + mutating func parseValue() -> LayoutValue? { + let token = peek() + switch token.kind { + case .string(let value): + _ = advance() + return .string(value) + case .number(let value): + _ = advance() + return .number(value) + case .boolean(let value): + _ = advance() + return .boolean(value) + case .null: + _ = advance() + return .null + case .lbracket: + return parseArray() + case .lbrace: + return parseDict() + case .identifier(let name): + _ = advance() + return parseRefFromFirst(name: name, span: token.span) + default: + warn("expected value", span: token.span) + return nil + } + } + + mutating func parseArray() -> LayoutValue { + _ = consumeExpected(.lbracket, message: "expected '[' to start array") + var values: [LayoutValue] = [] + while true { + let next = peek() + if next.kind == .eof { break } + if next.kind == .rbracket { break } + if let value = parseValue() { + values.append(value) + } else { + _ = advance() + } + if consumeIf(.comma) { continue } + } + _ = consumeExpected(.rbracket, message: "expected ']' to close array") + return .array(values) + } + + mutating func parseDict() -> LayoutValue { + _ = consumeExpected(.lbrace, message: "expected '{' to start dictionary") + var dict: [String: LayoutValue] = [:] + while true { + let next = peek() + if next.kind == .eof { break } + if next.kind == .rbrace { break } + guard let key = parseDictKey() else { + _ = advance() + continue + } + _ = consumeExpected(.colon, message: "expected ':' after dictionary key") + if let value = parseValue() { + dict[key] = value + } else { + dict[key] = .null + } + if consumeIf(.comma) { continue } + } + _ = consumeExpected(.rbrace, message: "expected '}' to close dictionary") + return .dict(dict) + } + + mutating func parseDictKey() -> String? { + let token = peek() + switch token.kind { + case .identifier(let name): + _ = advance() + return name + case .string(let value): + _ = advance() + return value + default: + warn("expected dictionary key", span: token.span) + return nil + } + } + + mutating func parseRefFromFirst(name: String, span: AdocRange?) -> LayoutValue { + var parts = [name] + var index: LayoutIndex? = nil + while true { + if consumeIf(.dot) { + if case .identifier(let nextName) = peek().kind { + _ = advance() + parts.append(nextName) + } else { + warn("expected identifier after '.'", span: peek().span) + break + } + continue + } + if consumeIf(.lbracket) { + index = parseIndex() + _ = consumeExpected(.rbracket, message: "expected ']' after index") + break + } + break + } + return .ref(LayoutRef(parts: parts, index: index)) + } + + mutating func parseIndex() -> LayoutIndex? { + let token = peek() + switch token.kind { + case .number(let value): + _ = advance() + return .number(value) + case .string(let value): + _ = advance() + return .string(value) + case .identifier(let value): + _ = advance() + return .identifier(value) + default: + warn("expected index value", span: token.span) + return nil + } + } + + mutating func peek() -> LayoutDSLToken { + if let lookahead { return lookahead } + let token = tokenizer.nextToken() + lookahead = token + return token + } + + mutating func peekSecond() -> LayoutDSLToken { + _ = peek() + var snapshot = tokenizer + return snapshot.nextToken() + } + + mutating func advance() -> LayoutDSLToken { + let token = peek() + lookahead = nil + return token + } + + mutating func consumeIf(_ kind: LayoutDSLTokenKind) -> Bool { + if peek().kind == kind { + _ = advance() + return true + } + return false + } + + mutating func consumeExpected(_ kind: LayoutDSLTokenKind, message: String) -> LayoutDSLToken? { + if peek().kind == kind { + return advance() + } + warn(message, span: peek().span) + return nil + } + + mutating func peekIdentifierOrValueStart() -> LayoutDSLToken? { + let token = peek() + switch token.kind { + case .identifier, .string, .number, .boolean, .null, .lbracket, .lbrace: + return token + default: + return nil + } + } + + mutating func warn(_ message: String, span: AdocRange?) { + warnings.append(AdocWarning(message: message, span: span)) + } + + func mergeSpan(_ a: AdocRange?, _ b: AdocRange?) -> AdocRange? { + guard let a, let b else { return a ?? b } + return AdocRange(start: a.start, end: b.end) + } +} + +private extension LayoutExpr { + var span: AdocRange? { + switch self { + case .node(let node): + return node.span + case .value: + return nil + } + } +} diff --git a/Sources/AsciiDocCore/Parser+Block.swift b/Sources/AsciiDocCore/Parser+Block.swift index 0a634ff..8c99cfb 100644 --- a/Sources/AsciiDocCore/Parser+Block.swift +++ b/Sources/AsciiDocCore/Parser+Block.swift @@ -5,7 +5,7 @@ import Foundation -private func parseDirectivePayload(_ payload: String) -> (target: String, body: String?) { +func parseDirectivePayload(_ payload: String) -> (target: String, body: String?) { guard let open = payload.firstIndex(of: "["), let close = payload.lastIndex(of: "]"), close > open else { @@ -25,13 +25,234 @@ private func parseDirectivePayload(_ payload: String) -> (target: String, body: ) } +func parseMacroAttributeList(_ body: String) -> [String: String] { + var result: [String: String] = [:] + var current = "" + var inQuotes = false + var quoteChar: Character? = nil + + func flush() { + let part = current.trimmingCharacters(in: .whitespacesAndNewlines) + current = "" + guard !part.isEmpty else { return } + if let eq = part.firstIndex(of: "=") { + let key = part[..= 2 { + if (value.first == "\"" && value.last == "\"") || (value.first == "'" && value.last == "'") { + value = String(value.dropFirst().dropLast()) + } + } + if !key.isEmpty { + result[String(key)] = String(value) + } + } else { + result[part] = "" + } + } + + for ch in body { + if inQuotes { + if ch == "\\" { + current.append(ch) + continue + } + if let currentQuote = quoteChar, ch == currentQuote { + inQuotes = false + quoteChar = nil + } + current.append(ch) + continue + } + + if ch == "\"" || ch == "'" { + inQuotes = true + quoteChar = ch + current.append(ch) + continue + } + + if ch == "," { + flush() + continue + } + current.append(ch) + } + + flush() + return result +} + +func normalizeXADAttributeValue(_ value: String, xadOptions: XADOptions) -> String { + guard xadOptions.enabled else { return value } + var index = value.startIndex + while index < value.endIndex, value[index].isWhitespace { + index = value.index(after: index) + } + guard index < value.endIndex, value[index] == "\\" else { return value } + let nextIndex = value.index(after: index) + guard nextIndex < value.endIndex else { return value } + let nextChar = value[nextIndex] + guard nextChar == "{" || nextChar == "[" else { return value } + var result = value + result.remove(at: index) + return result +} + +func consumesMultilineJSON(_ value: String, xadOptions: XADOptions) -> Bool { + guard xadOptions.enabled else { return false } + var index = value.startIndex + while index < value.endIndex, value[index].isWhitespace { + index = value.index(after: index) + } + guard index < value.endIndex else { return false } + let lead = value[index] + guard lead == "{" || lead == "[" else { return false } + return !isJSONBalanced(value) +} + +func isJSONBalanced(_ value: String) -> Bool { + var braceDepth = 0 + var bracketDepth = 0 + var inString = false + var escapeNext = false + + for ch in value { + if inString { + if escapeNext { + escapeNext = false + continue + } + if ch == "\\" { + escapeNext = true + continue + } + if ch == "\"" { + inString = false + } + continue + } + + if ch == "\"" { + inString = true + continue + } + if ch == "{" { braceDepth += 1 } + else if ch == "}" { braceDepth -= 1 } + else if ch == "[" { bracketDepth += 1 } + else if ch == "]" { bracketDepth -= 1 } + } + + return braceDepth <= 0 && bracketDepth <= 0 && !inString +} + +func parseAttributeAssignmentsFromDelimitedBlock( + it: inout TokenIter, + xadOptions: XADOptions +) -> [(name: String, value: String?, unset: Bool)]? { + guard let open = it.peek(), case .blockFence(let kind, _) = open.kind, kind == .listing else { + return nil + } + it.consume() + + var lines: [String] = [] + while let tok = it.peek() { + if case .blockFence(let kind, _) = tok.kind, kind == .listing { + it.consume() + break + } + lines.append(it.rawLineText(of: tok)) + it.consume() + } + + return parseAttributeAssignmentsFromLines(lines, xadOptions: xadOptions) +} + +func parseAttributeAssignmentsFromLines( + _ lines: [String], + xadOptions: XADOptions +) -> [(name: String, value: String?, unset: Bool)] { + var assignments: [(name: String, value: String?, unset: Bool)] = [] + var index = 0 + + while index < lines.count { + let rawLine = lines[index] + let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.hasPrefix(":") { + index += 1 + continue + } + let nameStart = trimmed.index(after: trimmed.startIndex) + guard let nameEnd = trimmed[nameStart...].firstIndex(of: ":") else { + index += 1 + continue + } + var name = String(trimmed[nameStart.. String { + guard consumesMultilineJSON(initial, xadOptions: xadOptions) else { return initial } + + var result = initial + while true { + guard let nextTok = it.peek(), case .text(let r) = nextTok.kind else { break } + let nextLine = it.textFromRelative(range: r, token: nextTok) + result.append("\n") + result.append(nextLine) + it.consume() + if !consumesMultilineJSON(result, xadOptions: xadOptions) { + break + } + } + return result +} + extension AdocParser { // Parse a sequence of blocks until `stop` says “this token belongs to the caller”. // Parse blocks until `stop` returns true for the *next* token. func parseBlocks( it: inout TokenIter, - env: AttrEnv, + env: inout AttrEnv, + warnings: inout [AdocWarning], stop: (Token) -> Bool ) -> [AdocBlock] { var blocks: [AdocBlock] = [] @@ -40,7 +261,7 @@ extension AdocParser { let before = it.index - if let block = parseBlock(it: &it, env: env) { + if let block = parseBlock(it: &it, env: &env, warnings: &warnings) { blocks.append(block) } else { // Safety to avoid infinite loops: always consume something. @@ -53,22 +274,111 @@ extension AdocParser { } return blocks } + + func applyAttributeAssignments( + _ assignments: [(name: String, value: String?, unset: Bool)], + env: inout AttrEnv + ) { + for assignment in assignments { + if assignment.unset { + env.applyAttributeUnset(name: assignment.name) + continue + } + let rawValue = assignment.value + let normalized = rawValue.map { normalizeXADAttributeValue($0, xadOptions: env.xadOptions) } + env.applyAttributeSet(name: assignment.name, value: normalized) + } + } + + private func parseBlockWithScopedAttributes( + _ assignments: [(name: String, value: String?, unset: Bool)], + it: inout TokenIter, + env: inout AttrEnv, + warnings: inout [AdocWarning], + warningSpan: AdocRange? + ) -> AdocBlock? { + while let next = it.peek(), case .blank = next.kind { + it.consume() + } + + env.pushScope() + applyAttributeAssignments(assignments, env: &env) + let block = parseBlock(it: &it, env: &env, warnings: &warnings) + _ = env.popScope() + if block == nil, env.xadOptions.enabled { + warnings.append( + AdocWarning( + message: "blockattr has no following block to apply to.", + span: warningSpan + ) + ) + } + return block + } + func parseBlock( it: inout TokenIter, - env: AttrEnv + env: inout AttrEnv, + warnings: inout [AdocWarning] ) -> AdocBlock? { // Slurp block metadata (ID, roles, options, attributes, title, span) let meta = consumeBlockMeta(it: &it, env: env) guard let tok = it.peek() else { return nil } + if env.xadOptions.enabled, meta.primaryStyle == "attrs" { + warnings.append( + AdocWarning( + message: "attrs is only valid immediately after a section title.", + span: meta.span + ) + ) + } + + if case .attrSet(let nameR, let valueR) = tok.kind { + let name = it.textFromRelative(range: nameR, token: tok) + var value = valueR.map { it.textFromRelative(range: $0, token: tok) } + it.consume() + if let raw = value { + value = normalizeXADAttributeValue(raw, xadOptions: env.xadOptions) + } + if let raw = value { + value = consumeAttributeMultilineJSON( + initial: raw, + it: &it, + xadOptions: env.xadOptions + ) + } + env.applyAttributeSet(name: name, value: value) + return nil + } + + if case .attrUnset(let nameR) = tok.kind { + let name = it.textFromRelative(range: nameR, token: tok) + it.consume() + env.applyAttributeUnset(name: name) + return nil + } + + if meta.primaryStyle == "blockattr" { + if let assignments = parseAttributeAssignmentsFromDelimitedBlock(it: &it, xadOptions: env.xadOptions) { + return parseBlockWithScopedAttributes( + assignments, + it: &it, + env: &env, + warnings: &warnings, + warningSpan: meta.span + ) + } + } + var inner: AdocBlock? // Lists / DLists: handled up front, since they consume multiple lines switch tok.kind { case .atxSection(let level, let titleRange): if let section = parseSection(from: tok, level: level, titleRange: titleRange, - it: &it, env: env) { + it: &it, env: &env, warnings: &warnings) { var block = AdocBlock.section(section) meta.applyMeta(to: &block) return block; @@ -83,7 +393,8 @@ extension AdocParser { for: key, listKind: listKind, it: &it, - env: env, + env: &env, + warnings: &warnings, stack: &stack, bibliographyStyle: isBibliography ) { @@ -102,7 +413,8 @@ extension AdocParser { if let dlist = parseDList( for: marker, it: &it, - env: env, + env: &env, + warnings: &warnings, stack: &stack ) { inner = .dlist(dlist) @@ -165,12 +477,12 @@ extension AdocParser { } case .quote: - if let q = parseQuote(it: &it, env: env) { + if let q = parseQuote(it: &it, env: &env, warnings: &warnings) { inner = .quote(q) } case .verseBlock: - if let q = parseQuote(it: &it, env: env) { + if let q = parseQuote(it: &it, env: &env, warnings: &warnings) { let span = combinedSpan(metaSpan: meta.span, innerSpan: q.span) let verse = AdocVerse( text: nil, @@ -204,17 +516,17 @@ extension AdocParser { } case .sidebar: - if let s = parseSidebar(it: &it, env: env) { + if let s = parseSidebar(it: &it, env: &env, warnings: &warnings) { inner = .sidebar(s) } case .example: - if let e = parseExample(it: &it, env: env) { + if let e = parseExample(it: &it, env: &env, warnings: &warnings) { inner = .example(e) } case .open: - if let o = parseOpen(it: &it, env: env) { + if let o = parseOpen(it: &it, env: &env, warnings: &warnings) { inner = .open(o) } @@ -241,6 +553,16 @@ extension AdocParser { let payload = String(tok.string[payloadRange]) let payloadParts = parseDirectivePayload(payload) + if name == "attrs", env.xadOptions.enabled { + warnings.append( + AdocWarning( + message: "attrs is only valid immediately after a section title.", + span: it.spanForLine(tok) + ) + ) + return nil + } + let targetForMath = payloadParts.target.isEmpty ? nil : payloadParts.target if let mathKind = AdocMathKind(macroName: name), let mathBody = payloadParts.body ?? targetForMath, @@ -260,9 +582,109 @@ extension AdocParser { break } - let macro = AdocBlockMacro( + if name == "attrpush", env.xadOptions.enabled { + let attrs = parseMacroAttributeList(payloadParts.body ?? "") + env.pushScope() + applyAttributeAssignments( + attrs.map { (name: $0.key, value: $0.value, unset: false) }, + env: &env + ) + return nil + } + + if name == "attrpop", env.xadOptions.enabled { + let popped = env.popScope() + if !popped { + warnings.append( + AdocWarning( + message: "attrpop without a matching attrpush.", + span: it.spanForLine(tok) + ) + ) + } + return nil + } + + if name == "blockattr", env.xadOptions.enabled { + var merged: [String: String] = parseMacroAttributeList(payloadParts.body ?? "") + + while let nextTok = it.peek() { + if case .blank = nextTok.kind { + it.consume() + continue + } + guard case .directive(let nextKind, let nextPayloadRange) = nextTok.kind else { + break + } + let nextName: String = { + switch nextKind { + case .include: return "include" + case .ifdef: return "ifdef" + case .ifndef: return "ifndef" + case .ifeval: return "ifeval" + case .endif: return "endif" + case .other(let n): return n + } + }() + guard nextName == "blockattr" else { break } + it.consume() + let nextPayload = String(nextTok.string[nextPayloadRange]) + let nextParts = parseDirectivePayload(nextPayload) + let nextAttrs = parseMacroAttributeList(nextParts.body ?? "") + for (k, v) in nextAttrs { merged[k] = v } + } + + let assignments = merged.map { (name: $0.key, value: $0.value, unset: false) } + return parseBlockWithScopedAttributes( + assignments, + it: &it, + env: &env, + warnings: &warnings, + warningSpan: it.spanForLine(tok) + ) + } + + if name == "get", env.xadOptions.enabled { + let attrs = parseMacroAttributeList(payloadParts.body ?? "") + let path = attrs["path"] ?? (payloadParts.target.isEmpty ? nil : payloadParts.target) + if let path { + let join = attrs["join"] + let span = it.spanForLine(tok) + let text: String + if let resolved = env.resolveAttribute(path, join: join) { + text = resolved + } else { + text = String(tok.string) + } + let para = AdocParagraph( + text: AdocText(plain: text, span: span), + id: meta.id, + title: meta.title, + reftext: nil, + meta: .init(), + span: span + ) + inner = .paragraph(para) + break + } + } + + if name == "set", env.xadOptions.enabled { + let attrs = parseMacroAttributeList(payloadParts.body ?? "") + let path = attrs["path"] ?? (payloadParts.target.isEmpty ? nil : payloadParts.target) + if let path { + let rawValue = attrs["value"] ?? payloadParts.body ?? "" + let normalizedValue = normalizeXADAttributeValue(rawValue, xadOptions: env.xadOptions) + env.applyAttributeSet(name: path, value: normalizedValue) + return nil + } + } + + let macroAttrs = parseMacroAttributeList(payloadParts.body ?? "") + let macro = AdocBlockMacro( name: name, target: payloadParts.target, + attributes: macroAttrs, id: meta.id, title: meta.title, reftext: nil, // use metadata consumed meta: .init(), // TODO: merge meta properly? span: it.spanForLine(tok) diff --git a/Sources/AsciiDocCore/Parser+DList.swift b/Sources/AsciiDocCore/Parser+DList.swift index 642f276..3d07547 100644 --- a/Sources/AsciiDocCore/Parser+DList.swift +++ b/Sources/AsciiDocCore/Parser+DList.swift @@ -9,7 +9,7 @@ extension AdocParser { /// IMPORTANT: this function CONSUMES the `.dlistItem` token. private func makeDListItem( it: inout TokenIter, - env: AttrEnv + env: inout AttrEnv ) -> AdocDListItem { guard let tok = it.peek(), case .dlistItem(let termRange, _, let descRange) = tok.kind @@ -96,7 +96,8 @@ extension AdocParser { func parseDList( for marker: String, it: inout TokenIter, - env: AttrEnv, + env: inout AttrEnv, + warnings: inout [AdocWarning], stack: inout [String] ) -> AdocDList? { stack.append(marker) @@ -123,7 +124,7 @@ extension AdocParser { if thisMarker == marker { // Same marker → sibling item at this level - let item = makeDListItem(it: &it, env: env) // consumes token + let item = makeDListItem(it: &it, env: &env) // consumes token items.append(item) continue outer } @@ -139,7 +140,7 @@ extension AdocParser { // New marker not in any outer level → nested dlist under last item if var lastItem = items.popLast() { - if let nested = parseDList(for: thisMarker, it: &it, env: env, stack: &stack) { + if let nested = parseDList(for: thisMarker, it: &it, env: &env, warnings: &warnings, stack: &stack) { lastItem.blocks.append(.dlist(nested)) } items.append(lastItem) @@ -167,12 +168,12 @@ extension AdocParser { let line = nextTok.string let thisMarker = String(line[sepRange]) - if let nested = parseDList(for: thisMarker, it: &it, env: env, stack: &stack) { + if let nested = parseDList(for: thisMarker, it: &it, env: &env, warnings: &warnings, stack: &stack) { lastItem.blocks.append(.dlist(nested)) } default: - if let nestedBlock = parseBlock(it: &it, env: env) { + if let nestedBlock = parseBlock(it: &it, env: &env, warnings: &warnings) { lastItem.blocks.append(nestedBlock) } } diff --git a/Sources/AsciiDocCore/Parser+Example.swift b/Sources/AsciiDocCore/Parser+Example.swift index d8745dd..17e8c7d 100644 --- a/Sources/AsciiDocCore/Parser+Example.swift +++ b/Sources/AsciiDocCore/Parser+Example.swift @@ -6,7 +6,8 @@ extension AdocParser { func parseExample( it: inout TokenIter, - env: AttrEnv + env: inout AttrEnv, + warnings: inout [AdocWarning] ) -> AdocExample? { // We expect the current token to be an example fence guard let open = it.peek(), @@ -20,7 +21,7 @@ extension AdocParser { it.consume() // Parse inner blocks until we see a matching example fence - let innerBlocks: [AdocBlock] = parseBlocks(it: &it, env: env) { tok in + let innerBlocks: [AdocBlock] = parseBlocks(it: &it, env: &env, warnings: &warnings) { tok in if case .blockFence(let k, let l) = tok.kind, k == .example, l == fenceLen { return true // stop before this token diff --git a/Sources/AsciiDocCore/Parser+Header.swift b/Sources/AsciiDocCore/Parser+Header.swift index 5023944..86ff06d 100644 --- a/Sources/AsciiDocCore/Parser+Header.swift +++ b/Sources/AsciiDocCore/Parser+Header.swift @@ -4,6 +4,7 @@ // import Foundation +import YYJSON private struct HeaderAuthorInfo { var fullname: String @@ -25,10 +26,12 @@ extension AdocParser { func detectHeader( into header: inout AdocHeader?, attrs docAttrs: inout [String: String?], + typedAttrs: inout [String: XADAttributeValue], it: inout TokenIter, env: inout AttrEnv, lockedAttributes: Set, - includeDerivedAttributes: Bool + includeDerivedAttributes: Bool, + xadOptions: XADOptions ) { // ——— Header parsing (no blank lines allowed within header) ——— // Header grammar at top of document: @@ -43,7 +46,7 @@ extension AdocParser { var lastHeaderTok: Token? = nil var headerSeen = false var parsedAuthors: [HeaderAuthorInfo]? = nil - var revisionInfo: HeaderRevisionInfo? = nil + var revisions: [HeaderRevisionInfo] = [] // Optional title if let tok = it.peek(), case .atxSection(let lvl, let titleRel) = tok.kind, lvl == 0 { @@ -65,7 +68,7 @@ extension AdocParser { headerSeen = true } - // Optional author/revision line (must be immediately next, no blank in between) + // Optional author/revision lines (must be immediately next, no blank in between) var consumedAuthorLine = false if let tok = it.peek(), case .blank = tok.kind { // blank → header ends; do nothing @@ -74,15 +77,20 @@ extension AdocParser { let line = env.expand(authorLineRaw).trimmingCharacters(in: .whitespacesAndNewlines) if !line.isEmpty { var handled = false - if parsedAuthors == nil && header?.title != nil { - if let revisionCandidate = parseRevisionLine(line) { - // Only treat as revision if there wasn't an author line and it clearly matches a revision pattern. - revisionInfo = revisionCandidate - it.consume() - lastHeaderTok = tok - headerSeen = true - handled = true - } + if parsedAuthors == nil && header?.title != nil, let revisionCandidate = parseRevisionLine(line) { + // Only treat as revision if there wasn't an author line and it clearly matches a revision pattern. + revisions.append(revisionCandidate) + it.consume() + lastHeaderTok = tok + headerSeen = true + handled = true + consumeRevisionLines( + it: &it, + env: env, + revisions: &revisions, + lastHeaderTok: &lastHeaderTok, + headerSeen: &headerSeen + ) } if !handled { let authors = parseAuthorsLine(line) @@ -112,27 +120,13 @@ extension AdocParser { } if consumedAuthorLine { - if let tok = it.peek(), case .blank = tok.kind { - // nothing - } else if headerSeen, let tok = it.peek(), case .text(let r) = tok.kind { - let revisionLineRaw = it.textFromRelative(range: r, token: tok) - let revisionLine = env.expand(revisionLineRaw).trimmingCharacters(in: .whitespacesAndNewlines) - if let revision = parseRevisionLine(revisionLine) { - revisionInfo = revision - it.consume() - lastHeaderTok = tok - headerSeen = true - } - } - } else if revisionInfo == nil, let tok = it.peek(), case .text(let r) = tok.kind, headerSeen { - let revisionLineRaw = it.textFromRelative(range: r, token: tok) - let revisionLine = env.expand(revisionLineRaw).trimmingCharacters(in: .whitespacesAndNewlines) - if let revision = parseRevisionLine(revisionLine) { - revisionInfo = revision - it.consume() - lastHeaderTok = tok - headerSeen = true - } + consumeRevisionLines( + it: &it, + env: env, + revisions: &revisions, + lastHeaderTok: &lastHeaderTok, + headerSeen: &headerSeen + ) } // Optional list of attributes (no blank lines allowed within header) @@ -141,21 +135,41 @@ extension AdocParser { switch tok.kind { case .attrSet(let nameR, let valueR): let name = it.textFromRelative(range: nameR, token: tok) - let value = valueR.map { it.textFromRelative(range: $0, token: tok) } - if !lockedAttributes.contains(name) { - docAttrs[name] = value - env.set(name, to: value) - } - it.consume(); + var value = valueR.map { it.textFromRelative(range: $0, token: tok) } + + it.consume() lastHeaderTok = tok - headerSeen = true + + if let initial = value { + value = consumeAttributeContinuation( + initial: initial, + it: &it, + lastHeaderTok: &lastHeaderTok + ) + } + + if let raw = value { + value = normalizeXADAttributeValue(raw, xadOptions: xadOptions) + } + + if let raw = value { + value = consumeAttributeMultilineJSON( + initial: raw, + it: &it, + lastHeaderTok: &lastHeaderTok, + xadOptions: xadOptions + ) + } + + if !lockedAttributes.contains(name) { + env.applyAttributeSet(name: name, value: value) + } continue attrsLoop case .attrUnset(let nameR): let name = it.textFromRelative(range: nameR, token: tok) if !lockedAttributes.contains(name) { - docAttrs[name] = nil - env.set(name, to: nil) + env.applyAttributeUnset(name: name) } it.consume(); @@ -174,6 +188,21 @@ extension AdocParser { // If we produced any part of the header, ensure attributes map is non-nil if headerSeen { + if !revisions.isEmpty { + if header == nil { + header = AdocHeader(title: nil, authors: nil, location: nil) + } + header!.revisions = revisions.map { + AdocRevision(number: $0.number, date: $0.date, remark: $0.remark) + } + if xadOptions.enabled, + !lockedAttributes.contains("doc.revisions"), + let json = revisionsJSONString(from: revisions) { + env.applyAttributeSet(name: "doc.revisions", value: json) + } + } + docAttrs = env.values + typedAttrs = env.typedValues if docAttrs.isEmpty { docAttrs = [:] } // Ensure we have a header object even if it was attributes-only if header == nil { @@ -193,17 +222,77 @@ extension AdocParser { applyHeaderDerivedAttributes( header: header, authors: parsedAuthors, - revision: revisionInfo, + revision: revisions.last, attrs: &docAttrs, lockedAttributes: lockedAttributes ) } + env = AttrEnv(initial: docAttrs, typedAttributes: typedAttrs, xadOptions: xadOptions) } } } } +private func stripAttributeContinuation(_ value: String) -> (String, Bool) { + var end = value.endIndex + while end > value.startIndex { + let before = value.index(before: end) + if value[before].isWhitespace { + end = before + } else { + break + } + } + guard end > value.startIndex else { return (value, false) } + let last = value.index(before: end) + guard value[last] == "\\" else { return (value, false) } + return (String(value[.. String { + var result = initial + while true { + let (stripped, didStrip) = stripAttributeContinuation(result) + guard didStrip else { break } + result = stripped + guard let nextTok = it.peek(), case .text(let r) = nextTok.kind else { break } + let nextLine = it.textFromRelative(range: r, token: nextTok) + result.append(nextLine) + it.consume() + lastHeaderTok = nextTok + } + return result +} + +private func consumeAttributeMultilineJSON( + initial: String, + it: inout TokenIter, + lastHeaderTok: inout Token?, + xadOptions: XADOptions +) -> String { + guard consumesMultilineJSON(initial, xadOptions: xadOptions) else { return initial } + + var result = initial + while true { + guard let nextTok = it.peek(), case .text(let r) = nextTok.kind else { break } + let nextLine = it.textFromRelative(range: r, token: nextTok) + result.append("\n") + result.append(nextLine) + it.consume() + lastHeaderTok = nextTok + + if !consumesMultilineJSON(result, xadOptions: xadOptions) { + break + } + } + return result +} + private func parseAuthorsLine(_ line: String) -> [HeaderAuthorInfo] { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } @@ -254,6 +343,42 @@ private func parseSingleAuthor(_ raw: String) -> HeaderAuthorInfo { ) } +private func consumeRevisionLines( + it: inout TokenIter, + env: AttrEnv, + revisions: inout [HeaderRevisionInfo], + lastHeaderTok: inout Token?, + headerSeen: inout Bool +) { + while let tok = it.peek(), case .text(let r) = tok.kind { + let revisionLineRaw = it.textFromRelative(range: r, token: tok) + let revisionLine = env.expand(revisionLineRaw).trimmingCharacters(in: .whitespacesAndNewlines) + guard let revision = parseRevisionLine(revisionLine) else { break } + revisions.append(revision) + it.consume() + lastHeaderTok = tok + headerSeen = true + } +} + +private func revisionsJSONString(from revisions: [HeaderRevisionInfo]) -> String? { + guard !revisions.isEmpty else { return nil } + var array: [[String: Any]] = [] + array.reserveCapacity(revisions.count) + for revision in revisions { + var obj: [String: Any] = [:] + if let number = revision.number { obj["version"] = number } + if let date = revision.date { obj["date"] = date } + if let remark = revision.remark { obj["remark"] = remark } + array.append(obj) + } + guard YYJSONSerialization.isValidJSONObject(array), + let data = try? YYJSONSerialization.data(withJSONObject: array, options: []) else { + return nil + } + return String(data: data, encoding: .utf8) +} + private func parseRevisionLine(_ rawLine: String) -> HeaderRevisionInfo? { if rawLine.contains("<") || rawLine.contains("@") { return nil } let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/AsciiDocCore/Parser+Lists.swift b/Sources/AsciiDocCore/Parser+Lists.swift index c723756..88b030c 100644 --- a/Sources/AsciiDocCore/Parser+Lists.swift +++ b/Sources/AsciiDocCore/Parser+Lists.swift @@ -103,7 +103,7 @@ extension AdocParser { /// IMPORTANT: this function CONSUMES the `.listItem` token. private func makeListItem( it: inout TokenIter, - env: AttrEnv, + env: inout AttrEnv, markerKey: ListMarkerKey, bibliographyStyle: Bool ) -> AdocListItem { @@ -231,7 +231,8 @@ extension AdocParser { for marker: ListMarkerKey, listKind: AdocListKind, it: inout TokenIter, - env: AttrEnv, + env: inout AttrEnv, + warnings: inout [AdocWarning], stack: inout [ListMarkerKey], bibliographyStyle: Bool = false ) -> AdocList? { @@ -262,7 +263,7 @@ extension AdocParser { if key == marker { // Same marker → sibling item on this level - let item = makeListItem(it: &it, env: env, markerKey: key, bibliographyStyle: bibliographyStyle) // consumes token + let item = makeListItem(it: &it, env: &env, markerKey: key, bibliographyStyle: bibliographyStyle) // consumes token items.append(item) continue outer } @@ -283,7 +284,8 @@ extension AdocParser { for: key, listKind: inferredListKind(from: key), it: &it, - env: env, + env: &env, + warnings: &warnings, stack: &stack, bibliographyStyle: false ) { @@ -318,7 +320,8 @@ extension AdocParser { for: key, listKind: inferredListKind(from: key), it: &it, - env: env, + env: &env, + warnings: &warnings, stack: &stack, bibliographyStyle: false ) { @@ -326,13 +329,13 @@ extension AdocParser { } } else { // Invalid list marker; fall back to generic block parsing - if let block = parseBlock(it: &it, env: env) { + if let block = parseBlock(it: &it, env: &env, warnings: &warnings) { lastItem.blocks.append(block) } } default: - if let block = parseBlock(it: &it, env: env) { + if let block = parseBlock(it: &it, env: &env, warnings: &warnings) { lastItem.blocks.append(block) } } diff --git a/Sources/AsciiDocCore/Parser+Open.swift b/Sources/AsciiDocCore/Parser+Open.swift index 7a9e2e3..3adaece 100644 --- a/Sources/AsciiDocCore/Parser+Open.swift +++ b/Sources/AsciiDocCore/Parser+Open.swift @@ -6,7 +6,8 @@ extension AdocParser { func parseOpen( it: inout TokenIter, - env: AttrEnv + env: inout AttrEnv, + warnings: inout [AdocWarning] ) -> AdocOpen? { guard let open = it.peek(), case .blockFence(let kind, let fenceLen) = open.kind, @@ -29,7 +30,7 @@ extension AdocParser { it.consume() default: - if let b = parseBlock(it: &it, env: env) { + if let b = parseBlock(it: &it, env: &env, warnings: &warnings) { blocks.append(b) } else { it.consume() diff --git a/Sources/AsciiDocCore/Parser+Paragraph.swift b/Sources/AsciiDocCore/Parser+Paragraph.swift index f43bb45..a8f761c 100644 --- a/Sources/AsciiDocCore/Parser+Paragraph.swift +++ b/Sources/AsciiDocCore/Parser+Paragraph.swift @@ -10,9 +10,9 @@ extension AdocParser { ) -> AdocParagraph? { guard let first = it.peek() else { return nil } - // Paragraphs start only on plain text / directive lines. + // Paragraphs start only on plain text lines. switch first.kind { - case .text, .directive: + case .text: break default: return nil @@ -23,7 +23,7 @@ extension AdocParser { while let t = it.peek() { switch t.kind { - case .text, .directive: + case .text: lines.append(it.contentText(of: t)) lastTok = t it.consume() diff --git a/Sources/AsciiDocCore/Parser+Quote.swift b/Sources/AsciiDocCore/Parser+Quote.swift index e464486..84f4ee7 100644 --- a/Sources/AsciiDocCore/Parser+Quote.swift +++ b/Sources/AsciiDocCore/Parser+Quote.swift @@ -5,7 +5,7 @@ extension AdocParser { // Quote (____) parsing with attribution - func parseQuote(it: inout TokenIter, env: AttrEnv) -> AdocQuote? { + func parseQuote(it: inout TokenIter, env: inout AttrEnv, warnings: inout [AdocWarning]) -> AdocQuote? { guard let open = it.peek(), case .blockFence(let kind, let fenceLen) = open.kind, kind == .quote @@ -28,7 +28,7 @@ extension AdocParser { it.consume() default: - if let b = parseBlock(it: &it, env: env) { + if let b = parseBlock(it: &it, env: &env, warnings: &warnings) { blocks.append(b) } else { it.consume() diff --git a/Sources/AsciiDocCore/Parser+Sections.swift b/Sources/AsciiDocCore/Parser+Sections.swift index 3a37e31..b1c85eb 100644 --- a/Sources/AsciiDocCore/Parser+Sections.swift +++ b/Sources/AsciiDocCore/Parser+Sections.swift @@ -9,7 +9,8 @@ extension AdocParser { level: Int, titleRange: Range, it: inout TokenIter, - env: AttrEnv + env: inout AttrEnv, + warnings: inout [AdocWarning] ) -> AdocSection? { // Consume the section title line it.consume() @@ -22,14 +23,57 @@ extension AdocParser { var titleText = AdocText(plain: String(titleSlice), span: titleSpan) titleText = titleText.applyingAttributes(using: env) + var scopedAssignments: [(name: String, value: String?, unset: Bool)]? = nil + + if let nextTok = it.peek() { + switch nextTok.kind { + case .blockMeta: + var meta = AdocBlockMeta() + meta.mergeBlockMeta(from: nextTok) + if meta.primaryStyle == "attrs" { + it.consume() + scopedAssignments = parseAttributeAssignmentsFromDelimitedBlock(it: &it, xadOptions: env.xadOptions) + } + case .directive(let kind, let payloadRange): + let name: String = { + switch kind { + case .include: return "include" + case .ifdef: return "ifdef" + case .ifndef: return "ifndef" + case .ifeval: return "ifeval" + case .endif: return "endif" + case .other(let n): return n + } + }() + if name == "attrs" { + it.consume() + let payload = String(nextTok.string[payloadRange]) + let payloadParts = parseDirectivePayload(payload) + let attrs = parseMacroAttributeList(payloadParts.body ?? "") + scopedAssignments = attrs.map { (name: $0.key, value: $0.value, unset: false) } + } + default: + break + } + } + + if let scopedAssignments { + env.pushScope() + applyAttributeAssignments(scopedAssignments, env: &env) + } + // Body: everything until we see a section with level <= this one - let bodyBlocks = parseBlocks(it: &it, env: env) { nextTok in + let bodyBlocks = parseBlocks(it: &it, env: &env, warnings: &warnings) { nextTok in if case .atxSection(let nextLevel, _) = nextTok.kind { return nextLevel <= level // stop at same or higher ATX-level } return false } + if scopedAssignments != nil { + _ = env.popScope() + } + // Span from this section line to last block’s end (or just the title line if empty) let endRange: AdocRange = { if let lastBlock = bodyBlocks.last { diff --git a/Sources/AsciiDocCore/Parser+Sidebar.swift b/Sources/AsciiDocCore/Parser+Sidebar.swift index 10095b1..e270e48 100644 --- a/Sources/AsciiDocCore/Parser+Sidebar.swift +++ b/Sources/AsciiDocCore/Parser+Sidebar.swift @@ -6,7 +6,8 @@ extension AdocParser { func parseSidebar( it: inout TokenIter, - env: AttrEnv + env: inout AttrEnv, + warnings: inout [AdocWarning] ) -> AdocSidebar? { guard let open = it.peek(), case .blockFence(let kind, let fenceLen) = open.kind, @@ -16,7 +17,7 @@ extension AdocParser { it.consume() // consume opening fence // Parse inner blocks until we see a *matching* sidebar fence - let blocks = parseBlocks(it: &it, env: env) { tok in + let blocks = parseBlocks(it: &it, env: &env, warnings: &warnings) { tok in if case .blockFence(let k, let l) = tok.kind, k == .sidebar, l == fenceLen { return true diff --git a/Sources/AsciiDocCore/Parser.swift b/Sources/AsciiDocCore/Parser.swift index b06409a..01de431 100644 --- a/Sources/AsciiDocCore/Parser.swift +++ b/Sources/AsciiDocCore/Parser.swift @@ -232,7 +232,8 @@ public struct AdocParser: Sendable { attributes initialAttributes: [String: String?] = [:], lockedAttributeNames: Set = [], includeHeaderDerivedAttributes: Bool = true, - preprocessorOptions: Preprocessor.Options = .init() + preprocessorOptions: Preprocessor.Options = .init(), + xadOptions: XADOptions = .init() ) -> AdocDocument { let preprocessor = Preprocessor(options: preprocessorOptions) let preprocessed = preprocessor.process( @@ -245,20 +246,35 @@ public struct AdocParser: Sendable { var it = TokenIter(tokens: toks, text: preprocessed.source.text) var header: AdocHeader? = nil var docAttrs: [String: String?] = preprocessed.attributes - var env = AttrEnv(initial: docAttrs) + var typedAttrs: [String: XADAttributeValue] = [:] + if xadOptions.enabled { + for (key, value) in docAttrs { + if let value, let typed = XADAttributeValue.parse(from: value, xadOptions: xadOptions) { + typedAttrs[key] = typed + } + } + } + var env = AttrEnv(initial: docAttrs, typedAttributes: typedAttrs, xadOptions: xadOptions) + var warnings: [AdocWarning] = [] // Header detection (keep your existing logic) detectHeader( into: &header, attrs: &docAttrs, + typedAttrs: &typedAttrs, it: &it, env: &env, lockedAttributes: lockedAttributeNames, - includeDerivedAttributes: includeHeaderDerivedAttributes + includeDerivedAttributes: includeHeaderDerivedAttributes, + xadOptions: xadOptions ) + docAttrs = env.values + typedAttrs = env.typedValues // Body blocks via parseBlocks - let bodyBlocks = parseBlocks(it: &it, env: env) { _ in false } + let bodyBlocks = parseBlocks(it: &it, env: &env, warnings: &warnings) { _ in false } + docAttrs = env.values + typedAttrs = env.typedValues // Document span can still be computed from first/last token let docSpan: AdocRange? = { @@ -266,7 +282,7 @@ public struct AdocParser: Sendable { return AdocRange(start: first.range.start, end: last.range.end) }() - return AdocDocument(attributes: docAttrs, header: header, blocks: bodyBlocks, span: docSpan) + return AdocDocument(attributes: docAttrs, typedAttributes: typedAttrs, header: header, blocks: bodyBlocks, warnings: warnings, span: docSpan, xadOptions: xadOptions) } diff --git a/Sources/AsciiDocCore/XADOptions.swift b/Sources/AsciiDocCore/XADOptions.swift new file mode 100644 index 0000000..7fb110a --- /dev/null +++ b/Sources/AsciiDocCore/XADOptions.swift @@ -0,0 +1,32 @@ +// +// Copyright (c) 2026 Mattias Holm +// SPDX-License-Identifier: Apache-2.0 +// + +public struct XADOptions: Sendable, Equatable { + public var enabled: Bool + public var strict: Bool + public var pagedJS: Bool + public var templatePath: String? + public var layoutTemplate: String? + public var layoutTemplateBase: String? + public var layoutTemplateSearchPaths: [String] + + public init( + enabled: Bool = false, + strict: Bool = false, + pagedJS: Bool = false, + templatePath: String? = nil, + layoutTemplate: String? = nil, + layoutTemplateBase: String? = nil, + layoutTemplateSearchPaths: [String] = [] + ) { + self.enabled = enabled + self.strict = strict + self.pagedJS = pagedJS + self.templatePath = templatePath + self.layoutTemplate = layoutTemplate + self.layoutTemplateBase = layoutTemplateBase + self.layoutTemplateSearchPaths = layoutTemplateSearchPaths + } +} diff --git a/Sources/AsciiDocCore/XADProcessor.swift b/Sources/AsciiDocCore/XADProcessor.swift new file mode 100644 index 0000000..7791ad0 --- /dev/null +++ b/Sources/AsciiDocCore/XADProcessor.swift @@ -0,0 +1,2203 @@ +// +// Copyright (c) 2026 Mattias Holm +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import YYJSON + +private enum MacroKind { + case block + case inline +} + +private let knownMacroEffects: Set = ["docpush", "docset", "docput"] + +private struct MacroEffects { + let allowsAll: Bool + let allowed: Set + + func allows(_ effect: String) -> Bool { + allowsAll || allowed.contains(effect) + } +} + +private struct MacroParam { + let name: String + let defaultValue: String? +} + +private struct MacroDefinition { + let name: String + let kind: MacroKind + let params: [MacroParam] + let effects: MacroEffects + let usedEffects: Set + let body: [AdocBlock] + let span: AdocRange? +} + +public struct XADProcessor: Sendable { + public struct Options: Sendable { + public var sourceURL: URL? + public var includeResolvers: [IncludeResolver] + public var safeMode: Preprocessor.SafeMode + public var allowURIRead: Bool + + public init( + sourceURL: URL? = nil, + includeResolvers: [IncludeResolver] = [], + safeMode: Preprocessor.SafeMode = .unsafe, + allowURIRead: Bool = true + ) { + self.sourceURL = sourceURL?.standardizedFileURL + self.includeResolvers = includeResolvers + self.safeMode = safeMode + self.allowURIRead = allowURIRead + } + } + + private let options: Options + + public init(options: Options = .init()) { + self.options = options + } + + public func apply(document: AdocDocument) -> AdocDocument { + var env = AttrEnv( + initial: document.attributes, + typedAttributes: document.typedAttributes, + xadOptions: document.xadOptions + ) + var warnings = document.warnings + var (macroDefs, strippedBlocks) = collectMacroDefinitions( + from: document.blocks, + warnings: &warnings + ) + let processed = processBlocks( + strippedBlocks, + env: &env, + warnings: &warnings, + locals: [:], + macros: ¯oDefs, + macroStack: [], + includeStack: [], + sourceURL: options.sourceURL + ) + var updated = document + updated.blocks = processed + updated.warnings = warnings + updated.attributes = env.values + updated.typedAttributes = env.typedValues + return updated + } +} + +private extension XADProcessor { + func processBlocks( + _ blocks: [AdocBlock], + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> [AdocBlock] { + var result: [AdocBlock] = [] + var index = 0 + + while index < blocks.count { + let block = blocks[index] + if case .blockMacro(let macro) = block { + let name = macro.name + if let handled = handleDocMutation( + macro: macro, + env: &env, + warnings: &warnings, + locals: locals + ), handled { + index += 1 + continue + } + let lowered = name.lowercased() + if lowered == "xinclude" { + if let expanded = expandXIncludeBlock( + macro, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) { + result.append(contentsOf: expanded) + } else { + result.append(block) + } + index += 1 + continue + } + if let definition = macros[name] { + switch definition.kind { + case .block: + let (bodyBlocks, nextIndex) = consumeMacroBody( + from: index + 1, + blocks: blocks + ) + let expanded = expandBlockMacro( + macro, + definition: definition, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL, + bodyBlocks: bodyBlocks + ) + result.append(contentsOf: expanded) + index = nextIndex + case .inline: + warnings.append( + AdocWarning( + message: "inline macro used in block context: \(name)", + span: macro.span + ) + ) + result.append(block) + index += 1 + } + continue + } + + if lowered == "if" { + let (endIndex, output) = expandIf( + from: index, + blocks: blocks, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) + result.append(contentsOf: output) + index = endIndex + 1 + continue + } + if lowered == "for" { + let (endIndex, output) = expandFor( + from: index, + blocks: blocks, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) + result.append(contentsOf: output) + index = endIndex + 1 + continue + } + if lowered == "elif" { + warnings.append( + AdocWarning( + message: "elif without open if block", + span: macro.span + ) + ) + index += 1 + continue + } + if lowered == "else" { + warnings.append( + AdocWarning( + message: "else without open if block", + span: macro.span + ) + ) + index += 1 + continue + } + if lowered == "end" { + warnings.append( + AdocWarning( + message: "end without open control block", + span: macro.span + ) + ) + index += 1 + continue + } + } + + let transformed = transformBlock( + block, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) + result.append(transformed) + index += 1 + } + + return result + } + + func collectMacroDefinitions( + from blocks: [AdocBlock], + warnings: inout [AdocWarning] + ) -> ([String: MacroDefinition], [AdocBlock]) { + var definitions: [String: MacroDefinition] = [:] + var output: [AdocBlock] = [] + var index = 0 + + while index < blocks.count { + let block = blocks[index] + switch block { + case .section(var section): + let (childDefs, childBlocks) = collectMacroDefinitions(from: section.blocks, warnings: &warnings) + section.blocks = childBlocks + for (name, def) in childDefs { definitions[name] = def } + output.append(.section(section)) + index += 1 + + case .blockMacro(let macro) where macro.name.lowercased() == "macro": + guard let name = macro.target?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { + warnings.append( + AdocWarning( + message: "macro definition missing name", + span: macro.span + ) + ) + index += 1 + continue + } + let (endIndex, body) = collectMacroBody( + startIndex: index, + macroName: name, + blocks: blocks, + warnings: &warnings + ) + if endIndex == index { + output.append(block) + index += 1 + continue + } + + let kind = parseMacroKind(macro.attributes["kind"]) + let params = parseMacroParams(macro.attributes["params"]) + let effects = parseMacroEffects(macro.attributes["effects"], warnings: &warnings, span: macro.span) + let (nestedDefs, strippedBody) = collectMacroDefinitions(from: body, warnings: &warnings) + for (nestedName, def) in nestedDefs { definitions[nestedName] = def } + let usedEffects = collectMacroEffects(from: strippedBody) + + let definition = MacroDefinition( + name: name, + kind: kind, + params: params, + effects: effects, + usedEffects: usedEffects, + body: strippedBody, + span: macro.span + ) + if definitions[name] != nil { + warnings.append( + AdocWarning( + message: "macro redefinition: \(name)", + span: macro.span + ) + ) + } + definitions[name] = definition + index = endIndex + 1 + + case .blockMacro(let macro) where macro.name.lowercased() == "endmacro": + warnings.append( + AdocWarning( + message: "endmacro without open macro", + span: macro.span + ) + ) + index += 1 + + default: + output.append(block) + index += 1 + } + } + + return (definitions, output) + } + + func collectMacroBody( + startIndex: Int, + macroName: String, + blocks: [AdocBlock], + warnings: inout [AdocWarning] + ) -> (Int, [AdocBlock]) { + var body: [AdocBlock] = [] + var index = startIndex + 1 + var depth = 0 + + while index < blocks.count { + let block = blocks[index] + if case .blockMacro(let macro) = block { + let name = macro.name.lowercased() + if name == "macro" { + depth += 1 + body.append(block) + index += 1 + continue + } + if name == "endmacro" { + if depth == 0 { + if let target = macro.target?.trimmingCharacters(in: .whitespacesAndNewlines), + !target.isEmpty, + target != macroName { + warnings.append( + AdocWarning( + message: "endmacro name does not match \(macroName)", + span: macro.span + ) + ) + } + return (index, body) + } + depth -= 1 + body.append(block) + index += 1 + continue + } + } + body.append(block) + index += 1 + } + + warnings.append( + AdocWarning( + message: "missing endmacro for \(macroName)", + span: blocks[startIndex].span + ) + ) + return (startIndex, []) + } + + func parseMacroKind(_ raw: String?) -> MacroKind { + switch raw?.lowercased() { + case "inline": + return .inline + default: + return .block + } + } + + func parseMacroParams(_ raw: String?) -> [MacroParam] { + guard let raw, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + let parts = raw.split(separator: ",") + var params: [MacroParam] = [] + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if let eq = trimmed.firstIndex(of: "=") { + let name = trimmed[.. MacroEffects { + guard let raw, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return MacroEffects(allowsAll: false, allowed: []) + } + let tokens = raw.split { $0 == "," || $0.isWhitespace } + var allowsAll = false + var allowed: Set = [] + for token in tokens { + let effect = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !effect.isEmpty else { continue } + if effect == "all" { + allowsAll = true + continue + } + if knownMacroEffects.contains(effect) { + allowed.insert(effect) + } else { + warnings.append( + AdocWarning( + message: "unknown macro effect: \(effect)", + span: span + ) + ) + } + } + return MacroEffects(allowsAll: allowsAll, allowed: allowed) + } + + func collectMacroEffects(from blocks: [AdocBlock]) -> Set { + var effects: Set = [] + for block in blocks { + switch block { + case .blockMacro(let macro): + let name = macro.name.lowercased() + if knownMacroEffects.contains(name) { + effects.insert(name) + } + case .section(let section): + effects.formUnion(collectMacroEffects(from: section.blocks)) + case .list(let list): + for item in list.items { + effects.formUnion(collectMacroEffects(from: item.blocks)) + } + case .dlist(let dlist): + for item in dlist.items { + effects.formUnion(collectMacroEffects(from: item.blocks)) + } + case .sidebar(let sidebar): + effects.formUnion(collectMacroEffects(from: sidebar.blocks)) + case .example(let example): + effects.formUnion(collectMacroEffects(from: example.blocks)) + case .quote(let quote): + effects.formUnion(collectMacroEffects(from: quote.blocks)) + case .open(let open): + effects.formUnion(collectMacroEffects(from: open.blocks)) + case .admonition(let admonition): + effects.formUnion(collectMacroEffects(from: admonition.blocks)) + case .verse(let verse): + effects.formUnion(collectMacroEffects(from: verse.blocks)) + default: + continue + } + } + return effects + } + + func bindMacroParams( + definition: MacroDefinition, + args: [String: String], + warnings: inout [AdocWarning], + span: AdocRange? + ) -> [String: XADAttributeValue] { + var locals: [String: XADAttributeValue] = [:] + let paramNames = Set(definition.params.map { $0.name }) + + for param in definition.params { + if let value = args[param.name] { + locals[param.name] = .string(value) + } else if let defaultValue = param.defaultValue { + locals[param.name] = .string(defaultValue) + } else { + warnings.append( + AdocWarning( + message: "missing macro parameter: \(param.name)", + span: span + ) + ) + } + } + + for (key, _) in args where !paramNames.contains(key) { + warnings.append( + AdocWarning( + message: "unknown macro parameter: \(key)", + span: span + ) + ) + } + + return locals + } + + func mergeMacroLocals( + base: [String: XADAttributeValue], + params: [String: XADAttributeValue], + warnings: inout [AdocWarning], + span: AdocRange? + ) -> [String: XADAttributeValue] { + var merged = base + for (key, value) in params { + if merged[key] != nil { + warnings.append( + AdocWarning( + message: "macro parameter shadows local variable: \(key)", + span: span + ) + ) + } + merged[key] = value + } + return merged + } + + func consumeMacroBody(from startIndex: Int, blocks: [AdocBlock]) -> ([AdocBlock], Int) { + guard startIndex < blocks.count else { + return ([], startIndex) + } + + let block = blocks[startIndex] + switch block { + case .section: + return ([], startIndex) + case .blockMacro(let macro): + let lowered = macro.name.lowercased() + switch lowered { + case "if", "for", "elif", "else", "end", "macro", "endmacro": + return ([], startIndex) + default: + return ([block], startIndex + 1) + } + default: + return ([block], startIndex + 1) + } + } + + func expandBlockMacro( + _ macro: AdocBlockMacro, + definition: MacroDefinition, + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL?, + bodyBlocks: [AdocBlock] + ) -> [AdocBlock] { + if macroStack.contains(definition.name) { + warnings.append( + AdocWarning( + message: "macro recursion detected: \(definition.name)", + span: macro.span + ) + ) + return [.blockMacro(macro)] + } + + if !definition.effects.allowsAll { + let missingEffects = definition.usedEffects.subtracting(definition.effects.allowed) + if !missingEffects.isEmpty { + let ordered = missingEffects.sorted().joined(separator: ", ") + warnings.append( + AdocWarning( + message: "macro uses effects not declared: \(ordered)", + span: macro.span + ) + ) + } + } + + let paramLocals = bindMacroParams( + definition: definition, + args: macro.attributes, + warnings: &warnings, + span: macro.span + ) + let mergedLocals = mergeMacroLocals( + base: locals, + params: paramLocals, + warnings: &warnings, + span: macro.span + ) + var macroLocals = mergedLocals + if !bodyBlocks.isEmpty { + let bodyText = bodyBlocks.map { $0.renderAsAsciiDoc() }.joined() + if macroLocals["body"] != nil { + warnings.append( + AdocWarning( + message: "macro body shadows local variable: body", + span: macro.span + ) + ) + } + macroLocals["body"] = .string(bodyText) + } + return processBlocks( + definition.body, + env: &env, + warnings: &warnings, + locals: macroLocals, + macros: ¯os, + macroStack: macroStack + [definition.name], + includeStack: includeStack, + sourceURL: sourceURL + ) + } + + func handleDocMutation( + macro: AdocBlockMacro, + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue] + ) -> Bool? { + let lowered = macro.name.lowercased() + guard lowered == "docset" || lowered == "docpush" || lowered == "docput" else { + return nil + } + guard let rawName = macro.attributes["name"], !rawName.isEmpty else { + warnings.append( + AdocWarning( + message: "missing doc variable name", + span: macro.span + ) + ) + return true + } + let name = expandString(rawName, env: env, locals: locals) + if !name.hasPrefix("doc.") { + warnings.append( + AdocWarning( + message: "doc variable name should start with doc.: \(name)", + span: macro.span + ) + ) + } + + func resolveValue() -> XADAttributeValue? { + guard let rawValue = macro.attributes["value"] else { return nil } + return parseDocValue(rawValue, env: env, locals: locals) + } + + switch lowered { + case "docset": + guard let value = resolveValue() else { + warnings.append( + AdocWarning( + message: "docset requires value", + span: macro.span + ) + ) + return true + } + env.applyAttributeSet(name: name, value: attributeString(from: value)) + return true + + case "docpush": + guard let value = resolveValue() else { + warnings.append( + AdocWarning( + message: "docpush requires value", + span: macro.span + ) + ) + return true + } + var array: [XADAttributeValue] = [] + if env.xadOptions.enabled, let current = env.resolveTypedValue(name) { + if case .array(let existing) = current { + array = existing + } else { + warnings.append( + AdocWarning( + message: "docpush target is not an array: \(name)", + span: macro.span + ) + ) + } + } + array.append(value) + env.applyAttributeSet(name: name, value: attributeString(from: .array(array))) + return true + + case "docput": + guard let keyRaw = macro.attributes["key"], !keyRaw.isEmpty else { + warnings.append( + AdocWarning( + message: "docput requires key", + span: macro.span + ) + ) + return true + } + guard let value = resolveValue() else { + warnings.append( + AdocWarning( + message: "docput requires value", + span: macro.span + ) + ) + return true + } + let key = stripSurroundingQuotes(expandString(keyRaw, env: env, locals: locals)) + var dict: [String: XADAttributeValue] = [:] + if env.xadOptions.enabled, let current = env.resolveTypedValue(name) { + if case .dictionary(let existing) = current { + dict = existing + } else { + warnings.append( + AdocWarning( + message: "docput target is not a dictionary: \(name)", + span: macro.span + ) + ) + } + } + dict[key] = value + env.applyAttributeSet(name: name, value: attributeString(from: .dictionary(dict))) + return true + default: + return nil + } + } + + func includeBaseDirectory(for sourceURL: URL?) -> URL? { + guard let sourceURL else { return nil } + if sourceURL.hasDirectoryPath { return sourceURL } + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), isDirectory.boolValue { + return sourceURL + } + return sourceURL.deletingLastPathComponent() + } + + func includeResolvers(for sourceURL: URL?) -> [IncludeResolver] { + if options.includeResolvers.isEmpty { + let resolver = FileSystemIncludeResolver( + rootDirectory: includeBaseDirectory(for: sourceURL), + allowURIRead: options.allowURIRead, + safeMode: options.safeMode + ) + return [resolver] + } + return options.includeResolvers + } + + func resolveIncludeTarget( + _ rawTarget: String?, + env: AttrEnv, + locals: [String: XADAttributeValue], + warnings: inout [AdocWarning], + span: AdocRange? + ) -> String? { + guard let rawTarget, !rawTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + warnings.append( + AdocWarning( + message: "xinclude missing target", + span: span + ) + ) + return nil + } + let expanded = expandString(rawTarget, env: env, locals: locals) + let normalized = stripSurroundingQuotes(expanded.trimmingCharacters(in: .whitespacesAndNewlines)) + if normalized.isEmpty { + warnings.append( + AdocWarning( + message: "xinclude missing target", + span: span + ) + ) + return nil + } + return normalized + } + + func resolveIncludeResult(target: String, sourceURL: URL?) -> IncludeResult? { + let sourceDirectory = includeBaseDirectory(for: sourceURL) + for resolver in includeResolvers(for: sourceURL) { + if let result = resolver.resolve(target: target, from: sourceDirectory) { + return result + } + } + guard options.safeMode != .secure else { return nil } + if let url = URL(string: target), url.scheme != nil, url.scheme != "file" { + return nil + } + let baseDirectory = sourceDirectory + ?? includeBaseDirectory(for: options.sourceURL) + ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let resolved = URL(fileURLWithPath: target, relativeTo: baseDirectory).standardizedFileURL + if options.safeMode == .safe { + let allowed = resolved.path.hasPrefix(baseDirectory.standardizedFileURL.path) + if !allowed { return nil } + } + guard let text = try? String(contentsOf: resolved, encoding: .utf8) else { return nil } + return IncludeResult( + content: text, + directory: resolved.deletingLastPathComponent(), + filePath: resolved.path + ) + } + + func resolveIncludeSourceURL(from result: IncludeResult) -> URL? { + if let filePath = result.filePath { + if let url = URL(string: filePath), url.scheme != nil { + return url + } + return URL(fileURLWithPath: filePath).standardizedFileURL + } + return result.directory?.standardizedFileURL + } + + func parseXIncludeParams( + _ raw: String?, + env: AttrEnv, + locals: [String: XADAttributeValue], + warnings: inout [AdocWarning], + span: AdocRange? + ) -> [String: XADAttributeValue] { + guard let raw, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [:] + } + let parsed = parseDocValue(raw, env: env, locals: locals) + guard case .dictionary(let dict) = parsed else { + warnings.append( + AdocWarning( + message: "xinclude params must be a dictionary", + span: span + ) + ) + return [:] + } + return dict + } + + func expandXIncludeBlocks( + target: String, + attributes: [String: String], + span: AdocRange?, + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> [AdocBlock]? { + guard let result = resolveIncludeResult(target: target, sourceURL: sourceURL) else { + warnings.append( + AdocWarning( + message: "xinclude could not resolve target: \(target)", + span: span + ) + ) + return nil + } + let includeID = result.filePath ?? target + if includeStack.contains(includeID) { + warnings.append( + AdocWarning( + message: "xinclude recursion detected: \(includeID)", + span: span + ) + ) + return nil + } + + let includeURL = resolveIncludeSourceURL(from: result) + let parser = AdocParser() + let preprocessorOptions = Preprocessor.Options( + sourceURL: includeURL, + safeMode: options.safeMode, + allowURIRead: options.allowURIRead, + includeResolvers: includeResolvers(for: includeURL ?? sourceURL) + ) + let includedDoc = parser.parse( + text: result.content, + attributes: env.values, + lockedAttributeNames: [], + includeHeaderDerivedAttributes: true, + preprocessorOptions: preprocessorOptions, + xadOptions: env.xadOptions + ) + warnings.append(contentsOf: includedDoc.warnings) + + let (includedDefs, strippedBlocks) = collectMacroDefinitions(from: includedDoc.blocks, warnings: &warnings) + for (name, def) in includedDefs { + if macros[name] != nil { + warnings.append( + AdocWarning( + message: "macro redefinition: \(name)", + span: def.span + ) + ) + } + macros[name] = def + } + + let effects = parseMacroEffects(attributes["effects"], warnings: &warnings, span: span) + if !effects.allowsAll { + let usedEffects = collectMacroEffects(from: strippedBlocks) + let missingEffects = usedEffects.subtracting(effects.allowed) + if !missingEffects.isEmpty { + let ordered = missingEffects.sorted().joined(separator: ", ") + warnings.append( + AdocWarning( + message: "xinclude uses effects not declared: \(ordered)", + span: span + ) + ) + } + } + + let paramLocals = parseXIncludeParams(attributes["params"], env: env, locals: locals, warnings: &warnings, span: span) + let mergedLocals = mergeMacroLocals(base: locals, params: paramLocals, warnings: &warnings, span: span) + + return processBlocks( + strippedBlocks, + env: &env, + warnings: &warnings, + locals: mergedLocals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack + [includeID], + sourceURL: includeURL ?? sourceURL + ) + } + + func expandXIncludeBlock( + _ macro: AdocBlockMacro, + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> [AdocBlock]? { + let target = resolveIncludeTarget( + macro.target ?? macro.attributes["target"], + env: env, + locals: locals, + warnings: &warnings, + span: macro.span + ) + guard let target else { return nil } + return expandXIncludeBlocks( + target: target, + attributes: macro.attributes, + span: macro.span, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) + } + + func trimInlineWhitespace(_ inlines: [AdocInline]) -> [AdocInline] { + var trimmed = inlines + let ws = CharacterSet.whitespacesAndNewlines + + func ltrim(_ value: String) -> String { + var scalars = value.unicodeScalars + while let first = scalars.first, ws.contains(first) { + scalars.removeFirst() + } + return String(scalars) + } + + func rtrim(_ value: String) -> String { + var scalars = value.unicodeScalars + while let last = scalars.last, ws.contains(last) { + scalars.removeLast() + } + return String(scalars) + } + + while let first = trimmed.first { + guard case .text(let text, let span) = first else { break } + let cleaned = ltrim(text) + if cleaned.isEmpty { + trimmed.removeFirst() + continue + } + if cleaned != text { + trimmed[0] = .text(cleaned, span: span) + } + break + } + + while let last = trimmed.last { + guard case .text(let text, let span) = last else { break } + let cleaned = rtrim(text) + if cleaned.isEmpty { + trimmed.removeLast() + continue + } + if cleaned != text { + trimmed[trimmed.count - 1] = .text(cleaned, span: span) + } + break + } + + return trimmed + } + + func expandXIncludeInline( + target: String?, + attributes: [String: String], + span: AdocRange?, + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> [AdocInline]? { + let resolved = resolveIncludeTarget( + target, + env: env, + locals: locals, + warnings: &warnings, + span: span + ) + guard let resolved else { return nil } + guard let blocks = expandXIncludeBlocks( + target: resolved, + attributes: attributes, + span: span, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) else { + return nil + } + guard blocks.count == 1, case .paragraph(let para) = blocks[0] else { + warnings.append( + AdocWarning( + message: "inline xinclude must resolve to a single paragraph: \(resolved)", + span: span + ) + ) + return nil + } + return trimInlineWhitespace(para.text.inlines) + } + + func expandInlineMacros( + _ inlines: [AdocInline], + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> [AdocInline] { + var result: [AdocInline] = [] + result.reserveCapacity(inlines.count) + + for inline in inlines { + switch inline { + case .strong(let xs, let span): + let mapped = expandInlineMacros(xs, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.strong(mapped, span: span)) + case .emphasis(let xs, let span): + let mapped = expandInlineMacros(xs, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.emphasis(mapped, span: span)) + case .mono(let xs, let span): + let mapped = expandInlineMacros(xs, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.mono(mapped, span: span)) + case .mark(let xs, let span): + let mapped = expandInlineMacros(xs, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.mark(mapped, span: span)) + case .superscript(let xs, let span): + let mapped = expandInlineMacros(xs, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.superscript(mapped, span: span)) + case .subscript(let xs, let span): + let mapped = expandInlineMacros(xs, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.subscript(mapped, span: span)) + case .link(let target, let text, let span): + let mapped = expandInlineMacros(text, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.link(target: target, text: mapped, span: span)) + case .xref(let target, let text, let span): + let mapped = expandInlineMacros(text, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.xref(target: target, text: mapped, span: span)) + case .footnote(let content, let ref, let id, let span): + let mapped = expandInlineMacros(content, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + result.append(.footnote(content: mapped, ref: ref, id: id, span: span)) + case .inlineMacro(let name, let target, let body, let span): + let lowered = name.lowercased() + if lowered == "xinclude" { + let args = parseMacroAttributeList(body) + if let expanded = expandXIncludeInline( + target: target, + attributes: args, + span: span, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) { + result.append(contentsOf: expanded) + } else { + result.append(inline) + } + continue + } + if let definition = macros[name] { + if definition.kind == .inline { + if macroStack.contains(definition.name) { + warnings.append( + AdocWarning( + message: "macro recursion detected: \(definition.name)", + span: span + ) + ) + result.append(inline) + continue + } + let args = parseMacroAttributeList(body) + let paramLocals = bindMacroParams( + definition: definition, + args: args, + warnings: &warnings, + span: span + ) + var macroEnv = env + for (key, value) in paramLocals { + if case .string(let str) = value { + macroEnv.applyAttributeSet(name: key, value: str) + } + } + let expandedBlocks = definition.body.map { $0.applyingAttributes(using: macroEnv) } + let expandedInlines: [AdocInline] + if expandedBlocks.count == 1, case .paragraph(let para) = expandedBlocks[0] { + expandedInlines = para.text.inlines + } else { + warnings.append( + AdocWarning( + message: "inline macro body must be a single paragraph: \(definition.name)", + span: span + ) + ) + let text = expandedBlocks.map { $0.renderAsAsciiDoc() }.joined() + expandedInlines = parseInlines(text, baseSpan: span) + } + let mapped = expandInlineMacros( + expandedInlines, + env: ¯oEnv, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack + [definition.name], + includeStack: includeStack, + sourceURL: sourceURL + ) + result.append(contentsOf: mapped) + } else { + warnings.append( + AdocWarning( + message: "block macro used in inline context: \(name)", + span: span + ) + ) + result.append(inline) + } + } else { + result.append(inline) + } + default: + result.append(inline) + } + } + + return result + } + + func transformBlock( + _ block: AdocBlock, + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> AdocBlock { + switch block { + case .section(var section): + section.title = expandText(section.title, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + section.blocks = processBlocks(section.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .section(section) + + case .paragraph(var para): + para.text = expandText(para.text, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .paragraph(para) + + case .listing(var listing): + listing.text = expandText(listing.text, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .listing(listing) + + case .literalBlock(var literal): + literal.text = expandText(literal.text, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .literalBlock(literal) + + case .discreteHeading(var heading): + heading.title = expandText(heading.title, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .discreteHeading(heading) + + case .list(var list): + list.items = list.items.map { item in + var copy = item + copy.principal = expandText(item.principal, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + copy.blocks = processBlocks(item.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return copy + } + return .list(list) + + case .dlist(var dlist): + dlist.items = dlist.items.map { item in + var copy = item + copy.term = expandText(item.term, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + if let principal = item.principal { + copy.principal = expandText(principal, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + } + copy.blocks = processBlocks(item.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return copy + } + return .dlist(dlist) + + case .sidebar(var sidebar): + sidebar.title = sidebar.title.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + sidebar.blocks = processBlocks(sidebar.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .sidebar(sidebar) + + case .example(var example): + example.title = example.title.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + example.blocks = processBlocks(example.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .example(example) + + case .quote(var quote): + quote.title = quote.title.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + quote.attribution = quote.attribution.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + quote.citetitle = quote.citetitle.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + quote.blocks = processBlocks(quote.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .quote(quote) + + case .open(var open): + open.title = open.title.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + open.blocks = processBlocks(open.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .open(open) + + case .admonition(var admonition): + admonition.title = admonition.title.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + admonition.blocks = processBlocks(admonition.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .admonition(admonition) + + case .verse(var verse): + verse.title = verse.title.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + verse.attribution = verse.attribution.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + verse.citetitle = verse.citetitle.map { expandText($0, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) } + if let text = verse.text { + verse.text = expandText(text, env: &env, locals: locals, macros: ¯os, warnings: &warnings, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + } + verse.blocks = processBlocks(verse.blocks, env: &env, warnings: &warnings, locals: locals, macros: ¯os, macroStack: macroStack, includeStack: includeStack, sourceURL: sourceURL) + return .verse(verse) + + default: + return block + } + } + + func expandIf( + from startIndex: Int, + blocks: [AdocBlock], + env: inout AttrEnv, + warnings: inout [AdocWarning], + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> (Int, [AdocBlock]) { + guard case .blockMacro(let ifMacro) = blocks[startIndex] else { + return (startIndex, []) + } + let initialCond = conditionExpression(from: ifMacro) + var branches: [(cond: String?, blocks: [AdocBlock])] = [ + (cond: initialCond, blocks: []) + ] + var currentIndex = startIndex + 1 + var depth = 0 + var sawElse = false + + while currentIndex < blocks.count { + let block = blocks[currentIndex] + if case .blockMacro(let macro) = block { + let name = macro.name.lowercased() + if name == "if" || name == "for" { + depth += 1 + branches[branches.count - 1].blocks.append(block) + currentIndex += 1 + continue + } + if name == "end" { + if depth == 0 { + if let target = macro.target?.lowercased(), !target.isEmpty, target != "if" { + warnings.append( + AdocWarning( + message: "end::\(target) does not match current if block", + span: macro.span + ) + ) + } + break + } + depth -= 1 + branches[branches.count - 1].blocks.append(block) + currentIndex += 1 + continue + } + if depth == 0 && name == "elif" { + if sawElse { + warnings.append( + AdocWarning( + message: "elif after else in if block", + span: macro.span + ) + ) + currentIndex += 1 + continue + } + branches.append((cond: conditionExpression(from: macro), blocks: [])) + currentIndex += 1 + continue + } + if depth == 0 && name == "else" { + if sawElse { + warnings.append( + AdocWarning( + message: "multiple else in if block", + span: macro.span + ) + ) + currentIndex += 1 + continue + } + sawElse = true + branches.append((cond: nil, blocks: [])) + currentIndex += 1 + continue + } + } + + branches[branches.count - 1].blocks.append(block) + currentIndex += 1 + } + + if currentIndex >= blocks.count { + warnings.append( + AdocWarning( + message: "Missing end for if directive", + span: ifMacro.span + ) + ) + return (blocks.count - 1, Array(blocks[startIndex.. (Int, [AdocBlock]) { + guard case .blockMacro(let forMacro) = blocks[startIndex] else { + return (startIndex, []) + } + + var body: [AdocBlock] = [] + var currentIndex = startIndex + 1 + var depth = 0 + + while currentIndex < blocks.count { + let block = blocks[currentIndex] + if case .blockMacro(let macro) = block { + let name = macro.name.lowercased() + if name == "if" || name == "for" { + depth += 1 + body.append(block) + currentIndex += 1 + continue + } + if name == "end" { + if depth == 0 { + if let target = macro.target?.lowercased(), !target.isEmpty, target != "for" { + warnings.append( + AdocWarning( + message: "end::\(target) does not match current for block", + span: macro.span + ) + ) + } + break + } + depth -= 1 + body.append(block) + currentIndex += 1 + continue + } + } + body.append(block) + currentIndex += 1 + } + + if currentIndex >= blocks.count { + warnings.append( + AdocWarning( + message: "Missing end for for directive", + span: forMacro.span + ) + ) + return (blocks.count - 1, Array(blocks[startIndex.. String? { + if let cond = macro.attributes["cond"] { return cond } + return targetFallback(from: macro) + } + + func targetFallback(from macro: AdocBlockMacro) -> String? { + let trimmed = macro.target?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} + +private extension XADProcessor { + enum ExprToken: Equatable { + case lparen + case rparen + case op(String) + case identifier(String) + case string(String) + case number(Double) + case bool(Bool) + case null + case variable(String) + } + + func evaluateCondition( + _ expr: String, + env: AttrEnv, + locals: [String: XADAttributeValue] + ) -> (Bool?, [String], [String]) { + var unresolved: [String] = [] + var warnings: [String] = [] + guard let value = evaluateExpression( + expr, + env: env, + locals: locals, + unresolved: &unresolved, + warnings: &warnings + ) else { + return (nil, unresolved, warnings) + } + return (isTruthy(value), unresolved, warnings) + } + + func evaluateValue( + _ expr: String, + env: AttrEnv, + locals: [String: XADAttributeValue] + ) -> (XADAttributeValue?, [String], [String]) { + let trimmed = expr.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") { + if let parsed = XADAttributeValue.parse(from: trimmed, xadOptions: env.xadOptions) { + return (parsed, [], []) + } + } + var unresolved: [String] = [] + var warnings: [String] = [] + let value = evaluateExpression( + expr, + env: env, + locals: locals, + unresolved: &unresolved, + warnings: &warnings + ) + return (value, unresolved, warnings) + } + + func evaluateExpression( + _ expr: String, + env: AttrEnv, + locals: [String: XADAttributeValue], + unresolved: inout [String], + warnings: inout [String] + ) -> XADAttributeValue? { + guard let tokens = tokenize(expr) else { return nil } + var localUnresolved: [String] = [] + var localWarnings: [String] = [] + var parser = ExprParser(tokens: tokens, warningSink: { message in + if !localWarnings.contains(message) { + localWarnings.append(message) + } + }) { name in + if let resolved = resolveValue(name, env: env, locals: locals) { + return resolved + } + if !localUnresolved.contains(name) { + localUnresolved.append(name) + } + return .null + } + let value = parser.parseExpression() + for name in localUnresolved where !unresolved.contains(name) { + unresolved.append(name) + } + for message in localWarnings where !warnings.contains(message) { + warnings.append(message) + } + return value + } + + func tokenize(_ expr: String) -> [ExprToken]? { + var tokens: [ExprToken] = [] + var index = expr.startIndex + + func peekChar() -> Character? { + guard index < expr.endIndex else { return nil } + return expr[index] + } + + func advance() { + index = expr.index(after: index) + } + + while let ch = peekChar() { + if ch.isWhitespace { + advance() + continue + } + + if ch == "(" { + tokens.append(.lparen) + advance() + continue + } + + if ch == ")" { + tokens.append(.rparen) + advance() + continue + } + + if ch == "{" { + let start = expr.index(after: index) + guard let close = expr[start...].firstIndex(of: "}") else { return nil } + let content = expr[start..="].contains(twoChar) { + tokens.append(.op(twoChar)) + index = expr.index(index, offsetBy: 2) + continue + } + if ch == "<" || ch == ">" { + tokens.append(.op(String(ch))) + advance() + continue + } + + if ch.isLetter || ch == "_" { + var ident = String(ch) + advance() + while let next = peekChar(), next.isLetter || next.isNumber || next == "_" || next == "." || next == "[" || next == "]" { + ident.append(next) + advance() + } + let lowered = ident.lowercased() + if lowered == "and" || lowered == "or" || lowered == "not" { + tokens.append(.op(lowered)) + } else if lowered == "true" { + tokens.append(.bool(true)) + } else if lowered == "false" { + tokens.append(.bool(false)) + } else if lowered == "null" { + tokens.append(.null) + } else { + tokens.append(.identifier(ident)) + } + continue + } + + return nil + } + + return tokens + } + + func resolveValue(_ name: String, env: AttrEnv, locals: [String: XADAttributeValue]) -> XADAttributeValue? { + if let local = locals[name] { + return local + } + if let typed = env.resolveTypedValue(name) { + return typed + } + if let raw = env.resolveAttribute(name) { + return parseScalar(raw) + } + return nil + } + + func parseScalar(_ raw: String) -> XADAttributeValue { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = trimmed.lowercased() + if lowered == "true" { return .bool(true) } + if lowered == "false" { return .bool(false) } + if lowered == "null" { return .null } + if let number = Double(trimmed) { return .number(number) } + return .string(trimmed) + } + + func isTruthy(_ value: XADAttributeValue) -> Bool { + switch value { + case .bool(let b): + return b + case .number(let n): + return n != 0 + case .string(let s): + return !s.isEmpty + case .null: + return false + case .array(let array): + return !array.isEmpty + case .dictionary(let dict): + return !dict.isEmpty + } + } + + func expandText( + _ text: AdocText, + env: inout AttrEnv, + locals: [String: XADAttributeValue], + macros: inout [String: MacroDefinition], + warnings: inout [AdocWarning], + macroStack: [String], + includeStack: [String], + sourceURL: URL? + ) -> AdocText { + let localEnv = localEnvWithLocals(env, locals: locals) + let attributedInlines = text.inlines.map { $0.applyingAttributes(using: localEnv) } + let expandedInlines = expandInlineMacros( + attributedInlines, + env: &env, + warnings: &warnings, + locals: locals, + macros: ¯os, + macroStack: macroStack, + includeStack: includeStack, + sourceURL: sourceURL + ) + return AdocText(inlines: expandedInlines, span: text.span) + } + + func expandString(_ value: String, env: AttrEnv, locals: [String: XADAttributeValue]) -> String { + let localEnv = localEnvWithLocals(env, locals: locals) + return localEnv.expand(value) + } + + func parseDocValue(_ rawValue: String, env: AttrEnv, locals: [String: XADAttributeValue]) -> XADAttributeValue { + let expanded = expandString(rawValue, env: env, locals: locals) + let normalized = stripSurroundingQuotes(expanded) + if let typed = XADAttributeValue.parse(from: normalized, xadOptions: env.xadOptions) { + return typed + } + return .string(normalized) + } + + func attributeString(from value: XADAttributeValue) -> String { + switch value { + case .string(let s): + return s + case .number(let n): + return formatNumber(n) + case .bool(let b): + return b ? "true" : "false" + case .null: + return "null" + case .array, .dictionary: + return jsonString(from: value) + } + } + + func stripSurroundingQuotes(_ value: String) -> String { + guard value.count >= 2 else { return value } + let first = value.first + let last = value.last + if (first == "\"" && last == "\"") || (first == "'" && last == "'") { + return String(value.dropFirst().dropLast()) + } + return value + } + + func localEnvWithLocals(_ env: AttrEnv, locals: [String: XADAttributeValue]) -> AttrEnv { + guard !locals.isEmpty else { return env } + var localEnv = env + for (key, val) in locals { + switch val { + case .string(let s): + localEnv.applyAttributeSet(name: key, value: s) + case .number(let n): + localEnv.applyAttributeSet(name: key, value: formatNumber(n)) + case .bool(let b): + localEnv.applyAttributeSet(name: key, value: b ? "true" : "false") + case .null: + localEnv.applyAttributeSet(name: key, value: "null") + case .array, .dictionary: + localEnv.applyAttributeSet(name: key, value: jsonString(from: val)) + } + } + return localEnv + } + + func formatNumber(_ value: Double) -> String { + if value.isFinite, + value.rounded() == value, + value >= Double(Int.min), + value <= Double(Int.max) { + return String(Int(value)) + } + return String(value) + } + + func jsonString(from value: XADAttributeValue) -> String { + let obj = value.toJSONCompatible() + guard YYJSONSerialization.isValidJSONObject(obj), + let data = try? YYJSONSerialization.data(withJSONObject: obj, options: []) else { + return "" + } + return String(data: data, encoding: .utf8) ?? "" + } +} + +private struct ExprParser { + private var tokens: [XADProcessor.ExprToken] + private var index: Int = 0 + private let warningSink: (String) -> Void + private let resolver: (String) -> XADAttributeValue? + + init( + tokens: [XADProcessor.ExprToken], + warningSink: @escaping (String) -> Void, + resolver: @escaping (String) -> XADAttributeValue? + ) { + self.tokens = tokens + self.warningSink = warningSink + self.resolver = resolver + } + + mutating func parseExpression() -> XADAttributeValue? { + parseOr() + } + + private mutating func parseOr() -> XADAttributeValue? { + guard var lhs = parseAnd() else { return nil } + while matchOp("or") { + guard let rhs = parseAnd() else { return nil } + lhs = .bool(isTruthy(lhs) || isTruthy(rhs)) + } + return lhs + } + + private mutating func parseAnd() -> XADAttributeValue? { + guard var lhs = parseNot() else { return nil } + while matchOp("and") { + guard let rhs = parseNot() else { return nil } + lhs = .bool(isTruthy(lhs) && isTruthy(rhs)) + } + return lhs + } + + private mutating func parseNot() -> XADAttributeValue? { + if matchOp("not") { + guard let value = parseNot() else { return nil } + return .bool(!isTruthy(value)) + } + return parseComparison() + } + + private mutating func parseComparison() -> XADAttributeValue? { + guard let lhs = parsePrimary() else { return nil } + if let op = currentOperator() { + advance() + guard let rhs = parsePrimary() else { return nil } + let result = compare(lhs: lhs, rhs: rhs, op: op) + return .bool(result) + } + return lhs + } + + private mutating func parsePrimary() -> XADAttributeValue? { + guard index < tokens.count else { return nil } + let token = tokens[index] + index += 1 + switch token { + case .lparen: + let inner = parseExpression() + _ = consume(.rparen) + return inner + case .string(let s): + return .string(s) + case .number(let n): + return .number(n) + case .bool(let b): + return .bool(b) + case .null: + return .null + case .variable(let name): + return resolver(name) + case .identifier(let name): + return resolver(name) + case .op: + return nil + case .rparen: + return nil + } + } + + private mutating func matchOp(_ op: String) -> Bool { + guard let token = peek() else { return false } + if case .op(let value) = token, value == op { + index += 1 + return true + } + return false + } + + private func currentOperator() -> String? { + guard let token = peek() else { return nil } + if case .op(let value) = token, ["==", "!=", "<", "<=", ">", ">="].contains(value) { + return value + } + return nil + } + + private mutating func consume(_ token: XADProcessor.ExprToken) -> Bool { + guard let current = peek(), current == token else { return false } + index += 1 + return true + } + + private func peek() -> XADProcessor.ExprToken? { + guard index < tokens.count else { return nil } + return tokens[index] + } + + private mutating func advance() { + index += 1 + } + + private func compare(lhs: XADAttributeValue, rhs: XADAttributeValue, op: String) -> Bool { + switch (lhs, rhs) { + case (.number(let l), .number(let r)): + return compareNumbers(l, r, op) + case (.string(let l), .string(let r)): + return compareStrings(l, r, op) + case (.bool(let l), .bool(let r)): + return compareBools(l, r, op) + case (.null, .null): + return op == "==" + default: + if lhs.typeName != rhs.typeName { + warningSink("type mismatch in comparison: \(lhs.typeName) vs \(rhs.typeName)") + } + let l = stringify(lhs) + let r = stringify(rhs) + if op == "==" { return l == r } + if op == "!=" { return l != r } + return false + } + } + + private func compareNumbers(_ lhs: Double, _ rhs: Double, _ op: String) -> Bool { + switch op { + case "==": return lhs == rhs + case "!=": return lhs != rhs + case "<": return lhs < rhs + case "<=": return lhs <= rhs + case ">": return lhs > rhs + case ">=": return lhs >= rhs + default: return false + } + } + + private func compareStrings(_ lhs: String, _ rhs: String, _ op: String) -> Bool { + switch op { + case "==": return lhs == rhs + case "!=": return lhs != rhs + case "<": return lhs < rhs + case "<=": return lhs <= rhs + case ">": return lhs > rhs + case ">=": return lhs >= rhs + default: return false + } + } + + private func compareBools(_ lhs: Bool, _ rhs: Bool, _ op: String) -> Bool { + switch op { + case "==": return lhs == rhs + case "!=": return lhs != rhs + default: return false + } + } + + private func stringify(_ value: XADAttributeValue) -> String { + switch value { + case .string(let s): return s + case .number(let n): return String(n) + case .bool(let b): return b ? "true" : "false" + case .null: return "null" + case .array(let array): + return "[\(array.map(stringify).joined(separator: ","))]" + case .dictionary(let dict): + let parts = dict.map { "\($0.key):\(stringify($0.value))" } + return "{\(parts.joined(separator: ","))}" + } + } + + private func isTruthy(_ value: XADAttributeValue) -> Bool { + switch value { + case .bool(let b): return b + case .number(let n): return n != 0 + case .string(let s): return !s.isEmpty + case .null: return false + case .array(let arr): return !arr.isEmpty + case .dictionary(let dict): return !dict.isEmpty + } + } +} +private extension XADAttributeValue { + var typeName: String { + switch self { + case .number: return "number" + case .string: return "string" + case .bool: return "bool" + case .null: return "null" + case .array: return "array" + case .dictionary: return "dictionary" + } + } +} + diff --git a/Sources/AsciiDocExtensions/PlantUMLExtension.swift b/Sources/AsciiDocExtensions/PlantUMLExtension.swift index 171dfae..91cd4ec 100644 --- a/Sources/AsciiDocExtensions/PlantUMLExtension.swift +++ b/Sources/AsciiDocExtensions/PlantUMLExtension.swift @@ -260,6 +260,7 @@ public struct PlantUMLExtension: AsciiDocExtension { let macro = AdocBlockMacro( name: "image", target: relativeTargetPath(for: destinationURL), + attributes: [:], id: meta.id, title: title, reftext: reftext, diff --git a/Sources/AsciiDocExtensions/XADExtension.swift b/Sources/AsciiDocExtensions/XADExtension.swift new file mode 100644 index 0000000..08ebfb9 --- /dev/null +++ b/Sources/AsciiDocExtensions/XADExtension.swift @@ -0,0 +1,23 @@ +// +// Copyright (c) 2026 Mattias Holm +// SPDX-License-Identifier: Apache-2.0 +// + +import AsciiDocCore + +public struct XADExtension: AsciiDocExtension { + public let name: String = "xad" + private let processor: XADProcessor + + public init(processor: XADProcessor = XADProcessor()) { + self.processor = processor + } + + public func willParse(source: String, attributes: [String:String]) -> (String, [String:String]) { + (source, attributes) + } + + public func didParse(document: AdocDocument) -> AdocDocument { + processor.apply(document: document) + } +} diff --git a/Sources/AsciiDocPagedRendering/LayoutEvaluator.swift b/Sources/AsciiDocPagedRendering/LayoutEvaluator.swift new file mode 100644 index 0000000..af3510c --- /dev/null +++ b/Sources/AsciiDocPagedRendering/LayoutEvaluator.swift @@ -0,0 +1,273 @@ +import Foundation +import AsciiDocCore + +public struct XADLayoutEvaluationResult { + public var tree: [String: Any] + public var warnings: [AdocWarning] + + public init(tree: [String: Any], warnings: [AdocWarning]) { + self.tree = tree + self.warnings = warnings + } +} + +public struct XADLayoutEvaluator { + public init() {} + + public func evaluate( + program: LayoutProgram, + slots: [String: [[String: Any]]], + collections: [String: [[String: Any]]] + ) -> XADLayoutEvaluationResult { + var warnings: [AdocWarning] = [] + let nodes = program.expressions.compactMap { expr in + evalExpr(expr, slots: slots, collections: collections, warnings: &warnings) + } + return XADLayoutEvaluationResult( + tree: [ + "kind": "program", + "nodes": nodes + ], + warnings: warnings + ) + } + + private func evalExpr( + _ expr: LayoutExpr, + slots: [String: [[String: Any]]], + collections: [String: [[String: Any]]], + warnings: inout [AdocWarning] + ) -> Any? { + switch expr { + case .node(let node): + return evalNode(node, slots: slots, collections: collections, warnings: &warnings) + case .value(let value): + return evalValue(value) + } + } + + private func evalNode( + _ node: LayoutNode, + slots: [String: [[String: Any]]], + collections: [String: [[String: Any]]], + warnings: inout [AdocWarning] + ) -> [String: Any] { + let args = evalArgs(node.args, slots: slots, collections: collections, warnings: &warnings) + let children = node.children.compactMap { evalExpr($0, slots: slots, collections: collections, warnings: &warnings) } + + switch node.name { + case "slot": + let name = argString(args, key: nil) ?? "main" + return [ + "kind": "source", + "source": "slot", + "name": name, + "blocks": slots[name] ?? [] + ] + + case "collect": + let name = argString(args, key: nil) ?? "" + return [ + "kind": "source", + "source": "collect", + "name": name, + "blocks": collections[name] ?? [] + ] + + case "flow": + var blocks: [[String: Any]] = [] + var sources: [[String: Any]] = [] + for value in args.values { + if let extracted = extractBlocks(from: value) { + blocks.append(contentsOf: extracted) + } + if let source = extractSource(from: value) { + sources.append(source) + } + } + for child in children { + if let extracted = extractBlocks(from: child) { + blocks.append(contentsOf: extracted) + } + if let source = extractSource(from: child) { + sources.append(source) + } + } + var payload: [String: Any] = [ + "kind": "flow", + "args": args, + "blocks": blocks, + "children": children.filter { !isSourceNode($0) } + ] + if let dataSlot = singleSlotName(from: sources) { + payload["dataSlot"] = dataSlot + } + if !sources.isEmpty { + payload["sources"] = sources + } + return payload + + case "place": + return [ + "kind": "place", + "args": args, + "children": children + ] + + case "grid": + return [ + "kind": "grid", + "args": args, + "children": children + ] + + case "stack": + return [ + "kind": "stack", + "args": args, + "children": children + ] + + case "pages": + return [ + "kind": "pages", + "children": children + ] + + case "page": + return [ + "kind": "page", + "args": args, + "children": children + ] + + case "text": + let value = argString(args, key: nil) ?? "" + return [ + "kind": "text", + "text": value + ] + + case "counter": + return [ + "kind": "counter", + "args": args + ] + + case "sink": + return [ + "kind": "sink", + "args": args + ] + + default: + return [ + "kind": node.name, + "args": args, + "children": children + ] + } + } + + private func evalArgs( + _ args: [LayoutArg], + slots: [String: [[String: Any]]], + collections: [String: [[String: Any]]], + warnings: inout [AdocWarning] + ) -> [String: Any] { + var result: [String: Any] = [:] + var positionalIndex = 0 + for arg in args { + let value = evalExpr(arg.value, slots: slots, collections: collections, warnings: &warnings) + if let name = arg.name, !name.isEmpty { + result[name] = value ?? NSNull() + } else { + result["$\(positionalIndex)"] = value ?? NSNull() + positionalIndex += 1 + } + } + return result + } + + private func evalValue(_ value: LayoutValue) -> Any { + switch value { + case .string(let string): + return string + case .number(let number): + return number + case .boolean(let bool): + return bool + case .null: + return NSNull() + case .array(let values): + return values.map { evalValue($0) } + case .dict(let dict): + var out: [String: Any] = [:] + for (key, entry) in dict { + out[key] = evalValue(entry) + } + return out + case .ref(let ref): + var payload: [String: Any] = [ + "type": "ref", + "parts": ref.parts + ] + if let index = ref.index { + payload["index"] = evalIndex(index) + } + return payload + } + } + + private func evalIndex(_ index: LayoutIndex) -> Any { + switch index { + case .number(let number): + return ["type": "number", "value": number] + case .string(let string): + return ["type": "string", "value": string] + case .identifier(let ident): + return ["type": "identifier", "value": ident] + } + } + + private func argString(_ args: [String: Any], key: String?) -> String? { + if let key { + return args[key] as? String + } + return args["$0"] as? String + } + + private func isSourceNode(_ value: Any) -> Bool { + guard let dict = value as? [String: Any] else { return false } + return dict["kind"] as? String == "source" + } + + private func extractBlocks(from value: Any) -> [[String: Any]]? { + if let dict = value as? [String: Any], + dict["kind"] as? String == "source", + let blocks = dict["blocks"] as? [[String: Any]] { + return blocks + } + return nil + } + + private func extractSource(from value: Any) -> [String: Any]? { + guard let dict = value as? [String: Any], + dict["kind"] as? String == "source", + let source = dict["source"] as? String, + let name = dict["name"] as? String else { + return nil + } + return ["source": source, "name": name] + } + + private func singleSlotName(from sources: [[String: Any]]) -> String? { + guard sources.count == 1, + let source = sources.first, + source["source"] as? String == "slot", + let name = source["name"] as? String else { + return nil + } + return name + } +} diff --git a/Sources/AsciiDocPagedRendering/TemplateIngestion.swift b/Sources/AsciiDocPagedRendering/TemplateIngestion.swift new file mode 100644 index 0000000..40102c5 --- /dev/null +++ b/Sources/AsciiDocPagedRendering/TemplateIngestion.swift @@ -0,0 +1,194 @@ +// +// Copyright (c) 2026 Mattias Holm +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import AsciiDocCore + +public struct XADTemplateAssets: Sendable, Equatable { + public var css: [String] + public var js: [String] + + public init(css: [String] = [], js: [String] = []) { + self.css = css + self.js = js + } +} + +public struct XADTemplateDocument: Sendable, Equatable { + public var url: URL + public var attributes: [String: String?] + public var typedAttributes: [String: XADAttributeValue] + public var layoutProgram: LayoutProgram? + public var assets: XADTemplateAssets + + public init( + url: URL, + attributes: [String: String?], + typedAttributes: [String: XADAttributeValue], + layoutProgram: LayoutProgram?, + assets: XADTemplateAssets + ) { + self.url = url + self.attributes = attributes + self.typedAttributes = typedAttributes + self.layoutProgram = layoutProgram + self.assets = assets + } +} + +public struct XADTemplateIngestor: Sendable { + public init() {} + + public func ingestTemplate( + at url: URL, + xadOptions: XADOptions = .init(enabled: true) + ) -> (XADTemplateDocument?, [AdocWarning]) { + var warnings: [AdocWarning] = [] + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + warnings.append(AdocWarning(message: "failed to read template: \(error.localizedDescription)", span: nil)) + return (nil, warnings) + } + guard let text = String(data: data, encoding: .utf8) else { + warnings.append(AdocWarning(message: "template must be UTF-8", span: nil)) + return (nil, warnings) + } + + var options = xadOptions + options.enabled = true + + let parser = AdocParser() + let preprocessorOptions = Preprocessor.Options(sourceURL: url) + let document = parser.parse( + text: text, + preprocessorOptions: preprocessorOptions, + xadOptions: options + ) + warnings.append(contentsOf: document.warnings) + + let assets = XADTemplateAssets( + css: parseAssetList(document.attributes["template.css"] ?? nil), + js: parseAssetList(document.attributes["template.js"] ?? nil) + ) + + let program = extractLayoutProgram(from: document.blocks, warnings: &warnings) + let template = XADTemplateDocument( + url: url, + attributes: document.attributes, + typedAttributes: document.typedAttributes, + layoutProgram: program, + assets: assets + ) + return (template, warnings) + } +} + +private func parseAssetList(_ raw: String?) -> [String] { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return [] + } + if raw.contains(",") { + return raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + return raw + .split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + .filter { !$0.isEmpty } +} + +private func extractLayoutProgram( + from blocks: [AdocBlock], + warnings: inout [AdocWarning] +) -> LayoutProgram? { + var programs: [LayoutProgram] = [] + + func visit(_ blocks: [AdocBlock]) { + for block in blocks { + if let source = layoutSource(from: block) { + let parser = LayoutDSLParser() + let (program, parserWarnings) = parser.parse(text: source.text, span: source.span) + warnings.append(contentsOf: parserWarnings) + if let program { + programs.append(program) + } else { + warnings.append( + AdocWarning(message: "layout block did not produce a layout program", span: source.span) + ) + } + } + + switch block { + case .section(let section): + visit(section.blocks) + case .sidebar(let sidebar): + visit(sidebar.blocks) + case .example(let example): + visit(example.blocks) + case .quote(let quote): + visit(quote.blocks) + case .open(let open): + visit(open.blocks) + case .admonition(let admonition): + visit(admonition.blocks) + case .verse(let verse): + visit(verse.blocks) + case .list(let list): + for item in list.items { + visit(item.blocks) + } + case .dlist(let dlist): + for item in dlist.items { + visit(item.blocks) + } + default: + break + } + } + } + + visit(blocks) + + if programs.isEmpty { + warnings.append(AdocWarning(message: "template did not contain a [layout] block", span: nil)) + return nil + } + if programs.count > 1 { + warnings.append(AdocWarning(message: "template contains multiple [layout] blocks; using the first one", span: nil)) + } + return programs.first +} + +private func layoutSource(from block: AdocBlock) -> (text: String, span: AdocRange?)? { + switch block { + case .listing(let listing): + if isLayoutStyle(listing.meta) { + return (listing.text.plain, listing.span) + } + case .literalBlock(let literal): + if isLayoutStyle(literal.meta) { + return (literal.text.plain, literal.span) + } + default: + break + } + return nil +} + +private func isLayoutStyle(_ meta: AdocBlockMeta) -> Bool { + if let style = meta.attributes["style"]?.trimmingCharacters(in: .whitespacesAndNewlines), + style.lowercased() == "layout" { + return true + } + if let positional = meta.attributes["1"]?.trimmingCharacters(in: .whitespacesAndNewlines), + positional.lowercased() == "layout" { + return true + } + return false +} diff --git a/Sources/AsciiDocPagedRendering/TemplateRegistry.swift b/Sources/AsciiDocPagedRendering/TemplateRegistry.swift new file mode 100644 index 0000000..8236b84 --- /dev/null +++ b/Sources/AsciiDocPagedRendering/TemplateRegistry.swift @@ -0,0 +1,121 @@ +// +// Copyright (c) 2026 Mattias Holm +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import AsciiDocCore + +public struct XADTemplateDescriptor: Sendable, Equatable { + public var name: String + public var baseURL: URL + public var layoutURL: URL + public var program: LayoutProgram + + public init(name: String, baseURL: URL, layoutURL: URL, program: LayoutProgram) { + self.name = name + self.baseURL = baseURL + self.layoutURL = layoutURL + self.program = program + } +} + +public struct XADTemplateRegistry: Sendable { + public var searchPaths: [URL] + + public init(searchPaths: [URL] = []) { + self.searchPaths = searchPaths + } + + public mutating func addSearchPath(_ url: URL) { + let normalized = url.standardizedFileURL + if !searchPaths.contains(where: { $0.standardizedFileURL == normalized }) { + searchPaths.append(normalized) + } + } + + public func listTemplates(fileManager: FileManager = .default) -> [String] { + var names = Set() + for root in searchPaths { + let xadDir = root.appendingPathComponent("xad", isDirectory: true) + guard let entries = try? fileManager.contentsOfDirectory( + at: xadDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for entry in entries { + guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + continue + } + let templateURL = entry.appendingPathComponent("template.adoc") + if fileManager.fileExists(atPath: templateURL.path) { + names.insert(entry.lastPathComponent) + } + } + } + return names.sorted() + } + + public func loadTemplate( + named name: String, + baseURL: URL? = nil, + fileManager: FileManager = .default + ) -> (XADTemplateDescriptor?, [AdocWarning]) { + var warnings: [AdocWarning] = [] + guard let layoutURL = resolveTemplateLayoutURL(named: name, fileManager: fileManager) else { + warnings.append(AdocWarning(message: "template not found: \(name)", span: nil)) + return (nil, warnings) + } + return loadTemplate(at: layoutURL, templateName: name, baseURL: baseURL, fileManager: fileManager) + } + + public func loadTemplate( + at url: URL, + templateName: String? = nil, + baseURL: URL? = nil, + fileManager: FileManager = .default + ) -> (XADTemplateDescriptor?, [AdocWarning]) { + var warnings: [AdocWarning] = [] + let templateURL: URL + var name = templateName ?? url.deletingPathExtension().lastPathComponent + + if url.hasDirectoryPath { + templateURL = url.appendingPathComponent("template.adoc") + name = url.lastPathComponent + } else { + templateURL = url + } + + guard fileManager.fileExists(atPath: templateURL.path) else { + warnings.append(AdocWarning(message: "template.adoc not found at \(templateURL.path)", span: nil)) + return (nil, warnings) + } + + let ingestor = XADTemplateIngestor() + let (templateDoc, ingestWarnings) = ingestor.ingestTemplate(at: templateURL) + warnings.append(contentsOf: ingestWarnings) + + guard let program = templateDoc?.layoutProgram else { + warnings.append(AdocWarning(message: "template did not produce a layout program", span: nil)) + return (nil, warnings) + } + + let base = baseURL ?? templateURL.deletingLastPathComponent() + let template = XADTemplateDescriptor(name: name, baseURL: base, layoutURL: templateURL, program: program) + return (template, warnings) + } + + private func resolveTemplateLayoutURL(named name: String, fileManager: FileManager) -> URL? { + for root in searchPaths { + let candidate = root + .appendingPathComponent("xad", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + .appendingPathComponent("template.adoc") + if fileManager.fileExists(atPath: candidate.path) { + return candidate + } + } + return nil + } +} diff --git a/Sources/AsciiDocRender/DocumentRender.swift b/Sources/AsciiDocRender/DocumentRender.swift index 2bb4e67..199381a 100644 --- a/Sources/AsciiDocRender/DocumentRender.swift +++ b/Sources/AsciiDocRender/DocumentRender.swift @@ -5,6 +5,7 @@ import Foundation import AsciiDocCore +import AsciiDocPagedRendering public struct RenderConfig { public var backend: Backend @@ -12,18 +13,24 @@ public struct RenderConfig { public var xrefResolver: XrefResolver? public var navigationTree: [String: Any]? public var customTemplateName: String? + public var xadOptions: XADOptions + public var xadLayoutProgram: LayoutProgram? public init( backend: Backend, inlineBackend: AdocInlineBackend? = nil, xrefResolver: XrefResolver? = nil, navigationTree: [String: Any]? = nil, - customTemplateName: String? = nil + customTemplateName: String? = nil, + xadOptions: XADOptions = .init(), + xadLayoutProgram: LayoutProgram? = nil ) { self.backend = backend self.xrefResolver = xrefResolver self.navigationTree = navigationTree self.customTemplateName = customTemplateName + self.xadOptions = xadOptions + self.xadLayoutProgram = xadLayoutProgram self.inlineBackend = inlineBackend ?? { switch backend { case .html5: return .html5 @@ -40,6 +47,24 @@ public final class DocumentRenderer { private let inlineRenderer: AdocInlineRenderer private var calloutSerial: Int = 0 + private enum SlotMode { + case move + case copy + } + + private struct SlotItem { + let block: AdocBlock + let order: Double + let index: Int + } + + private struct SlotExtractionResult { + var mainBlocks: [AdocBlock] + var slots: [String: [SlotItem]] + var collections: [String: [SlotItem]] + var warnings: [AdocWarning] + } + public init(engine: TemplateEngine, config: RenderConfig) { self.engine = engine self.config = config @@ -71,7 +96,40 @@ public final class DocumentRenderer { let docToRender = resolution.document let footnotes = resolution.definitions - let blocks = renderBlocks(docToRender.blocks, context: inlineContext) + let slotExtraction: SlotExtractionResult + let blocks: [[String: Any]] + if config.xadOptions.enabled { + slotExtraction = extractSlotsAndCollections(from: docToRender) + blocks = renderBlocks(slotExtraction.mainBlocks, context: inlineContext) + } else { + slotExtraction = SlotExtractionResult( + mainBlocks: docToRender.blocks, + slots: [:], + collections: [:], + warnings: [] + ) + blocks = renderBlocks(docToRender.blocks, context: inlineContext) + } + + var templateDocument: XADTemplateDocument? + var templateWarnings: [AdocWarning] = [] + var layoutProgram = config.xadLayoutProgram + if config.xadOptions.enabled, let templatePath = config.xadOptions.templatePath { + let templateURL = resolveTemplateURL( + templatePath, + relativeTo: sourceURL?.deletingLastPathComponent() + ) + let ingestor = XADTemplateIngestor() + let (template, warnings) = ingestor.ingestTemplate( + at: templateURL, + xadOptions: config.xadOptions + ) + templateDocument = template + templateWarnings.append(contentsOf: warnings) + if let program = template?.layoutProgram { + layoutProgram = program + } + } let indexResolver = IndexResolver() let indexCatalog = indexResolver.resolve(docToRender) @@ -88,12 +146,66 @@ public final class DocumentRenderer { ] } + let typedAttributes = docToRender.typedAttributes.mapValues { $0.toJSONCompatible() } + let pagedEnabled = config.xadOptions.pagedJS || isPagedAttributeEnabled(document: docToRender) + var xadContext: [String: Any] = [ + "enabled": config.xadOptions.enabled, + "strict": config.xadOptions.strict, + "pagedJS": pagedEnabled, + "templatePath": config.xadOptions.templatePath ?? "", + "layoutTemplate": config.xadOptions.layoutTemplate ?? "", + "layoutTemplateBase": config.xadOptions.layoutTemplateBase ?? "", + "layoutTemplateSearchPaths": config.xadOptions.layoutTemplateSearchPaths + ] + if config.xadOptions.enabled { + var slotContexts = renderSlotGroups(slotExtraction.slots, context: inlineContext) + slotContexts["main"] = blocks + let collectionContexts = renderSlotGroups(slotExtraction.collections, context: inlineContext) + xadContext["slots"] = slotContexts + xadContext["collections"] = collectionContexts + + var warnings = slotExtraction.warnings + warnings.append(contentsOf: templateWarnings) + if let program = layoutProgram { + let evaluator = XADLayoutEvaluator() + let evaluation = evaluator.evaluate( + program: program, + slots: slotContexts, + collections: collectionContexts + ) + xadContext["layoutTree"] = evaluation.tree + warnings.append(contentsOf: evaluation.warnings) + } + + if !warnings.isEmpty { + xadContext["warnings"] = warnings.map { $0.message } + } + } + if let program = layoutProgram { + xadContext["layoutProgram"] = layoutProgramContext(program) + } + if let templateDocument { + var templateContext: [String: Any] = [ + "path": templateDocument.url.path, + "attributes": templateDocument.attributes, + "typedAttributes": templateDocument.typedAttributes.mapValues { $0.toJSONCompatible() }, + "css": templateDocument.assets.css, + "js": templateDocument.assets.js + ] + if let pageCSS = pageCSS(from: templateDocument) { + templateContext["pageCSS"] = pageCSS + } + xadContext["template"] = templateContext + } + var context: [String: Any] = [ "attributes": docToRender.attributes, + "typedAttributes": typedAttributes, "headerTitle": docToRender.header?.title?.plain ?? "", "blocks": blocks, "footnotes": footnotes.map { renderFootnoteDefinition($0, context: inlineContext) }, - "indexCatalog": indexEntries + "indexCatalog": indexEntries, + "xad": xadContext ] if let nav = config.navigationTree { @@ -122,6 +234,78 @@ public final class DocumentRenderer { ] } + private func layoutProgramContext(_ program: LayoutProgram) -> [String: Any] { + return [ + "expressions": program.expressions.map { layoutExprContext($0) } + ] + } + + private func layoutExprContext(_ expr: LayoutExpr) -> [String: Any] { + switch expr { + case .node(let node): + return [ + "type": "node", + "name": node.name, + "args": node.args.map { layoutArgContext($0) }, + "children": node.children.map { layoutExprContext($0) } + ] + case .value(let value): + return [ + "type": "value", + "value": layoutValueContext(value) + ] + } + } + + private func layoutArgContext(_ arg: LayoutArg) -> [String: Any] { + return [ + "name": arg.name ?? "", + "named": arg.name != nil, + "value": layoutExprContext(arg.value) + ] + } + + private func layoutValueContext(_ value: LayoutValue) -> Any { + switch value { + case .string(let string): + return string + case .number(let number): + return number + case .boolean(let boolean): + return boolean + case .null: + return NSNull() + case .array(let values): + return values.map { layoutValueContext($0) } + case .dict(let dict): + var out: [String: Any] = [:] + for (key, entry) in dict { + out[key] = layoutValueContext(entry) + } + return out + case .ref(let ref): + var payload: [String: Any] = [ + "type": "ref", + "parts": ref.parts + ] + if let index = ref.index { + payload["index"] = layoutIndexContext(index) + } + return payload + } + } + + private func layoutIndexContext(_ index: LayoutIndex) -> Any { + switch index { + case .number(let number): + return ["type": "number", "value": number] + case .string(let string): + return ["type": "string", "value": string] + case .identifier(let ident): + return ["type": "identifier", "value": ident] + } + } + private func renderBlocks(_ blocks: [AdocBlock], context: InlineContext) -> [[String: Any]] { var rendered: [[String: Any]] = [] var idx = 0 @@ -473,6 +657,347 @@ public final class DocumentRenderer { ] } + private func renderSlotGroups(_ groups: [String: [SlotItem]], context: InlineContext) -> [String: [[String: Any]]] { + var rendered: [String: [[String: Any]]] = [:] + for (name, items) in groups { + let sorted = items.sorted { + if $0.order == $1.order { return $0.index < $1.index } + return $0.order < $1.order + } + let slotBlocks = sorted.map { $0.block } + rendered[name] = renderBlocks(slotBlocks, context: context) + } + return rendered + } + + private func extractSlotsAndCollections(from document: AdocDocument) -> SlotExtractionResult { + var slots: [String: [SlotItem]] = [:] + var collections: [String: [SlotItem]] = [:] + var warnings: [AdocWarning] = [] + var slotModeCache: [String: SlotMode] = [:] + var index = 0 + + func attributeValue(_ name: String) -> String? { + guard let value = document.attributes[name] else { return nil } + return value ?? "" + } + + func slotMode(for slot: String) -> SlotMode { + if let cached = slotModeCache[slot] { return cached } + let raw = attributeValue("slot.mode.\(slot)") ?? attributeValue("slot.mode.default") + let mode: SlotMode + if let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { + switch raw.lowercased() { + case "move": + mode = .move + case "copy": + mode = .copy + default: + warnings.append(AdocWarning(message: "Invalid slot.mode value '\(raw)' for '\(slot)'; defaulting to move.")) + mode = .move + } + } else { + mode = .move + } + slotModeCache[slot] = mode + return mode + } + + func parseOrder(_ raw: String?, context: String, span: AdocRange?) -> Double { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return 0 + } + if let value = Double(raw) { + return value + } + warnings.append(AdocWarning(message: "Invalid order '\(raw)' for \(context); defaulting to 0.", span: span)) + return 0 + } + + func normalizedName(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return raw + } + + func addSlot(_ name: String, block: AdocBlock, order: Double, index: Int) { + slots[name, default: []].append(SlotItem(block: block, order: order, index: index)) + } + + func addCollection(_ name: String, block: AdocBlock, order: Double, index: Int) { + collections[name, default: []].append(SlotItem(block: block, order: order, index: index)) + } + + func isAbstractBlock(_ meta: AdocBlockMeta) -> Bool { + if let style = meta.attributes["style"], style.lowercased() == "abstract" { + return true + } + return meta.roles.contains { $0.lowercased() == "abstract" } + } + + func isBibliographyBlock(_ meta: AdocBlockMeta) -> Bool { + if let style = meta.attributes["style"], style.lowercased() == "bibliography" { + return true + } + return meta.roles.contains { $0.lowercased() == "bibliography" } + } + + func inferredSlotName(for meta: AdocBlockMeta) -> String? { + if isAbstractBlock(meta) { return "abstract" } + if isBibliographyBlock(meta) { return "bibliography" } + return nil + } + + func extractBlocks(_ blocks: [AdocBlock]) -> [AdocBlock] { + var out: [AdocBlock] = [] + for block in blocks { + if let extracted = extractBlock(block) { + out.append(extracted) + } + } + return out + } + + func extractBlock(_ block: AdocBlock) -> AdocBlock? { + let meta = blockMeta(block) + let slotName = normalizedName(meta.attributes["slot"]) + let collectionName = normalizedName(meta.attributes["collect"]) + let inferredSlot = slotName == nil && collectionName == nil ? inferredSlotName(for: meta) : nil + let orderContext = slotName ?? collectionName ?? inferredSlot ?? "main" + let order = parseOrder(meta.attributes["order"], context: orderContext, span: meta.span) + index += 1 + let currentIndex = index + + if let collectionName { + addCollection(collectionName, block: block, order: order, index: currentIndex) + } + + let effectiveSlot = slotName ?? inferredSlot ?? "main" + if effectiveSlot != "main" { + addSlot(effectiveSlot, block: block, order: order, index: currentIndex) + } + + let shouldMove = effectiveSlot != "main" && slotMode(for: effectiveSlot) == .move + if shouldMove { + return nil + } + + if slotName != nil || collectionName != nil || inferredSlot != nil { + return block + } + + switch block { + case .section(var s): + s.blocks = extractBlocks(s.blocks) + return .section(s) + case .sidebar(var s): + s.blocks = extractBlocks(s.blocks) + return .sidebar(s) + case .example(var e): + e.blocks = extractBlocks(e.blocks) + return .example(e) + case .quote(var q): + q.blocks = extractBlocks(q.blocks) + return .quote(q) + case .open(var o): + o.blocks = extractBlocks(o.blocks) + return .open(o) + case .admonition(var a): + a.blocks = extractBlocks(a.blocks) + return .admonition(a) + case .verse(var v): + v.blocks = extractBlocks(v.blocks) + return .verse(v) + case .list(var l): + l.items = l.items.map { item in + var updated = item + updated.blocks = extractBlocks(item.blocks) + return updated + } + return .list(l) + case .dlist(var d): + d.items = d.items.map { item in + var updated = item + updated.blocks = extractBlocks(item.blocks) + return updated + } + return .dlist(d) + default: + return block + } + } + + func makeParagraph(_ text: String, roles: [String]) -> AdocParagraph { + var meta = AdocBlockMeta() + meta.roles = roles + return AdocParagraph(text: AdocText(plain: text), meta: meta) + } + + func addHeaderSlotsIfMissing() { + if (slots["title"]?.isEmpty ?? true), + let titleText = document.header?.title?.plain ?? attributeValue("doctitle"), + !titleText.isEmpty { + index += 1 + let titlePara = makeParagraph(titleText, roles: ["doc-title"]) + addSlot("title", block: .paragraph(titlePara), order: 0, index: index) + } + + if (slots["authors"]?.isEmpty ?? true) { + if let headerAuthors = document.header?.authors, !headerAuthors.isEmpty { + for author in headerAuthors { + let text = formatAuthor(author) + if text.isEmpty { continue } + index += 1 + let authorPara = makeParagraph(text, roles: ["author"]) + addSlot("authors", block: .paragraph(authorPara), order: 0, index: index) + } + } else if let raw = attributeValue("author"), !raw.isEmpty { + index += 1 + let authorPara = makeParagraph(raw, roles: ["author"]) + addSlot("authors", block: .paragraph(authorPara), order: 0, index: index) + } + } + } + + let mainBlocks = extractBlocks(document.blocks) + addHeaderSlotsIfMissing() + return SlotExtractionResult( + mainBlocks: mainBlocks, + slots: slots, + collections: collections, + warnings: warnings + ) + } + + private func blockMeta(_ block: AdocBlock) -> AdocBlockMeta { + switch block { + case .section(let s): return s.meta + case .paragraph(let p): return p.meta + case .listing(let l): return l.meta + case .list(let l): return l.meta + case .dlist(let d): return d.meta + case .discreteHeading(let h): return h.meta + case .sidebar(let s): return s.meta + case .example(let e): return e.meta + case .quote(let q): return q.meta + case .open(let o): return o.meta + case .admonition(let a): return a.meta + case .verse(let v): return v.meta + case .literalBlock(let l): return l.meta + case .table(let t): return t.meta + case .math(let m): return m.meta + case .blockMacro(let m): return m.meta + } + } + + private func resolveTemplateURL(_ raw: String, relativeTo baseURL: URL?) -> URL { + let base = baseURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + return URL(fileURLWithPath: raw, relativeTo: base).standardizedFileURL + } + + private func isPagedAttributeEnabled(document: AdocDocument) -> Bool { + if let typed = document.typedAttributes["paged"] { + return isTruthy(typed) + } + if let raw = document.attributes["paged"] { + return isTruthy(raw) + } + return false + } + + private func pageCSS(from template: XADTemplateDocument) -> String? { + guard let mastersValue = template.typedAttributes["page.masters"] else { return nil } + guard case .dictionary(let masters) = mastersValue else { return nil } + guard case .dictionary(let defaultMaster) = masters["default"] else { return nil } + + let size: String? + if case .string(let rawSize)? = defaultMaster["size"] { + size = rawSize + } else { + size = nil + } + + var marginParts: [String] = [] + if case .dictionary(let margins)? = defaultMaster["margins"] { + let top = stringValue(margins["top"]) + let right = stringValue(margins["right"]) ?? stringValue(margins["outer"]) + let bottom = stringValue(margins["bottom"]) + let left = stringValue(margins["left"]) ?? stringValue(margins["inner"]) + if let top, let right, let bottom, let left { + marginParts = [top, right, bottom, left] + } + } + + guard size != nil || !marginParts.isEmpty else { return nil } + var lines: [String] = ["@page {"] + if let size { + lines.append(" size: \(size);") + } + if !marginParts.isEmpty { + lines.append(" margin: \(marginParts.joined(separator: " "));") + } + lines.append("}") + return lines.joined(separator: "\n") + } + + private func formatAuthor(_ author: AdocAuthor) -> String { + if let fullname = author.fullname, !fullname.isEmpty { + return appendAddress(fullname, address: author.address) + } + let parts = [author.firstname, author.middlename, author.lastname].compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + if !parts.isEmpty { + return appendAddress(parts.joined(separator: " "), address: author.address) + } + if let initials = author.initials, !initials.isEmpty { + return appendAddress(initials, address: author.address) + } + return "" + } + + private func appendAddress(_ name: String, address: String?) -> String { + guard let address, !address.isEmpty else { return name } + return "\(name) <\(address)>" + } + + private func stringValue(_ value: XADAttributeValue?) -> String? { + if case .string(let raw)? = value { + return raw + } + return nil + } + + private func isTruthy(_ value: XADAttributeValue) -> Bool { + switch value { + case .bool(let b): + return b + case .number(let n): + return n != 0 + case .string(let s): + return isTruthy(s) + case .null: + return false + case .array(let arr): + return !arr.isEmpty + case .dictionary(let dict): + return !dict.isEmpty + } + } + + private func isTruthy(_ value: String?) -> Bool { + guard let value else { return false } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + switch trimmed.lowercased() { + case "true", "yes", "on", "1": + return true + case "false", "no", "off", "0": + return false + default: + return true + } + } + private func renderInlines(_ inlines: [AdocInline], context: InlineContext) -> String { inlineRenderer.render(inlines, context: context) } diff --git a/Sources/AsciiDocRender/FootnoteResolver.swift b/Sources/AsciiDocRender/FootnoteResolver.swift index 3e379e9..4b0a316 100644 --- a/Sources/AsciiDocRender/FootnoteResolver.swift +++ b/Sources/AsciiDocRender/FootnoteResolver.swift @@ -34,9 +34,12 @@ public final class FootnoteResolver { let newDoc = AdocDocument( attributes: document.attributes, + typedAttributes: document.typedAttributes, header: document.header, blocks: newBlocks, - span: document.span + warnings: document.warnings, + span: document.span, + xadOptions: document.xadOptions ) return FootnoteResolution(document: newDoc, definitions: collected) diff --git a/Sources/asciidoc-swift/Commands.swift b/Sources/asciidoc-swift/Commands.swift index 7044d09..20a1f87 100644 --- a/Sources/asciidoc-swift/Commands.swift +++ b/Sources/asciidoc-swift/Commands.swift @@ -10,6 +10,7 @@ import AsciiDocCore import AsciiDocTools import AsciiDocExtensions import AsciiDocAntora +import AsciiDocPagedRendering private struct AdapterInput: Decodable { enum Payload: String, Decodable { @@ -53,7 +54,7 @@ struct AsciiDocSwift: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "asciidoc-swift", abstract: "Swift AsciiDoc implementation with a JSON adapter for the Eclipse TCK.", - subcommands: [JSONAdapter.self, HTML.self, DocBook.self, Latex.self, Lint.self, Antora.self] + subcommands: [JSONAdapter.self, HTML.self, XadHtml.self, XadPagedHtml.self, DocBook.self, Latex.self, Lint.self, Antora.self] ) } @@ -179,6 +180,18 @@ struct HTML: AsyncParsableCommand { @Option(help: "Path to the Stencil templates root directory.") var template: String = "Templates" + @Flag(name: .long, help: "Enable XAD mode.") + var xad: Bool = false + + @Flag(name: .long, help: "Enable strict XAD parsing.") + var xadStrict: Bool = false + + @Flag(name: .long, help: "Enable Paged.js hooks (HTML only).") + var xadPagedJS: Bool = false + + @Option(name: .long, help: "Path to XAD template (.adoc).") + var xadTemplate: String? + @Option( name: [.customShort("a"), .customLong("attribute")], parsing: .unconditionalSingleValue, @@ -200,6 +213,15 @@ struct HTML: AsyncParsableCommand { var extensions: [String] = [] mutating func run() async throws { + let xadOptions = makeXadOptions( + enabled: xad, + pagedJS: xadPagedJS, + strict: xadStrict, + templatePath: xadTemplate, + layoutTemplate: nil, + layoutTemplateBase: nil, + layoutTemplateSearchPaths: [] + ) try renderDocument( backend: .html5, defaultExtension: "html", @@ -208,7 +230,181 @@ struct HTML: AsyncParsableCommand { templateRoot: template, attributeAssignments: attributeAssignments, outputPath: output, - enabledExtensions: extensions + enabledExtensions: extensions, + xadOptions: xadOptions + ) + } +} + +struct XadHtml: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "xad-html", + abstract: "Render AsciiDoc to HTML with XAD enabled." + ) + + @Flag(help: "Read source from stdin. Otherwise provide a path argument.") + var stdin: Bool = false + + @Option(help: "Path to the Stencil templates root directory.") + var template: String = "Templates" + + @Flag(name: .long, help: "Enable strict XAD parsing.") + var xadStrict: Bool = false + + @Option(name: .long, help: "Path to XAD template (.adoc).") + var xadTemplate: String? + + @Option( + name: [.customShort("a"), .customLong("attribute")], + parsing: .unconditionalSingleValue, + help: "Set document attribute (name[=value]). Repeatable." + ) + var attributeAssignments: [String] = [] + + @Option(name: .shortAndLong, help: "Write rendered output to this path.") + var output: String? + + @Argument(help: "Path to the .adoc document (omit when using --stdin).") + var inputPath: String? + + @Option( + name: [.customShort("e"), .customLong("extension")], + parsing: .upToNextOption, + help: "Enable an extension by name (repeatable)." + ) + var extensions: [String] = [] + + mutating func run() async throws { + let xadOptions = makeXadOptions( + enabled: true, + pagedJS: false, + strict: xadStrict, + templatePath: xadTemplate, + layoutTemplate: nil, + layoutTemplateBase: nil, + layoutTemplateSearchPaths: [] + ) + try renderDocument( + backend: .html5, + defaultExtension: "html", + stdin: stdin, + inputPath: inputPath, + templateRoot: template, + attributeAssignments: attributeAssignments, + outputPath: output, + enabledExtensions: extensions, + xadOptions: xadOptions + ) + } +} + +struct XadPagedHtml: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "xad-paged-html", + abstract: "Render AsciiDoc to HTML with XAD + Paged.js enabled." + ) + + @Flag(help: "Read source from stdin. Otherwise provide a path argument.") + var stdin: Bool = false + + @Option(help: "Path to the Stencil templates root directory.") + var template: String = "Templates" + + @Flag(name: .long, help: "Enable strict XAD parsing.") + var xadStrict: Bool = false + + @Option(name: .long, help: "Path to XAD template (.adoc).") + var xadTemplate: String? + + @Option(name: .long, help: "XAD paged layout template name or path.") + var xadLayoutTemplate: String? + + @Option(name: .long, help: "Base directory for resolving XAD paged template paths.") + var xadTemplateBase: String? + + @Option( + name: .long, + parsing: .upToNextOption, + help: "Additional XAD paged template search paths." + ) + var xadTemplateSearchPath: [String] = [] + + @Flag(name: .long, help: "List available XAD paged templates and exit.") + var listXadTemplates: Bool = false + + @Option( + name: [.customShort("a"), .customLong("attribute")], + parsing: .unconditionalSingleValue, + help: "Set document attribute (name[=value]). Repeatable." + ) + var attributeAssignments: [String] = [] + + @Option(name: .shortAndLong, help: "Write rendered output to this path.") + var output: String? + + @Argument(help: "Path to the .adoc document (omit when using --stdin).") + var inputPath: String? + + @Option( + name: [.customShort("e"), .customLong("extension")], + parsing: .upToNextOption, + help: "Enable an extension by name (repeatable)." + ) + var extensions: [String] = [] + + mutating func run() async throws { + let templateBaseURL = resolveTemplateBaseURL(xadTemplateBase) + var registry = XADTemplateRegistry(searchPaths: defaultTemplateRoots()) + for path in resolveTemplateSearchPaths(xadTemplateSearchPath, baseURL: templateBaseURL) { + registry.addSearchPath(path) + } + + if listXadTemplates { + let names = registry.listTemplates() + if names.isEmpty { + print("No XAD paged templates found.") + } else { + for name in names { + print(name) + } + } + return + } + + let templateSelection = xadLayoutTemplate ?? "default" + let templateResult: (XADTemplateDescriptor?, [AdocWarning]) + if let overrideURL = resolveExistingTemplateURL(templateSelection, baseURL: templateBaseURL) { + templateResult = registry.loadTemplate(at: overrideURL, baseURL: templateBaseURL) + } else { + templateResult = registry.loadTemplate(named: templateSelection, baseURL: templateBaseURL) + } + emitTemplateWarnings(templateResult.1) + guard templateResult.0 != nil else { + throw ValidationError("Unable to load XAD template: \(templateSelection)") + } + + let xadLayoutProgram = templateResult.0?.program + let templatePath = xadTemplate ?? templateResult.0?.layoutURL.path + let xadOptions = makeXadOptions( + enabled: true, + pagedJS: true, + strict: xadStrict, + templatePath: templatePath, + layoutTemplate: templateSelection, + layoutTemplateBase: xadTemplateBase, + layoutTemplateSearchPaths: xadTemplateSearchPath + ) + try renderDocument( + backend: .html5, + defaultExtension: "html", + stdin: stdin, + inputPath: inputPath, + templateRoot: template, + attributeAssignments: attributeAssignments, + outputPath: output, + enabledExtensions: extensions, + xadOptions: xadOptions, + xadLayoutProgram: xadLayoutProgram ) } } @@ -225,6 +421,18 @@ struct DocBook: AsyncParsableCommand { @Option(help: "Path to the Stencil templates root directory.") var template: String = "Templates" + @Flag(name: .long, help: "Enable XAD mode.") + var xad: Bool = false + + @Flag(name: .long, help: "Enable strict XAD parsing.") + var xadStrict: Bool = false + + @Flag(name: .long, help: "Enable Paged.js hooks (HTML only).") + var xadPagedJS: Bool = false + + @Option(name: .long, help: "Path to XAD template (.adoc).") + var xadTemplate: String? + @Option( name: [.customShort("a"), .customLong("attribute")], parsing: .unconditionalSingleValue, @@ -246,6 +454,15 @@ struct DocBook: AsyncParsableCommand { var extensions: [String] = [] mutating func run() async throws { + let xadOptions = makeXadOptions( + enabled: xad, + pagedJS: xadPagedJS, + strict: xadStrict, + templatePath: xadTemplate, + layoutTemplate: nil, + layoutTemplateBase: nil, + layoutTemplateSearchPaths: [] + ) try renderDocument( backend: .docbook5, defaultExtension: "xml", @@ -254,7 +471,8 @@ struct DocBook: AsyncParsableCommand { templateRoot: template, attributeAssignments: attributeAssignments, outputPath: output, - enabledExtensions: extensions + enabledExtensions: extensions, + xadOptions: xadOptions ) } } @@ -271,6 +489,18 @@ struct Latex: AsyncParsableCommand { @Option(help: "Path to the Stencil templates root directory.") var template: String = "Templates" + @Flag(name: .long, help: "Enable XAD mode.") + var xad: Bool = false + + @Flag(name: .long, help: "Enable strict XAD parsing.") + var xadStrict: Bool = false + + @Flag(name: .long, help: "Enable Paged.js hooks (HTML only).") + var xadPagedJS: Bool = false + + @Option(name: .long, help: "Path to XAD template (.adoc).") + var xadTemplate: String? + @Option( name: [.customShort("a"), .customLong("attribute")], parsing: .unconditionalSingleValue, @@ -292,6 +522,15 @@ struct Latex: AsyncParsableCommand { var extensions: [String] = [] mutating func run() async throws { + let xadOptions = makeXadOptions( + enabled: xad, + pagedJS: xadPagedJS, + strict: xadStrict, + templatePath: xadTemplate, + layoutTemplate: nil, + layoutTemplateBase: nil, + layoutTemplateSearchPaths: [] + ) try renderDocument( backend: .latex, defaultExtension: "tex", @@ -300,7 +539,8 @@ struct Latex: AsyncParsableCommand { templateRoot: template, attributeAssignments: attributeAssignments, outputPath: output, - enabledExtensions: extensions + enabledExtensions: extensions, + xadOptions: xadOptions ) } } @@ -536,6 +776,62 @@ struct Antora: AsyncParsableCommand { // Rendering helpers extension PlantUMLExtension.Format: ExpressibleByArgument {} +private func defaultTemplateRoots() -> [URL] { + var roots: [URL] = [] + if let bundleTemplates = Bundle.module.url(forResource: "Templates", withExtension: nil) { + roots.append(bundleTemplates) + } + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + roots.append(cwd.appendingPathComponent("Templates")) + return roots +} + +private func resolveTemplateBaseURL(_ value: String?) -> URL? { + guard let value else { return nil } + return URL(fileURLWithPath: value).standardizedFileURL +} + +private func resolveTemplateSearchPaths(_ values: [String], baseURL: URL?) -> [URL] { + let base = baseURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + return values.map { URL(fileURLWithPath: $0, relativeTo: base).standardizedFileURL } +} + +private func resolveExistingTemplateURL(_ value: String, baseURL: URL?) -> URL? { + let base = baseURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let candidate = URL(fileURLWithPath: value, relativeTo: base).standardizedFileURL + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + return nil +} + +private func emitTemplateWarnings(_ warnings: [AdocWarning]) { + for warning in warnings { + FileHandle.standardError.write(Data("Template warning: \(warning.message)\n".utf8)) + } +} + +private func makeXadOptions( + enabled: Bool, + pagedJS: Bool, + strict: Bool, + templatePath: String?, + layoutTemplate: String?, + layoutTemplateBase: String?, + layoutTemplateSearchPaths: [String] +) -> XADOptions { + let shouldEnable = enabled || pagedJS || templatePath != nil || layoutTemplate != nil + return XADOptions( + enabled: shouldEnable, + strict: strict, + pagedJS: pagedJS, + templatePath: templatePath, + layoutTemplate: layoutTemplate, + layoutTemplateBase: layoutTemplateBase, + layoutTemplateSearchPaths: layoutTemplateSearchPaths + ) +} + private func renderDocument( backend: Backend, defaultExtension: String, @@ -544,7 +840,9 @@ private func renderDocument( templateRoot: String, attributeAssignments: [String], outputPath: String?, - enabledExtensions: [String] + enabledExtensions: [String], + xadOptions: XADOptions, + xadLayoutProgram: LayoutProgram? = nil ) throws { let (source, sourcePath) = try readRenderSource(stdin: stdin, inputPath: inputPath) let documentDirectory = sourcePath.map { URL(fileURLWithPath: $0).deletingLastPathComponent() } @@ -561,7 +859,8 @@ private func renderDocument( extensionHost: &extensionHost, attributes: baseAttributes, documentDirectory: documentDirectory, - enabledExtensions: enabledExtensions + enabledExtensions: enabledExtensions, + xadOptions: xadOptions ) let (preprocessedSource, updatedAttributes) = extensionHost.runWillParse( @@ -581,7 +880,8 @@ private func renderDocument( text: preprocessedSource, attributes: parserAttributes, lockedAttributeNames: seed.locked, - preprocessorOptions: preprocessorOptions + preprocessorOptions: preprocessorOptions, + xadOptions: xadOptions ) doc = extensionHost.runDidParse(document: doc) @@ -589,7 +889,10 @@ private func renderDocument( let engine = StencilTemplateEngine(templateRoot: templateRoot) let renderer = DocumentRenderer( engine: engine, - config: RenderConfig(backend: backend) + config: RenderConfig( + backend: backend, + xadOptions: xadOptions, + ) ) let rendered = try renderer.render(document: doc) try writeRenderedOutput(rendered, explicitPath: outputPath, sourcePath: sourcePath, defaultExtension: defaultExtension) @@ -637,7 +940,8 @@ private func registerExtensions( extensionHost: inout ExtensionHost, attributes: [String: String], documentDirectory: URL?, - enabledExtensions: [String] + enabledExtensions: [String], + xadOptions: XADOptions ) { if let plantumlExtension = makePlantumlExtension( attributes: attributes, @@ -650,6 +954,10 @@ private func registerExtensions( if let latexExtension = makeLatexExtension(enabledExtensions: enabledExtensions) { extensionHost.register(latexExtension) } + + if xadOptions.enabled { + extensionHost.register(XADExtension()) + } } private func makePlantumlExtension( diff --git a/Sources/asciidoc-swift/asciidoc-swift.docc/asciidoc-swift.md b/Sources/asciidoc-swift/asciidoc-swift.docc/asciidoc-swift.md index 790955c..254f732 100644 --- a/Sources/asciidoc-swift/asciidoc-swift.docc/asciidoc-swift.md +++ b/Sources/asciidoc-swift/asciidoc-swift.docc/asciidoc-swift.md @@ -53,11 +53,72 @@ asciidoc-swift html [--stdin] [--template