diff --git a/Package.resolved b/Package.resolved index 4247bca..e426ad1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "228703345ba4471e3c0fc29c7b71f4486d20782e43b972784dc1b3b010895fbd", + "originHash" : "8ed9a9d9e721da71dfc2e44baf106c5f006158b5665518d22d352e32f671eb79", "pins" : [ { "identity" : "swift-argument-parser", @@ -55,6 +55,15 @@ "version" : "0.7.3" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, { "identity" : "yams", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 987513a..1e45e29 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,12 @@ let package = Package( platforms: [.macOS(.v26)], products: [ .library(name: "SiteKit", targets: ["SiteKit"]), + // Optional add-on library: a SwiftSyntax-based Swift code highlighter for DocC sites that want + // semantic-near token roles (variable/call/member/param/…) instead of the regex highlighter's + // capitalized-only type detection. It lives in its own product+target so it pulls swift-syntax + // ONLY into builds that actually use it. A consumer depending only on the `SiteKit` product never + // compiles swift-syntax (SE-0226 target-based dependency resolution prunes it). + .library(name: "SiteKitSyntaxHighlighting", targets: ["SiteKitSyntaxHighlighting"]), // The executable *product* is `sitekit` (the durable public command name); its *target* // is `SiteKitCLI` because a target literally named `sitekit` collides with the `SiteKit` // library target on a case-insensitive filesystem – at both the `Sources/` directory and @@ -19,6 +25,12 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + + // swift-syntax powers ONLY the optional SiteKitSyntaxHighlighting target. The 6xx.x line tracks + // the Swift toolchain (603.x = Swift 6.3, the toolchain SiteKit builds with). Only the parser + + // tree + syntactic-classification modules are used; the macro/compiler-plugin modules (the heavy, + // slow-to-compile part of swift-syntax) are deliberately NOT depended upon. + .package(url: "https://github.com/swiftlang/swift-syntax.git", "603.0.0"..<"604.0.0"), ], targets: [ .target( @@ -38,10 +50,30 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), + // Optional SwiftSyntax-based highlighter. Depends on the base `SiteKit` library (for the + // `CodeHighlighting` seam) plus exactly three swift-syntax modules: SwiftParser (error-tolerant + // parsing of code fragments), SwiftSyntax (the tree + visitor), and SwiftIDEUtils (the syntactic + // `classifications` API that SourceKit uses). Deliberately excludes SwiftSyntaxMacros, + // SwiftCompilerPlugin, SwiftSyntaxMacroExpansion, SwiftOperators, SwiftSyntaxBuilder and + // SwiftParserDiagnostics – none are needed to classify tokens, and they are the bulk of the + // swift-syntax build cost. + .target( + name: "SiteKitSyntaxHighlighting", + dependencies: [ + "SiteKit", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftIDEUtils", package: "swift-syntax"), + ] + ), .testTarget( name: "SiteKitTests", dependencies: ["SiteKit"] ), + .testTarget( + name: "SiteKitSyntaxHighlightingTests", + dependencies: ["SiteKitSyntaxHighlighting"] + ), .testTarget( name: "SiteKitCLITests", dependencies: ["SiteKitCLI"] diff --git a/Plugin/blueprints/INDEX.md b/Plugin/blueprints/INDEX.md index b17133d..073c80d 100644 --- a/Plugin/blueprints/INDEX.md +++ b/Plugin/blueprints/INDEX.md @@ -26,7 +26,7 @@ Read `.md` first, then copy files from `/`. | `Podcast` | Episode pages, audio player, chapters, iTunes RSS | Podcast shows, interview series | [Podcast.md](Podcast.md) | [appstore-tagebuch.de](https://appstore-tagebuch.de) | | `Newsletter` | Email newsletter with issue archive, signup forms, email rendering | Topic newsletters, curated digests, weekly/monthly roundups | [Newsletter.md](Newsletter.md) | [evolutionkit.dev](https://evolutionkit.dev) | | `AppLanding` | Single product landing page with hero, features, pricing, reviews | App marketing pages, SaaS products | [AppLanding.md](AppLanding.md) | [translatekit.pages.dev](https://translatekit.pages.dev) | -| `DocC` | DocC catalog → static, AI-fetchable HTML with a sidebar + full-text search | Documentation sites, API/guide docs | [DocC.md](DocC.md) | [wwdcnotes.fline.dev](https://wwdcnotes.fline.dev) | +| `DocC` | DocC catalog → static, AI-fetchable HTML with a sidebar + full-text search | Documentation sites, API/guide docs | [DocC.md](DocC.md) | [wwdcnotes.com](https://wwdcnotes.com) | | `Plain` | Minimal structure, no opinions | Experimentation, custom pipelines | [Plain.md](Plain.md) | – | --- diff --git a/README.md b/README.md index 1ed6c82..9e378c0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ SiteKit ships **9 blueprints** – starter sites you scaffold and customise. Eac | **Podcast** | Episode pages, audio player, iTunes RSS | [Podcast.md](Plugin/blueprints/Podcast.md) | [appstore-tagebuch.de](https://appstore-tagebuch.de) | | **Newsletter** | Issue archive, signup forms, email rendering | [Newsletter.md](Plugin/blueprints/Newsletter.md) | [evolutionkit.dev](https://evolutionkit.dev) | | **AppLanding** | Single product landing page (hero, features, pricing) | [AppLanding.md](Plugin/blueprints/AppLanding.md) | [translatekit.pages.dev](https://translatekit.pages.dev) | -| **DocC** | DocC catalog → docs site with sidebar + full-text search | [DocC.md](Plugin/blueprints/DocC.md) | [wwdcnotes.fline.dev](https://wwdcnotes.fline.dev) | +| **DocC** | DocC catalog → docs site with sidebar + full-text search | [DocC.md](Plugin/blueprints/DocC.md) | [wwdcnotes.com](https://wwdcnotes.com) | | **Plain** | Minimal, no opinions – a blank canvas | [Plain.md](Plugin/blueprints/Plain.md) | – | Not sure which to pick? The [blueprint catalog](Plugin/blueprints/INDEX.md) has a decision tree and a feature comparison. diff --git a/Sources/SiteKit/Pipeline/SiteBuilder.swift b/Sources/SiteKit/Pipeline/SiteBuilder.swift index cdc0f88..8d46aaf 100644 --- a/Sources/SiteKit/Pipeline/SiteBuilder.swift +++ b/Sources/SiteKit/Pipeline/SiteBuilder.swift @@ -496,7 +496,8 @@ public struct SiteBuilder { public static func docc( config: SiteConfig, projectDirectory: URL, - cleanBeforeBuild: Bool = true + cleanBeforeBuild: Bool = true, + highlighter: (any CodeHighlighting)? = nil ) -> SiteBuilder { let urlPrefix = config.effectiveSections.first?.urlPrefix ?? "documentation" @@ -505,7 +506,7 @@ public struct SiteBuilder { var builder = SiteBuilder(config: config, projectDirectory: projectDirectory) .cleanBeforeBuild(cleanBeforeBuild) .contentDiscovery(DocCCatalogDiscovery()) - .articleLoader(DocCLoader(language: config.language, defaultCodeLanguage: config.docc?.defaultCodeLanguage)) + .articleLoader(DocCLoader(language: config.language, defaultCodeLanguage: config.docc?.defaultCodeLanguage, highlighter: highlighter)) // Emit every *.docc/Images/ asset into output /assets/ so @PageImage icon // URLs like /assets/WWDC25.svg resolve to real files in production. .additionalTeleporter(DocCCatalogImageTeleporter(contentDirectory: contentDirectory)) @@ -698,11 +699,12 @@ extension SiteBuilder { /// ``` public static func docc( configPath: String, - cleanBeforeBuild: Bool = true + cleanBeforeBuild: Bool = true, + highlighter: (any CodeHighlighting)? = nil ) throws -> SiteBuilder { let projectDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) let config = SiteBuilder.loadConfigOrExit(at: configPath, in: projectDirectory) - return docc(config: config, projectDirectory: projectDirectory, cleanBeforeBuild: cleanBeforeBuild) + return docc(config: config, projectDirectory: projectDirectory, cleanBeforeBuild: cleanBeforeBuild, highlighter: highlighter) } /// Loads the site configuration for a `configPath:` convenience factory, reporting @@ -731,7 +733,7 @@ extension SiteBuilder { enum BaseURLOverrideError: Error, Equatable, CustomStringConvertible { /// `--base-url` was passed as the last argument, with no value following it. case missingValue - /// The value is not an absolute http(s) URL (e.g. `wwdcnotes.fline.dev` without a scheme). + /// The value is not an absolute http(s) URL (e.g. `wwdcnotes.com` without a scheme). case notAnAbsoluteHTTPURL(String) var description: String { diff --git a/Sources/SiteKit/Plugins/DocC/DocCLoader.swift b/Sources/SiteKit/Plugins/DocC/DocCLoader.swift index 809c039..57465ef 100644 --- a/Sources/SiteKit/Plugins/DocC/DocCLoader.swift +++ b/Sources/SiteKit/Plugins/DocC/DocCLoader.swift @@ -27,10 +27,28 @@ public struct DocCLoader: Loader { /// untagged blocks are highlighted as if they were tagged with this language. Nil means /// plain escaped text. Does not override an explicitly tagged block's language. private let defaultCodeLanguage: String? + /// The code highlighter applied to fenced code blocks. Defaults to the zero-dependency + /// regex `CodeHighlighter`; a DocC site can inject `SwiftSyntaxHighlighter` (from the + /// `SiteKitSyntaxHighlighting` product) for semantic-near Swift token roles. + private let highlighter: any CodeHighlighting - public init(language: String = "en", defaultCodeLanguage: String? = nil) { + /// Creates a DocC loader. + /// + /// - Parameters: + /// - language: The locale this loader produces pages for. + /// - defaultCodeLanguage: Fallback language for untagged fenced code blocks. + /// - highlighter: The code highlighter for fenced blocks. Pass nil (the default) to use the + /// zero-dependency regex `CodeHighlighter`; inject `SwiftSyntaxHighlighter` for the richer + /// SwiftSyntax-based Swift roles. The default is resolved internally so callers depending + /// only on `SiteKit` never reference the swift-syntax product. + public init( + language: String = "en", + defaultCodeLanguage: String? = nil, + highlighter: (any CodeHighlighting)? = nil + ) { self.language = language self.defaultCodeLanguage = defaultCodeLanguage + self.highlighter = highlighter ?? CodeHighlighter() } public func load(source: MarkdownSource) throws -> PageModel { @@ -119,7 +137,7 @@ public struct DocCLoader: Loader { extensions["doccAIOnly"] = true } else if let aiBody = self.aiVariantBodyHTML(communityPath: source.filePath) { // Apply syntax highlighting to the AI-variant body as well. - let highlightedAI = CodeHighlighter.applyToBodyHTML(aiBody, defaultLanguage: self.defaultCodeLanguage) + let highlightedAI = self.highlighter.applyToBodyHTML(aiBody, defaultLanguage: self.defaultCodeLanguage) extensions["doccAIVariant"] = highlightedAI } @@ -127,7 +145,7 @@ public struct DocCLoader: Loader { // DocC notes only: the MarkdownRenderer is shared across all site types and must // not be changed. Highlighting is applied here (post-rendering, pre-PageModel) so // it is cleanly isolated and does not affect non-DocC pages. - let highlightedBodyHTML = CodeHighlighter.applyToBodyHTML( + let highlightedBodyHTML = self.highlighter.applyToBodyHTML( parsed.bodyHTML, defaultLanguage: self.defaultCodeLanguage ) diff --git a/Sources/SiteKit/Resources/DocC/docc.css b/Sources/SiteKit/Resources/DocC/docc.css index deb0698..dee169f 100644 --- a/Sources/SiteKit/Resources/DocC/docc.css +++ b/Sources/SiteKit/Resources/DocC/docc.css @@ -4039,6 +4039,33 @@ body.sk-docc-shell-body { border-radius: 4px; } +/* A linked inline-code pill must read as interactive, not as just another code pill. The base link + affordance (accent color + prose underline) is otherwise lost: the neutral pill above already + carries an accent-leaning border, and a host theme that sets a bare `code { color: ... }` (legacy + sites often do) flattens the inherited link color outright. The DESCENDANT selector with + :not([class]) lands at specificity (0,2,2), so it out-ranks both the neutral pill (0,2,1) for the + border/tint and any bare `code` color rule (0,0,1) for the text color. Give the pill an explicit + accent text color, a visible underline, and an accent-dominant border + tint so it is unmistakably + a link in every scheme while keeping the redesign's pill shape. `code a` covers the reverse nesting + (a link wrapped inside a code span). */ +.sk-article-body a code:not([class]) { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 60%, var(--color-border)); + background: color-mix(in srgb, var(--color-accent) 14%, var(--color-bg-code-inline)); + text-decoration: underline; + text-underline-offset: 2px; +} +.sk-article-body code a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} +.sk-article-body a:hover code:not([class]), +.sk-article-body code a:hover { + color: var(--color-accent-hover, var(--color-accent)); + border-color: var(--color-accent); +} + /* Code inside any fenced block fills the block and drops the inline treatment: block display, no padding/border, inherited size. Covers both highlighted () and the class-less of an un-highlighted block. */ @@ -4062,43 +4089,115 @@ body.sk-docc-shell-body { } /* ── Syntax-highlight token colors ─────────────────────────────── - Token spans emitted by CodeHighlighter during build time. All colors are - theme-token-driven via color-mix so they adapt to every color scheme and - both light/dark modes without per-scheme overrides. - The .sk-docc-highlight scope gate ensures these rules never affect non-DocC - or un-highlighted code blocks. */ + Token spans emitted by a code highlighter during build time. Two highlighters feed these classes: + the zero-dependency regex CodeHighlighter (keyword/type/string/number/attribute/comment) and the + optional SwiftSyntax highlighter, which additionally classifies call/variable/member/param/boolean/ + operator/label so a DocC site gets an Xcode-like, semantic-near palette – notably GREEN variable + references (the `stickers` in `ForEach(stickers) { … }`). + + The palette is a FIXED Apple/Xcode set, not accent-derived: syntax highlighting reads best with + stable, learned colours, so code legibility outranks per-scheme tinting here (an earlier + accent-derived mix collapsed to near-monochrome under a saturated accent). Because the colours are + fixed, the highlighted code-block surface is pinned too (below) so every token keeps a known WCAG + contrast regardless of the site's accent or colour scheme. Roles Xcode leaves in the default text + colour – member, param, operator, label – get no rule and simply inherit, matching the reference + screenshot where `.swipeActions` and the `sticker` binding are default-coloured. + + Light values live at the default scope; dark values flip under [data-theme="dark"], the same toggle + the generated tokens.css uses. Values are seeded from the SiteKit DocC palette spec (Apple + swift-docc-render / Xcode); a site can override any of them in its own CSS with no Swift rebuild. + Measured WCAG contrast on the pinned surfaces: light tokens >= 4.5 except attribute 4.17 and comment + 3.78 (both AA-large, muted-by-design / tuning candidates); dark tokens all >= 4.5. */ + +/* Pin the highlighted code-block surface so the fixed token palette keeps its measured contrast in + both modes (light #f5f5f7 ≈ Apple near-white, which also lifts the whole palette; dark #1f1f24 ≈ + Xcode editor). Scoped to highlighted blocks only, so inline code pills and un-highlighted blocks + keep their scheme-adaptive surface. */ +.sk-article-body pre.sk-docc-highlight { + background: #f5f5f7; +} +[data-theme="dark"] .sk-article-body pre.sk-docc-highlight { + background: #1f1f24; +} -/* Comments: muted, italicised – conventional across editors. */ +/* Comments: muted grey, italicised – conventional across editors. */ .sk-docc-highlight .sk-tok-comment { - color: color-mix(in srgb, var(--color-text-secondary, var(--color-text)) 70%, transparent); + color: #707F8C; font-style: italic; } -/* String literals: accent-adjacent warm tone derived from the scheme accent. - color-mix shifts toward a green-adjacent hue on most schemes while keeping - contrast – works in both light and dark because the accent itself is always - legible against the code surface. */ -.sk-docc-highlight .sk-tok-string { - color: color-mix(in srgb, var(--color-accent) 80%, #22c55e); -} - -/* Numeric literals: a complementary warm tone. */ -.sk-docc-highlight .sk-tok-number { - color: color-mix(in srgb, var(--color-accent) 60%, #f59e0b); +/* Keywords, and #if/#else directives: Apple pink – the strongest semantic signal. */ +.sk-docc-highlight .sk-tok-keyword { + color: #AD3DA4; + font-weight: 600; } -/* Keywords: accent color (the strongest semantic signal). */ -.sk-docc-highlight .sk-tok-keyword { - color: var(--color-accent); +/* Booleans and nil: the same pink as keywords. */ +.sk-docc-highlight .sk-tok-boolean { + color: #AD3DA4; font-weight: 600; } -/* Type names: a slightly lighter or shifted accent-adjacent tone. */ +/* Type references: purple. Every capitalized type – initializer, annotation, or bare reference – + uses this single role, matching the regex highlighter (`ScrollView`, `Text`, and a project's own + `StickerListItemView` all read purple). */ .sk-docc-highlight .sk-tok-type { - color: color-mix(in srgb, var(--color-accent) 75%, #818cf8); + color: #703DAA; } -/* Attributes (@MainActor, @State, …): a purple-shifted tone distinct from keywords. */ +/* Free-function calls – a lowercase callee like `print`: green. A capitalized callee is a type + initializer and takes the purple `sk-tok-type` above instead. */ +.sk-docc-highlight .sk-tok-call { + color: #3C7D3C; +} + +/* Variable references: green – the headline of the semantic-near palette. */ +.sk-docc-highlight .sk-tok-variable { + color: #3C7D3C; +} + +/* String literals: Apple red. */ +.sk-docc-highlight .sk-tok-string { + color: #D12F1B; +} + +/* Numeric literals: Apple blue. */ +.sk-docc-highlight .sk-tok-number { + color: #272AD8; +} + +/* Attributes (@State, @MainActor, …): ocher. */ .sk-docc-highlight .sk-tok-attribute { - color: color-mix(in srgb, var(--color-accent) 55%, #a78bfa); + color: #947100; +} + +/* Dark mode: the same hue families, lifted to lighter tints that clear WCAG AA on the dark surface. + Driven by [data-theme="dark"] (the toggle the head-init script sets and the generated tokens.css + switches on), so it tracks the active theme. member/param/operator/label inherit in both modes. */ +[data-theme="dark"] .sk-docc-highlight .sk-tok-comment { + color: #7F8C98; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-keyword { + color: #FF7AB2; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-boolean { + color: #FF7AB2; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-type { + color: #DABAFF; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-call { + color: #7FD98A; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-variable { + color: #7FD98A; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-string { + color: #FF8170; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-number { + color: #D9C97C; +} +[data-theme="dark"] .sk-docc-highlight .sk-tok-attribute { + color: #CC9768; } diff --git a/Sources/SiteKit/Utilities/CodeHighlighter.swift b/Sources/SiteKit/Utilities/CodeHighlighter.swift index c158a25..08bd210 100644 --- a/Sources/SiteKit/Utilities/CodeHighlighter.swift +++ b/Sources/SiteKit/Utilities/CodeHighlighter.swift @@ -19,9 +19,21 @@ import Foundation /// ```swift /// let html = CodeHighlighter.highlight(code: rawCode, language: "swift") /// ``` -struct CodeHighlighter { +public struct CodeHighlighter: CodeHighlighting { - // MARK: - Public API + /// Creates the zero-dependency regex highlighter – the default DocC code highlighter. + public init() {} + + // MARK: - CodeHighlighting + + /// Highlights `code` for `language`, satisfying the `CodeHighlighting` seam. Forwards to the + /// static `highlight(code:language:)` entry point that carries the regex implementation, so + /// the regex logic has a single home while `DocCLoader` can hold this as `any CodeHighlighting`. + public func highlight(code: String, language: String?) -> String { + Self.highlight(code: code, language: language) + } + + // MARK: - Static API /// Highlights `code` for the given `language` and returns an HTML fragment /// containing `` tokens. The returned string is @@ -53,92 +65,30 @@ struct CodeHighlighter { /// This method is DocC-only: it is called from `DocCLoader` after the body /// HTML is produced and is not referenced by any shared renderer. /// + /// The block-extraction plumbing now lives on the `CodeHighlighting` protocol so every + /// conformer shares it; this static entry point is a thin shim over the regex conformer + /// for callers (and tests) that reach `CodeHighlighter` directly. + /// /// - Parameters: /// - html: The rendered article body HTML (code content is already HTML-escaped /// by `MarkdownRenderer`). /// - defaultLanguage: Optional fallback language for untagged fences. static func applyToBodyHTML(_ html: String, defaultLanguage: String?) -> String { - // Match:
...content...
- // or
...content...
(no language class) - // The content may span multiple lines, so .dotMatchesLineSeparators is required. - guard let regex = try? NSRegularExpression( - pattern: #"
(.*?)
"#, - options: [.dotMatchesLineSeparators] - ) else { return html } - - let ns = html as NSString - var result = "" - var cursor = 0 - - let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length)) - for match in matches { - // Append verbatim text before this match. - let wholeRange = match.range - result += ns.substring(with: NSRange(location: cursor, length: wholeRange.location - cursor)) - - // Extract language from `class="language-X"` (group 2). When the class - // attribute is entirely absent group 1 and group 2 are both empty. - let language: String? - if match.range(at: 2).location != NSNotFound { - let langRaw = ns.substring(with: match.range(at: 2)) - language = langRaw.isEmpty ? defaultLanguage : langRaw - } else { - language = defaultLanguage - } - - // The already-escaped code content (group 3). - let escapedContent: String - if match.range(at: 3).location != NSNotFound { - escapedContent = ns.substring(with: match.range(at: 3)) - } else { - escapedContent = "" - } - - // Unescape then re-highlight so the tokenizer works on plain text. - let rawContent = unescapeHTML(escapedContent) - let highlighted: String - let classAttr: String - if let lang = language?.lowercased().trimmingCharacters(in: .whitespaces), !lang.isEmpty { - highlighted = highlight(code: rawContent, language: lang) - classAttr = " class=\"language-\(escapeAttribute(lang))\"" - } else { - // No language and no default: just re-escape the raw content. - highlighted = escapeHTML(rawContent) - classAttr = "" - } - - result += "
\(highlighted)
" - cursor = wholeRange.location + wholeRange.length - } - - result += ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)) - return result + CodeHighlighter().applyToBodyHTML(html, defaultLanguage: defaultLanguage) } // MARK: - HTML helpers - /// Escapes `<`, `>`, `&`, and `"` so the text is safe in HTML content. + /// Escapes `<`, `>`, `&`, and `"` so the text is safe in HTML content. Forwards to the + /// shared `HTMLEscaping` helper so every highlighter escapes identically. static func escapeHTML(_ string: String) -> String { - string - .replacing("&", with: "&") - .replacing("<", with: "<") - .replacing(">", with: ">") - .replacing("\"", with: """) + HTMLEscaping.escape(string) } /// Reverses the four basic HTML entities used by `escapeHTML`. /// Used to recover raw source text from already-escaped HTML before re-tokenizing. static func unescapeHTML(_ string: String) -> String { - string - .replacing("<", with: "<") - .replacing(">", with: ">") - .replacing(""", with: "\"") - .replacing("&", with: "&") - } - - /// Escapes a string for use in an HTML attribute value (double-quote context). - private static func escapeAttribute(_ string: String) -> String { - escapeHTML(string) + HTMLEscaping.unescape(string) } // MARK: - Tokenization engine diff --git a/Sources/SiteKit/Utilities/CodeHighlighting.swift b/Sources/SiteKit/Utilities/CodeHighlighting.swift new file mode 100644 index 0000000..2e933d5 --- /dev/null +++ b/Sources/SiteKit/Utilities/CodeHighlighting.swift @@ -0,0 +1,104 @@ +import Foundation + +/// HTML entity escaping shared by every code highlighter and the body-rewriting plumbing. +/// +/// Kept in one place so the regex `CodeHighlighter`, the `CodeHighlighting` body-rewriter, +/// and any out-of-module conformer (e.g. the SwiftSyntax highlighter) all escape token text +/// identically. Only the four entities a build-time highlighter ever needs are handled. +public enum HTMLEscaping { + /// Escapes `&`, `<`, `>`, and `"` so the text is safe in HTML content. The `&` rule runs + /// first so the ampersands introduced by the other rules are not re-escaped. + public static func escape(_ string: String) -> String { + string + .replacing("&", with: "&") + .replacing("<", with: "<") + .replacing(">", with: ">") + .replacing("\"", with: """) + } + + /// Reverses the four entities `escape(_:)` produces. Used to recover raw source text from + /// already-escaped rendered HTML before re-tokenizing it. + public static func unescape(_ string: String) -> String { + string + .replacing("<", with: "<") + .replacing(">", with: ">") + .replacing(""", with: "\"") + .replacing("&", with: "&") + } +} + +/// A build-time syntax highlighter for DocC code blocks. +/// +/// A conformer turns one raw (unescaped) code snippet into an HTML fragment of +/// `` tokens whose contents are HTML-escaped, so the output is safe +/// to embed inside a `` element. The shared `applyToBodyHTML(_:defaultLanguage:)` +/// default walks a rendered article body and re-highlights every fenced `
` block
+/// through `highlight(code:language:)`, so a conformer only implements the per-snippet step.
+///
+/// SiteKit ships two conformers:
+/// - `CodeHighlighter` – the zero-dependency regex highlighter, the default in the base
+///   `SiteKit` library. It recognizes keywords, strings, comments, numbers, attributes, and
+///   capitalized type names.
+/// - `SwiftSyntaxHighlighter` – a SwiftSyntax-based highlighter in the optional
+///   `SiteKitSyntaxHighlighting` product. It classifies Swift tokens by syntactic role
+///   (variable, call, member, parameter, …) for a semantic-near, Xcode-like palette.
+///
+/// DocC sites opt into the richer highlighter by injecting it (`SiteBuilder.docc(…, highlighter:)`
+/// or `DocCLoader(…, highlighter:)`); sites that stay on the default never compile swift-syntax.
+public protocol CodeHighlighting: Sendable {
+   /// Highlights `code` for the given `language` and returns an HTML fragment containing
+   /// `` tokens. The returned string is safe to embed inside a ``
+   /// element without further escaping. A nil, empty, or unknown language returns plain escaped
+   /// text with no spans.
+   ///
+   /// - Parameters:
+   ///   - code: The raw, unescaped source code to highlight.
+   ///   - language: A language identifier (e.g. "swift", "python"). Nil or empty falls back to
+   ///     plain escaped text.
+   func highlight(code: String, language: String?) -> String
+}
+
+extension CodeHighlighting {
+   /// Post-processes rendered article body HTML and applies syntax highlighting to every
+   /// `
` block found. + /// + /// Adds `sk-docc-highlight` to the `
` class list so the token CSS rules are scoped to
+   /// highlighted blocks. Blocks whose language class is absent or empty use `defaultLanguage`
+   /// when provided; blocks with no language and no default are left as plain escaped text.
+   ///
+   /// This is the shared block-level plumbing for every conformer: it locates the fenced blocks
+   /// and delegates the per-snippet work to `highlight(code:language:)`. It is DocC-only – it is
+   /// called from `DocCLoader` after the body HTML is produced and is not referenced by any
+   /// shared renderer.
+   ///
+   /// - Parameters:
+   ///   - html: The rendered article body HTML (code content is already HTML-escaped by
+   ///     `MarkdownRenderer`).
+   ///   - defaultLanguage: Optional fallback language for untagged fences.
+   public func applyToBodyHTML(_ html: String, defaultLanguage: String?) -> String {
+      // Match: 
...content...
+ // or
...content...
(no language class) + // `lang` is nil when the class attribute is absent; `body` may span multiple lines, so the dot + // must match newlines. `replacing` rewrites each non-overlapping block and leaves the rest of + // the body verbatim. + let blockPattern = #/
[^"]*)")?\s*>(?.*?)
/# + .dotMatchesNewlines() + + return html.replacing(blockPattern) { match in + // An absent class attribute and an empty `language-` value both fall back to `defaultLanguage`. + let language = match.lang.flatMap { $0.isEmpty ? nil : String($0) } ?? defaultLanguage + + // The captured content is already HTML-escaped; unescape so the tokenizer works on plain text. + let rawContent = HTMLEscaping.unescape(String(match.body)) + + if let lang = language?.lowercased().trimmingCharacters(in: .whitespaces), !lang.isEmpty { + let highlighted = self.highlight(code: rawContent, language: lang) + return "
\(highlighted)
" + } else { + // No language and no default: just re-escape the raw content. + let highlighted = HTMLEscaping.escape(rawContent) + return "
\(highlighted)
" + } + } + } +} diff --git a/Sources/SiteKitSyntaxHighlighting/SwiftSyntaxHighlighter.swift b/Sources/SiteKitSyntaxHighlighting/SwiftSyntaxHighlighter.swift new file mode 100644 index 0000000..991f916 --- /dev/null +++ b/Sources/SiteKitSyntaxHighlighting/SwiftSyntaxHighlighter.swift @@ -0,0 +1,140 @@ +import SiteKit +import SwiftIDEUtils +import SwiftParser +import SwiftSyntax + +/// A SwiftSyntax-based code highlighter that classifies Swift tokens by their syntactic role and +/// emits a distinct `sk-tok-` span per role, for an Xcode-like, semantic-near palette. +/// +/// Compared with the regex `CodeHighlighter` (which only recognizes keywords, strings, comments, +/// numbers, attributes, and capitalized type names), this highlighter additionally distinguishes +/// value references (`stickers` → `variable`, rendered green), function calls (`call`), member +/// accesses (`member`), parameter bindings (`param`), booleans/`nil` (`boolean`), and argument +/// labels (`label`). It does so purely from the parsed syntax tree – no type-checker or symbol +/// graph – so it is fast and error-tolerant on the partial code fragments common in DocC notes. +/// +/// The classification is SYNTACTIC, not semantic: every capitalized type (initializer, annotation, +/// or bare reference) gets the single `type` role, exactly like the regex `CodeHighlighter`. The +/// extra value this highlighter adds is the EXPRESSION-position roles the regex pass cannot see – +/// the green `variable` references above all. +/// +/// Non-Swift snippets (and nil/empty languages) are delegated to a fallback highlighter – by +/// default the zero-dependency regex `CodeHighlighter` – so a DocC site can inject this single +/// highlighter and still get reasonable coloring for its Python, shell, or YAML blocks. +public struct SwiftSyntaxHighlighter: CodeHighlighting { + private let fallback: any CodeHighlighting + + /// Creates a SwiftSyntax-based highlighter. + /// + /// - Parameter fallback: The highlighter used for non-Swift snippets and nil/empty languages. Pass + /// nil (the default) to use the zero-dependency regex `CodeHighlighter`. Resolved internally so + /// the default does not reference an internal type across the module boundary. + public init(fallback: (any CodeHighlighting)? = nil) { + self.fallback = fallback ?? CodeHighlighter() + } + + public func highlight(code: String, language: String?) -> String { + guard let language = language?.lowercased().trimmingCharacters(in: .whitespaces), + language == "swift" else { + return self.fallback.highlight(code: code, language: language) + } + return self.highlightSwift(code) + } + + // MARK: - Swift highlighting + + /// Parses `code`, merges the base syntactic classification with the per-token role refinement, + /// and emits one HTML fragment of escaped, role-tagged spans. + func highlightSwift(_ code: String) -> String { + let bytes = Array(code.utf8) + let count = bytes.count + guard count > 0 else { return "" } + + let tree = Parser.parse(source: code) + let roleMap = SwiftTokenRoleClassifier.classify(tree) + + var output = "" + var cursor = 0 + + // The classification stream is ordered and non-overlapping. Any byte range it does not cover + // (whitespace, punctuation classified `.none`) is emitted as plain escaped text via the gap + // fill below, so the full source is always reproduced exactly once. + for classified in tree.classifications { + let lower = classified.range.lowerBound.utf8Offset + let upper = classified.range.upperBound.utf8Offset + guard lower < upper, lower >= cursor, upper <= count else { continue } + + if lower > cursor { + output += Self.escapedSlice(bytes, from: cursor, to: lower) + } + + let text = Self.escapedSlice(bytes, from: lower, to: upper) + if let role = self.role(forKind: classified.kind, offset: lower, bytes: bytes, from: lower, to: upper, roleMap: roleMap) { + output += "\(text)" + } else { + output += text + } + cursor = upper + } + + if cursor < count { + output += Self.escapedSlice(bytes, from: cursor, to: count) + } + return output + } + + /// Resolves the final `sk-tok-*` role class for one classified range: a visitor refinement when + /// present, otherwise a direct mapping of the base `SyntaxClassification`. + private func role( + forKind kind: SyntaxClassification, + offset: Int, + bytes: [UInt8], + from lower: Int, + to upper: Int, + roleMap: [Int: String] + ) -> String? { + if let refined = roleMap[offset] { + return refined + } + switch kind { + case .keyword, .ifConfigDirective: + return "keyword" + case .type: + // A token the base pass already knows sits in TYPE position (`View` in `: View`, `Sticker` + // in `[Sticker]`). Every capitalized type gets the single `type` role, matching the regex + // highlighter and the expression-visitor types above. + return "type" + case .stringLiteral, .regexLiteral: + return "string" + case .integerLiteral, .floatLiteral: + return "number" + case .attribute: + return "attribute" + case .lineComment, .blockComment, .docLineComment, .docBlockComment: + return "comment" + case .operator: + return "operator" + case .argumentLabel: + return "label" + case .dollarIdentifier: + // `$0`, `$1` – anonymous closure arguments, i.e. value references. + return "variable" + case .identifier: + // An identifier the role visitor did not refine. Mirror the regex highlighter's only + // heuristic – a capitalized identifier is a `type`; leave anything else uncolored rather + // than guess. + let text = String(decoding: bytes[lower..` span. + private static func escapedSlice(_ bytes: [UInt8], from lower: Int, to upper: Int) -> String { + HTMLEscaping.escape(String(decoding: bytes[lower.. [Int: String] { + let visitor = SwiftTokenRoleClassifier(viewMode: .sourceAccurate) + visitor.walk(tree) + return visitor.roles + } + + // MARK: - Helpers + + private func byteOffset(of token: TokenSyntax) -> Int { + token.positionAfterSkippingLeadingTrivia.utf8Offset + } + + /// Records `role` for `token`. `overwrite: false` only fills an offset the visitor has not yet + /// classified, so a specific parent assignment is never clobbered by the generic fallback. + private func set(_ role: String, at token: TokenSyntax, overwrite: Bool = true) { + let offset = self.byteOffset(of: token) + if overwrite || self.roles[offset] == nil { + self.roles[offset] = role + } + } + + private func isIdentifierToken(_ token: TokenSyntax) -> Bool { + if case .identifier = token.tokenKind { return true } + return false + } + + private func startsUppercased(_ text: String) -> Bool { + guard let first = text.first else { return false } + return first.isUppercase + } + + // MARK: - Calls + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + // The callee identifies what kind of call this is. A capitalized callee is a type initializer + // (`ScrollView { … }`, `ForEach(…)`, `StickerListItemView(…)`) and takes the `type` role. A + // lowercase callee is a free-function call (`print(…)`). A member callee (`view.swipeActions(…)`) + // is left to the member-access visitor so the member keeps its `member` role whether or not it + // is called. + if let reference = node.calledExpression.as(DeclReferenceExprSyntax.self), + self.isIdentifierToken(reference.baseName) { + let role = self.startsUppercased(reference.baseName.text) ? "type" : "call" + self.set(role, at: reference.baseName) + } + return .visitChildren + } + + // MARK: - Member access + + override func visit(_ node: MemberAccessExprSyntax) -> SyntaxVisitorContinueKind { + // The member name in `base.member` (or a leading-dot member like `.trailing`). Classified + // `member` regardless of whether it is then called, matching Xcode where `.swipeActions` + // stays the default member color. + let name = node.declName.baseName + if self.isIdentifierToken(name) { + self.set("member", at: name) + } + return .visitChildren + } + + // MARK: - Generic value references + + override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { + // Reached for every declaration reference. Callees and member names were already assigned + // by their parent above, so only fill the ones still unset: a lowercase reference is a value + // (`stickers`, `sticker`) → the headline green `variable`; a capitalized one is a bare type + // reference (`Color.red`'s `Color`, a metatype) → the `type` role. + let token = node.baseName + guard self.isIdentifierToken(token) else { return .visitChildren } + let offset = self.byteOffset(of: token) + guard self.roles[offset] == nil else { return .visitChildren } + self.roles[offset] = self.startsUppercased(token.text) ? "type" : "variable" + return .visitChildren + } + + // MARK: - Parameter and binding declarations + + override func visit(_ node: ClosureShorthandParameterSyntax) -> SyntaxVisitorContinueKind { + // The `sticker` in `{ sticker in … }`. + self.set("param", at: node.name) + return .visitChildren + } + + override func visit(_ node: ClosureParameterSyntax) -> SyntaxVisitorContinueKind { + // The name in a typed closure parameter `{ (sticker: Sticker) in … }`. + self.set("param", at: node.firstName) + return .visitChildren + } + + override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { + // The internal binding name of a function parameter (`func add(to list: …)` → `list`). The + // external label (`firstName`) is already classified `argumentLabel` by the base pass. + if let secondName = node.secondName { + self.set("param", at: secondName) + } + return .visitChildren + } + + override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind { + // A value binding name: `let name = …`, `for sticker in …`, `case let .some(value)`. Colored + // like a value reference (green) since it names a value the reader will then refer to. + self.set("variable", at: node.identifier) + return .visitChildren + } + + // MARK: - Boolean and nil literals + + override func visit(_ node: BooleanLiteralExprSyntax) -> SyntaxVisitorContinueKind { + // `true` / `false` arrive as keyword tokens; lift them into their own `boolean` role. + self.set("boolean", at: node.literal) + return .visitChildren + } + + override func visit(_ node: NilLiteralExprSyntax) -> SyntaxVisitorContinueKind { + // `nil` is grouped with the booleans per the palette spec. + self.set("boolean", at: node.nilKeyword) + return .visitChildren + } +} diff --git a/Tests/SiteKitSyntaxHighlightingTests/SwiftSyntaxHighlighterTests.swift b/Tests/SiteKitSyntaxHighlightingTests/SwiftSyntaxHighlighterTests.swift new file mode 100644 index 0000000..02332c6 --- /dev/null +++ b/Tests/SiteKitSyntaxHighlightingTests/SwiftSyntaxHighlighterTests.swift @@ -0,0 +1,228 @@ +import Testing +import SiteKit +@testable import SiteKitSyntaxHighlighting + +@Suite("SwiftSyntaxHighlighter") +struct SwiftSyntaxHighlighterTests { + + private let highlighter = SwiftSyntaxHighlighter() + + /// Convenience: assert that `text` is wrapped in exactly the `sk-tok-` span. + private func expectSpan(_ html: String, role: String, text: String, sourceLocation: SourceLocation = #_sourceLocation) { + #expect(html.contains("\(text)"), "expected \(text) as sk-tok-\(role)", sourceLocation: sourceLocation) + } + + // MARK: - Role classification (the semantic-near roles the regex highlighter cannot produce) + + @Test("A value reference is classified variable (the green-variables headline)") + func variableReferenceIsGreen() { + // `stickers` is a DeclReferenceExpr that is neither a callee nor a member base, so it must be + // classified `variable` (rendered green) – the headline requirement of this whole change. + let code = "ForEach(stickers) { sticker in row(sticker) }" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "variable", text: "stickers") + } + + @Test("A capitalized callee is classified type (one class for all type refs)") + func capitalizedCalleeIsType() { + let code = "ScrollView { Text(title) }" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "type", text: "ScrollView") + self.expectSpan(result, role: "type", text: "Text") + } + + @Test("A lowercase free-function callee is classified call") + func lowercaseCalleeIsCall() { + let code = "print(message)" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "call", text: "print") + } + + @Test("A member access name is classified member") + func memberAccessIsMember() { + let code = "view.swipeActions(edge: .trailing) { }" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "member", text: "swipeActions") + self.expectSpan(result, role: "member", text: "trailing") + } + + @Test("A closure parameter binding is classified param") + func closureParameterIsParam() { + // The binding `sticker` (declaration) is `param`; its later use is `variable`. Both occur, at + // different offsets, so the same name carries two roles depending on position. + let code = "ForEach(stickers) { sticker in StickerRow(sticker) }" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "param", text: "sticker") + self.expectSpan(result, role: "variable", text: "sticker") + } + + @Test("Boolean and nil literals are classified boolean") + func booleanAndNilAreBoolean() { + let code = "let flag = true\nlet value = nil" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "boolean", text: "true") + self.expectSpan(result, role: "boolean", text: "nil") + } + + @Test("An argument label is classified label") + func argumentLabelIsLabel() { + let code = "LazyVStack(spacing: 12) { }" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "label", text: "spacing") + } + + @Test("A let-binding name is classified variable") + func letBindingIsVariable() { + let code = "let count = 3" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "variable", text: "count") + } + + // MARK: - Capitalized types all map to the single type role + + @Test("A project-defined type is classified type, just like an SDK type") + func projectDefinedTypeIsType() { + // With the framework-vs-project split removed, every capitalized type – SDK or project-defined – + // takes the single `type` role, exactly like the regex highlighter. + let code = "StickerListItemView(sticker: sticker)" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "type", text: "StickerListItemView") + } + + @Test("A type in TYPE position is classified type (annotation, not just an initializer)") + func typePositionIsType() { + // The base SwiftIDEUtils pass classifies these as `.type` (not via the expression visitors), so + // the base mapping must also produce `type`: both `View` and the project's `Sticker`. + let code = "struct Row: View { var items: [Sticker] = [] }" + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "type", text: "View") + self.expectSpan(result, role: "type", text: "Sticker") + } + + @Test("The swipe-actions block classifies every capitalized type as type and keeps values green") + func swipeBlockTypesAndGreenValues() { + // The representative SwiftUI swipe-actions block: SDK and project types alike read as `type`, + // and the value references stay the headline green `variable`. + let code = """ + ScrollView { + LazyVStack { + ForEach(stickers) { sticker in + StickerListItemView(sticker: sticker) + .swipeActions { + DeleteButton(title: "Delete") { } + } + } + } + } + """ + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "type", text: "ScrollView") + self.expectSpan(result, role: "type", text: "LazyVStack") + self.expectSpan(result, role: "type", text: "ForEach") + self.expectSpan(result, role: "type", text: "StickerListItemView") + self.expectSpan(result, role: "type", text: "DeleteButton") + self.expectSpan(result, role: "variable", text: "stickers") + self.expectSpan(result, role: "variable", text: "sticker") + } + + // MARK: - Roles taken straight from the base SwiftIDEUtils classification + + @Test("Keywords, strings, numbers, comments, and attributes use the base classification") + func baseClassificationRoles() { + let code = """ + // a leading comment + @State private var amount = 42 + let label = "hello" + """ + let result = self.highlighter.highlight(code: code, language: "swift") + self.expectSpan(result, role: "keyword", text: "var") + self.expectSpan(result, role: "keyword", text: "let") + self.expectSpan(result, role: "number", text: "42") + self.expectSpan(result, role: "string", text: ""hello"") + self.expectSpan(result, role: "comment", text: "// a leading comment") + self.expectSpan(result, role: "attribute", text: "@State") + } + + // MARK: - Fragment tolerance + + @Test("A partial fragment (no enclosing type) is classified without crashing") + func fragmentToleranceDegradesGracefully() { + // DocC code blocks are routinely partial – a bare statement list, an elided body. SwiftParser + // is error-tolerant and still yields a tree, so classification must produce best-effort roles + // (here the green variable) rather than throwing or returning empty. + let code = "ForEach(stickers) { sticker in\n StickerListItemView(sticker)\n .swipeActions" + let result = self.highlighter.highlight(code: code, language: "swift") + #expect(!result.isEmpty) + self.expectSpan(result, role: "variable", text: "stickers") + // `StickerListItemView` is a capitalized type → the single `type` role. + self.expectSpan(result, role: "type", text: "StickerListItemView") + } + + @Test("Empty input returns empty output") + func emptyInput() { + #expect(self.highlighter.highlight(code: "", language: "swift") == "") + } + + // MARK: - HTML escaping safety + + @Test("Angle brackets and ampersands are escaped exactly once") + func htmlEscaping() { + let code = "let a: Array = []\nlet b = x && y" + let result = self.highlighter.highlight(code: code, language: "swift") + #expect(result.contains("<")) + #expect(result.contains(">")) + #expect(result.contains("&")) + #expect(!result.contains("&amp;")) + #expect(!result.contains("&lt;")) + } + + @Test("Reproduces the full source text once spans are stripped") + func reproducesSourceExactly() { + // No byte of the source may be dropped or duplicated: stripping every span tag and unescaping + // must return the original code verbatim (the gap-fill emits untouched regions as plain text). + let code = "struct S {\n var n = 1 // note\n func f() { print(n) }\n}" + let result = self.highlighter.highlight(code: code, language: "swift") + let stripped = result + .replacing(try! Regex(""), with: "") + .replacing("", with: "") + #expect(HTMLEscaping.unescape(stripped) == code) + } + + // MARK: - Non-Swift fallback + + @Test("Non-Swift languages delegate to the regex fallback") + func nonSwiftDelegatesToFallback() { + let code = "def greet():\n return None" + let viaSyntax = self.highlighter.highlight(code: code, language: "python") + let viaRegex = CodeHighlighter().highlight(code: code, language: "python") + #expect(viaSyntax == viaRegex) + } + + @Test("A nil language delegates to the fallback (plain escaped text)") + func nilLanguageDelegatesToFallback() { + let code = "let x = 1" + let viaSyntax = self.highlighter.highlight(code: code, language: nil) + let viaRegex = CodeHighlighter().highlight(code: code, language: nil) + #expect(viaSyntax == viaRegex) + #expect(!viaSyntax.contains("ForEach(stickers) { s in row(s) }
" + let result = self.highlighter.applyToBodyHTML(html, defaultLanguage: nil) + #expect(result.contains("sk-docc-highlight")) + self.expectSpan(result, role: "variable", text: "stickers") + } + + @Test("applyToBodyHTML keeps a // inside a pre-escaped string out of comments") + func applyToBodyHTMLStringWithSlashes() { + // DocC hands already-escaped content; the parser must treat the URL's // as part of the string. + let html = "
let u = "https://x"
" + let result = self.highlighter.applyToBodyHTML(html, defaultLanguage: nil) + #expect(result.contains("sk-tok-string")) + #expect(!result.contains("sk-tok-comment")) + } +} diff --git a/Tests/SiteKitTests/BaseURLOverrideTests.swift b/Tests/SiteKitTests/BaseURLOverrideTests.swift index 9a6dc62..cbd37d8 100644 --- a/Tests/SiteKitTests/BaseURLOverrideTests.swift +++ b/Tests/SiteKitTests/BaseURLOverrideTests.swift @@ -39,8 +39,8 @@ struct BaseURLOverrideTests { @Test("Scheme-less value throws instead of silently building broken absolute URLs") func schemelessValue() { - #expect(throws: BaseURLOverrideError.notAnAbsoluteHTTPURL("wwdcnotes.fline.dev")) { - try SiteBuilder.baseURLOverride(from: ["Site", "build", "--base-url", "wwdcnotes.fline.dev"]) + #expect(throws: BaseURLOverrideError.notAnAbsoluteHTTPURL("wwdcnotes.com")) { + try SiteBuilder.baseURLOverride(from: ["Site", "build", "--base-url", "wwdcnotes.com"]) } }