From 69a0de8b7fa7898a5c14acf12bd1149258a4da89 Mon Sep 17 00:00:00 2001 From: Becca Royal-Gordon Date: Thu, 11 Dec 2025 15:39:34 -0800 Subject: [PATCH] =?UTF-8?q?Revert=20"Minimally=20integrate=20per-page=20HT?= =?UTF-8?q?ML=20content=20into=20each=20"index.html"=20file=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8b49f1baf32e7db412e1da2f8e209f25758e6a55. --- Package.swift | 1 - Sources/SwiftDocC/CMakeLists.txt | 4 - .../ConvertActionConverter.swift | 31 +- .../ConvertOutputConsumer.swift | 2 + .../Rendering/HTML/HTMLContentConsumer.swift | 40 --- .../Model/Rendering/HTML/HTMLRenderer.swift | 336 ------------------ .../SymbolGraphCreation.swift | 17 +- .../Actions/Convert/ConvertAction.swift | 1 - .../FileWritingHTMLContentConsumer.swift | 119 ------- .../JSONEncodingRenderNodeWriter.swift | 63 ++-- Sources/SwiftDocCUtilities/CMakeLists.txt | 1 - ...recatedDiagnosticsDigestWarningTests.swift | 2 - .../Indexing/ExternalRenderNodeTests.swift | 1 - .../TestRenderNodeOutputConsumer.swift | 1 - .../FileWritingHTMLContentConsumerTests.swift | 318 ----------------- .../MergeActionTests.swift | 2 +- 16 files changed, 39 insertions(+), 900 deletions(-) delete mode 100644 Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift delete mode 100644 Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift delete mode 100644 Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift delete mode 100644 Tests/SwiftDocCUtilitiesTests/FileWritingHTMLContentConsumerTests.swift diff --git a/Package.swift b/Package.swift index 86249f0bf4..f5162a432b 100644 --- a/Package.swift +++ b/Package.swift @@ -44,7 +44,6 @@ let package = Package( name: "SwiftDocC", dependencies: [ .target(name: "DocCCommon"), - .target(name: "DocCHTML"), .product(name: "Markdown", package: "swift-markdown"), .product(name: "SymbolKit", package: "swift-docc-symbolkit"), .product(name: "CLMDB", package: "swift-lmdb"), diff --git a/Sources/SwiftDocC/CMakeLists.txt b/Sources/SwiftDocC/CMakeLists.txt index ce74f882ea..c5e6ef0e26 100644 --- a/Sources/SwiftDocC/CMakeLists.txt +++ b/Sources/SwiftDocC/CMakeLists.txt @@ -175,8 +175,6 @@ add_library(SwiftDocC Model/Rendering/Diffing/Differences.swift Model/Rendering/Diffing/RenderNode+Diffable.swift Model/Rendering/DocumentationContentRenderer.swift - Model/Rendering/HTML/HTMLContentConsumer.swift - Model/Rendering/HTML/HTMLRenderer.swift Model/Rendering/LinkTitleResolver.swift "Model/Rendering/Navigation Tree/RenderHierarchy.swift" "Model/Rendering/Navigation Tree/RenderHierarchyChapter.swift" @@ -467,8 +465,6 @@ add_library(SwiftDocC Utility/Version.swift) target_link_libraries(SwiftDocC PRIVATE DocCCommon) -target_link_libraries(SwiftDocC PRIVATE - DocCHTML) target_link_libraries(SwiftDocC PUBLIC SwiftMarkdown::Markdown DocC::SymbolKit diff --git a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift index d82fcce906..c18d64004b 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift @@ -26,7 +26,6 @@ package enum ConvertActionConverter { /// - Parameters: /// - context: The context that the bundle is a part of. /// - outputConsumer: The consumer that the conversion passes outputs of the conversion to. - /// - htmlContentConsumer: The consumer for HTML content that the conversion produces, or `nil` if the conversion shouldn't produce any HTML content. /// - sourceRepository: The source repository where the documentation's sources are hosted. /// - emitDigest: Whether the conversion should pass additional metadata output––such as linkable entities information, indexing information, or asset references by asset type––to the consumer. /// - documentationCoverageOptions: The level of experimental documentation coverage information that the conversion should pass to the consumer. @@ -34,7 +33,6 @@ package enum ConvertActionConverter { package static func convert( context: DocumentationContext, outputConsumer: some ConvertOutputConsumer & ExternalNodeConsumer, - htmlContentConsumer: (any HTMLContentConsumer)?, sourceRepository: SourceRepository?, emitDigest: Bool, documentationCoverageOptions: DocumentationCoverageOptions @@ -105,7 +103,7 @@ package enum ConvertActionConverter { let renderSignpostHandle = signposter.beginInterval("Render", id: signposter.makeSignpostID(), "Render \(context.knownPages.count) pages") - var conversionProblems: [Problem] = context.knownPages.concurrentPerform { [htmlContentConsumer] identifier, results in + var conversionProblems: [Problem] = context.knownPages.concurrentPerform { identifier, results in // If cancelled skip all concurrent conversion work in this block. guard !Task.isCancelled else { return } @@ -113,19 +111,7 @@ package enum ConvertActionConverter { autoreleasepool { do { let entity = try context.entity(with: identifier) - - if let htmlContentConsumer { - var renderer = HTMLRenderer(reference: identifier, context: context, goal: .conciseness) - - if let symbol = entity.semantic as? Symbol { - let renderedPageInfo = renderer.renderSymbol(symbol) - try htmlContentConsumer.consume(pageInfo: renderedPageInfo, forPage: identifier) - } else if let article = entity.semantic as? Article { - let renderedPageInfo = renderer.renderArticle(article) - try htmlContentConsumer.consume(pageInfo: renderedPageInfo, forPage: identifier) - } - } - + guard let renderNode = converter.renderNode(for: entity) else { // No render node was produced for this entity, so just skip it. return @@ -261,16 +247,3 @@ package enum ConvertActionConverter { return conversionProblems } } - -private extension HTMLContentConsumer { - func consume(pageInfo: HTMLRenderer.RenderedPageInfo, forPage reference: ResolvedTopicReference) throws { - try consume( - mainContent: pageInfo.content, - metadata: ( - title: pageInfo.metadata.title, - description: pageInfo.metadata.plainDescription - ), - forPage: reference - ) - } -} diff --git a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift index 288e84334e..f5e1ebd432 100644 --- a/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift +++ b/Sources/SwiftDocC/Infrastructure/ConvertOutputConsumer.swift @@ -8,6 +8,8 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ +import Foundation + /// A consumer for output produced by a documentation conversion. /// /// Types that conform to this protocol manage what to do with documentation conversion products, for example persist them to disk diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift deleted file mode 100644 index 877e6932dd..0000000000 --- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLContentConsumer.swift +++ /dev/null @@ -1,40 +0,0 @@ -/* - This source file is part of the Swift.org open source project - - Copyright (c) 2025 Apple Inc. and the Swift project authors - Licensed under Apache License v2.0 with Runtime Library Exception - - See https://swift.org/LICENSE.txt for license information - See https://swift.org/CONTRIBUTORS.txt for Swift project authors -*/ - -#if canImport(FoundationXML) -// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530) -package import FoundationXML -#else -package import Foundation -#endif - -/// A consumer for HTML content produced during documentation conversion. -package protocol HTMLContentConsumer { - // One reason that this is its own protocol, rather than an extension of ConvertOutputConsumer, is so that we can avoid exposing `XMLNode` in any public API. - // That way, we are completely free to replace the entire internal HTML rendering implementation with something else in the future, without breaking API. - - /// Consumes the HTML content and metadata for a given page. - /// - /// The content and metadata doesn't make up a full valid HTML page. - /// It's the consumers responsibility to insert the information into a template or skeletal structure to produce a valid HTML file for each page. - /// - /// - Parameters: - /// - mainContent: The contents for this page as an XHTML node. - /// - metadata: Metadata information (title and description) about this page. - /// - reference: The resolved topic reference that identifies this page. - func consume( - mainContent: XMLNode, - metadata: ( - title: String, - description: String? - ), - forPage reference: ResolvedTopicReference - ) throws -} diff --git a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift b/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift deleted file mode 100644 index fae8cddcb5..0000000000 --- a/Sources/SwiftDocC/Model/Rendering/HTML/HTMLRenderer.swift +++ /dev/null @@ -1,336 +0,0 @@ -/* - This source file is part of the Swift.org open source project - - Copyright (c) 2025 Apple Inc. and the Swift project authors - Licensed under Apache License v2.0 with Runtime Library Exception - - See https://swift.org/LICENSE.txt for license information - See https://swift.org/CONTRIBUTORS.txt for Swift project authors -*/ - -#if canImport(FoundationXML) -// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530) -import FoundationXML -import FoundationEssentials -#else -import Foundation -#endif -import DocCHTML -import Markdown -import SymbolKit - -/// A link provider that provider structured information about resolved links and assets to the underlying HTML renderer. -private struct ContextLinkProvider: LinkProvider { - let reference: ResolvedTopicReference - let context: DocumentationContext - let goal: RenderGoal - - func element(for url: URL) -> LinkedElement? { - guard url.scheme == "doc", - let rawBundleID = url.host, - // TODO: Support returning information about external pages (rdar://165912415) - let node = context.documentationCache[ResolvedTopicReference(bundleID: .init(rawValue: rawBundleID), path: url.path, fragment: url.fragment, sourceLanguage: .swift /* The reference's language doesn't matter */)] - else { - return nil - } - - // A helper function that transforms SymbolKit fragments into renderable identifier/decorator fragments - func convert(_ fragments: [SymbolGraph.Symbol.DeclarationFragments.Fragment]) -> [LinkedElement.SymbolNameFragment] { - func convert(kind: SymbolGraph.Symbol.DeclarationFragments.Fragment.Kind) -> LinkedElement.SymbolNameFragment.Kind { - switch kind { - case .identifier, .externalParameter: .identifier - default: .decorator - } - } - guard var current = fragments.first.map({ LinkedElement.SymbolNameFragment(text: $0.spelling, kind: convert(kind: $0.kind)) }) else { - return [] - } - - // Join together multiple fragments of the same identifier/decorator kind to produce a smaller output. - var result: [LinkedElement.SymbolNameFragment] = [] - for fragment in fragments.dropFirst() { - let kind = convert(kind: fragment.kind) - if kind == current.kind { - current.text += fragment.spelling - } else { - result.append(current) - current = .init(text: fragment.spelling, kind: kind) - } - } - result.append(current) - return result - } - - let subheadings: LinkedElement.Subheadings = if let symbol = node.semantic as? Symbol { - switch symbol.subHeadingVariants.values(goal: goal) { - case .single(let subHeading): - .single(.symbol(convert(subHeading))) - case .languageSpecific(let subHeadings): - .languageSpecificSymbol(subHeadings.mapValues(convert)) - case .empty: - // This shouldn't happen but because of a shortcoming in the API design of `DocumentationDataVariants`, it can't be guaranteed. - .single(.symbol([])) - } - } else { - .single(.conceptual(node.name.plainText)) - } - - return .init( - path: Self.filePath(for: node.reference), - names: node.makeNames(goal: goal), - subheadings: subheadings, - abstract: (node.semantic as? (any Abstracted))?.abstract - ) - } - - func pathForSymbolID(_ usr: String) -> URL? { - context.localOrExternalReference(symbolID: usr).map { - Self.filePath(for: $0) - } - } - - func assetNamed(_ assetName: String) -> LinkedAsset? { - guard let asset = context.resolveAsset(named: assetName, in: reference) else { - // The context - return nil - } - - var files = [LinkedAsset.ColorStyle: [Int: URL]]() - for (traits, url) in asset.variants { - let scale = (traits.displayScale ?? .standard).scaleFactor - - files[traits.userInterfaceStyle == .dark ? .dark : .light, default: [:]][scale] = url - } - - return .init(files: files) - } - - func fallbackLinkText(linkString: String) -> String { - // For unresolved links, especially to symbols, prefer to display only the the last link component without its disambiguation - PathHierarchy.PathParser.parse(path: linkString).components.last.map { String($0.name) } ?? linkString - } - - static func filePath(for reference: ResolvedTopicReference) -> URL { - reference.url.withoutHostAndPortAndScheme().appendingPathComponent("index.html") - } -} - -// MARK: HTML Renderer - -/// A type that renders documentation pages into semantic HTML elements. -struct HTMLRenderer { - let reference: ResolvedTopicReference - let context: DocumentationContext - let goal: RenderGoal - - private let renderer: MarkdownRenderer - - init(reference: ResolvedTopicReference, context: DocumentationContext, goal: RenderGoal) { - self.reference = reference - self.context = context - self.goal = goal - self.renderer = MarkdownRenderer( - path: ContextLinkProvider.filePath(for: reference), - goal: goal, - linkProvider: ContextLinkProvider(reference: reference, context: context, goal: goal) - ) - } - - /// Information about a rendered page - struct RenderedPageInfo { - /// The HTML content of the page as an XMLNode hierarchy. - /// - /// The string representation of this node hierarchy is intended to be inserted _somewhere_ inside the `` HTML element. - /// It _doesn't_ include a page header, footer, navigator, etc. and may be an insufficient representation of the "entire" page - var content: XMLNode - /// The title and description/abstract of the page. - var metadata: Metadata - /// Meta information about the page that belongs in the HTML `` element. - struct Metadata { - /// The plain text title of this page, suitable as content for the HTML `` element. - var title: String - /// The plain text description/abstract of this page, suitable a data for a `<meta>` element for sharing purposes. - var plainDescription: String? - } - } - - mutating func renderArticle(_ article: Article) -> RenderedPageInfo { - let node = context.documentationCache[reference]! - - let main = XMLElement(name: "main") - let articleElement = XMLElement(name: "article") - main.addChild(articleElement) - - let hero = XMLElement(name: "section") - articleElement.addChild(hero) - - // Title - hero.addChild( - .element(named: "h1", children: [.text(node.name.plainText)]) - ) - - // Abstract - if let abstract = article.abstract { - let paragraph = renderer.visit(abstract) as! XMLElement - if goal == .richness { - paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) - } - hero.addChild(paragraph) - } - - return RenderedPageInfo( - content: goal == .richness ? main : articleElement, - metadata: .init( - title: article.title?.plainText ?? node.name.plainText, - plainDescription: article.abstract?.plainText - ) - ) - } - - mutating func renderSymbol(_ symbol: Symbol) -> RenderedPageInfo { - let main = XMLElement(name: "main") - let articleElement = XMLElement(name: "article") - main.addChild(articleElement) - - let hero = XMLElement(name: "section") - articleElement.addChild(hero) - - // Title - switch symbol.titleVariants.values(goal: goal) { - case .single(let title): - hero.addChild( - .element(named: "h1", children: renderer.wordBreak(symbolName: title)) - ) - case .languageSpecific(let languageSpecificTitles): - for (language, languageSpecificTitle) in languageSpecificTitles.sorted(by: { $0.key < $1.key }) { - hero.addChild( - .element(named: "h1", children: renderer.wordBreak(symbolName: languageSpecificTitle), attributes: ["class": "\(language.id)-only"]) - ) - } - case .empty: - // This shouldn't happen but because of a shortcoming in the API design of `DocumentationDataVariants`, it can't be guaranteed. - hero.addChild( - .element(named: "h1", children: renderer.wordBreak(symbolName: symbol.title /* This is internally force unwrapped */)) - ) - } - - // Abstract - if let abstract = symbol.abstract { - let paragraph = renderer.visit(abstract) as! XMLElement - if goal == .richness { - paragraph.addAttribute(XMLNode.attribute(withName: "id", stringValue: "abstract") as! XMLNode) - } - hero.addChild(paragraph) - } - - return RenderedPageInfo( - content: goal == .richness ? main : articleElement, - metadata: .init( - title: symbol.title, - plainDescription: symbol.abstract?.plainText - ) - ) - } - - // TODO: As a future enhancement, add another layer on top of this that creates complete HTML pages (both `<head>` and `<body>`) (rdar://165912669) -} - -// MARK: Helpers - -// Note; this isn't a Comparable conformance so that it can remain private to this file. -private extension DocumentationDataVariantsTrait { - static func < (lhs: DocumentationDataVariantsTrait, rhs: DocumentationDataVariantsTrait) -> Bool { - if let lhs = lhs.sourceLanguage { - if let rhs = rhs.sourceLanguage { - return lhs < rhs - } - return true // nil is after anything - } - return false // nil is after anything - } -} - -private extension XMLElement { - func addChildren(_ nodes: [XMLNode]) { - for node in nodes { - addChild(node) - } - } -} - -private extension DocumentationNode { - func makeNames(goal: RenderGoal) -> LinkedElement.Names { - switch name { - case .conceptual(let title): - // This node has a single "conceptual" name. - // It could either be an article or a symbol with an authored `@DisplayName`. - .single(.conceptual(title)) - case .symbol(let nodeTitle): - if let symbol = semantic as? Symbol { - symbol.makeNames(goal: goal, fallbackTitle: nodeTitle) - } else { - // This node has a symbol name, but for some reason doesn't have a symbol semantic. - // That's a bit strange and unexpected, but we can still make a single name for it. - .single(.symbol(nodeTitle)) - } - } - } -} - -private extension Symbol { - func makeNames(goal: RenderGoal, fallbackTitle: String) -> LinkedElement.Names { - switch titleVariants.values(goal: goal) { - case .single(let title): - .single(.symbol(title)) - case .languageSpecific(let titles): - .languageSpecificSymbol(titles) - case .empty: - // This shouldn't happen but because of a shortcoming in the API design of `DocumentationDataVariants`, it can't be guaranteed. - .single(.symbol(fallbackTitle)) - } - } -} - -private enum VariantValues<Value> { - case single(Value) - case languageSpecific([SourceLanguage: Value]) - // This is necessary because of a shortcoming in the API design of `DocumentationDataVariants`. - case empty -} - -// Both `DocumentationDataVariants` and `VariantCollection` are really hard to work with correctly and neither offer a good API that both: -// - Makes a clear distinction between when a value will always exist and when the "values" can be empty. -// - Allows the caller to iterate over all the values. -// TODO: Design and implement a better solution for representing language specific variations of a value (rdar://166211961) -private extension DocumentationDataVariants where Variant: Equatable { - func values(goal: RenderGoal) -> VariantValues<Variant> { - guard let primaryValue = firstValue else { - return .empty - } - - guard goal == .richness else { - // On the rendered page, language specific symbol information _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML. - // So that tools don't need to filter out language specific information themselves, include only the primary language's value. - return .single(primaryValue) - } - - let values = allValues - guard allValues.count > 1 else { - // Return a single value to simplify the caller's code - return .single(primaryValue) - } - - // Check if the variants has any language-specific values (that are _actually_ different from the primary value) - if values.contains(where: { _, value in value != primaryValue }) { - // There are multiple distinct values - return .languageSpecific([SourceLanguage: Variant]( - values.map { trait, value in - (trait.sourceLanguage ?? .swift, value) - }, uniquingKeysWith: { _, new in new } - )) - } else { - // There are multiple values, but the're all the same - return .single(primaryValue) - } - } -} diff --git a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift index 8bd48ead94..6a15e4596b 100644 --- a/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift +++ b/Sources/SwiftDocCTestUtilities/SymbolGraphCreation.swift @@ -90,7 +90,6 @@ extension XCTestCase { location: (position: SymbolGraph.LineList.SourceRange.Position, url: URL)? = (defaultSymbolPosition, defaultSymbolURL), signature: SymbolGraph.Symbol.FunctionSignature? = nil, availability: [SymbolGraph.Symbol.Availability.AvailabilityItem]? = nil, - declaration: [SymbolGraph.Symbol.DeclarationFragments.Fragment]? = nil, otherMixins: [any Mixin] = [] ) -> SymbolGraph.Symbol { precondition(!pathComponents.isEmpty, "Need at least one path component to name the symbol") @@ -105,24 +104,10 @@ extension XCTestCase { if let availability { mixins.append(SymbolGraph.Symbol.Availability(availability: availability)) } - if let declaration { - mixins.append(SymbolGraph.Symbol.DeclarationFragments(declarationFragments: declaration)) - } - - let names = if let declaration { - SymbolGraph.Symbol.Names( - title: pathComponents.last!, // Verified above to exist - navigator: declaration, - subHeading: declaration, - prose: nil - ) - } else { - makeSymbolNames(name: pathComponents.last!) // Verified above to exist - } return SymbolGraph.Symbol( identifier: SymbolGraph.Symbol.Identifier(precise: id, interfaceLanguage: language.id), - names: names, + names: makeSymbolNames(name: pathComponents.last!), pathComponents: pathComponents, docComment: docComment.map { makeLineList( diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index 62d2baf744..b6c98ecedd 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift @@ -320,7 +320,6 @@ public struct ConvertAction: AsyncAction { try ConvertActionConverter.convert( context: context, outputConsumer: outputConsumer, - htmlContentConsumer: nil, sourceRepository: sourceRepository, emitDigest: emitDigest, documentationCoverageOptions: documentationCoverageOptions diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift deleted file mode 100644 index 2fcaf2eae7..0000000000 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/FileWritingHTMLContentConsumer.swift +++ /dev/null @@ -1,119 +0,0 @@ -/* - This source file is part of the Swift.org open source project - - Copyright (c) 2025 Apple Inc. and the Swift project authors - Licensed under Apache License v2.0 with Runtime Library Exception - - See https://swift.org/LICENSE.txt for license information - See https://swift.org/CONTRIBUTORS.txt for Swift project authors -*/ - -#if canImport(FoundationXML) -// TODO: Consider other HTML rendering options as a future improvement (rdar://165755530) -import FoundationXML -import FoundationEssentials -#else -import Foundation -#endif - -import SwiftDocC -import DocCHTML - -struct FileWritingHTMLContentConsumer: HTMLContentConsumer { - var targetFolder: URL - var fileManager: any FileManagerProtocol - var prettyPrintOutput: Bool - - private struct HTMLTemplate { - var original: String - var contentReplacementRange: Range<String.Index> - var titleReplacementRange: Range<String.Index> - var descriptionReplacementRange: Range<String.Index> - - init(data: Data) throws { - let content = String(decoding: data, as: UTF8.self) - - // ???: Should we parse the content with XMLParser instead? If so, what do we do if it's not valid XHTML? - let noScriptStart = content.utf8.firstRange(of: "<noscript>".utf8)!.upperBound - let noScriptEnd = content.utf8.firstRange(of: "</noscript>".utf8)!.lowerBound - - let titleStart = content.utf8.firstRange(of: "<title>".utf8)!.upperBound - let titleEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound - - let beforeHeadEnd = content.utf8.firstRange(of: "".utf8)!.lowerBound - - original = content - // TODO: If the template doesn't already contain a