Skip to content

Commit 27f3303

Browse files
Add authoring support for @AutomaticArticleSubheading directive (#425)
Adds a new `@AutomaticArticleSubheading` directive that allows for customizing the way an article page’s subheading is autogenerated. `@AutomaticArticleSubheading` gives authors control over the automatic "Overview" subheading that is automatically created above the first paragraph in any articles that don't include an explicitly authored H2 heading. By setting `@AutomaticArticleSubheading` to `disabled` an author can choose to start an article more directly with a paragraph of text or an image instead of always beginning with a subheading. Example: # What's New in SlothCreator @options { @AutomaticArticleSubheading(disabled) } ![A sloth on a tree wearing a fedora.](sloth-fedora) Let's check out what's new in SlothCreator! ... Details: `@AutomaticArticleSubheading` accepts an unnamed parameter containing one of the following: - `enabled`: A subheading will be auto-generated for the page, following Swift-DocC’s current default behavior. Today, this means that (in the absence of some other H2 heading below the summary sentence) an “Overview” subheading will be created for the article. The specifics of this behavior could change in the future; using `enabled` is just a way to opt-in to Swift-DocC’s default and allows for setting a global setting of `disabled` while still enabling the feature on certain pages. - `disabled`: A subheading will not be auto-generated for the page. To include a subheading, the author can explicitly write one. This change is described on the Swift forums here: https://forums.swift.org/t/customizing-the-auto-generated-overview-heading-in-swift-docc-articles/61390 Resolves rdar://101368956
1 parent b9d9ada commit 27f3303

File tree

6 files changed

+195
-10
lines changed

6 files changed

+195
-10
lines changed

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,9 @@ public struct RenderNodeTranslator: SemanticVisitor {
630630
var title: String?
631631
if let first = discussionContent.first, case RenderBlockContent.heading = first {
632632
title = nil
633-
} else {
634-
// For articles hardcode an overview title
633+
} else if shouldCreateAutomaticArticleSubheading(for: documentationNode) {
634+
// For articles hardcode an overview title unless the user explicitly
635+
// opts-out with the `@AutomaticArticleSubheading` directive.
635636
title = "Overview"
636637
}
637638
node.primaryContentSections.append(ContentRenderSection(kind: .content, content: discussionContent, heading: title))
@@ -1077,6 +1078,19 @@ public struct RenderNodeTranslator: SemanticVisitor {
10771078
return shouldCreateAutomaticRoleHeading
10781079
}
10791080

1081+
private func shouldCreateAutomaticArticleSubheading(for node: DocumentationNode) -> Bool {
1082+
let shouldCreateAutomaticArticleSubheading: Bool
1083+
if let automaticSubheadingOption = node.options?.automaticArticleSubheadingBehavior
1084+
?? context.options?.automaticArticleSubheadingBehavior
1085+
{
1086+
shouldCreateAutomaticArticleSubheading = !(automaticSubheadingOption == .disabled)
1087+
} else {
1088+
shouldCreateAutomaticArticleSubheading = true
1089+
}
1090+
1091+
return shouldCreateAutomaticArticleSubheading
1092+
}
1093+
10801094
private func topicsSectionStyle(for node: DocumentationNode) -> RenderNode.TopicsSectionStyle {
10811095
let topicsVisualStyleOption: TopicsVisualStyle.Style
10821096
if let topicsSectionStyleOption = node.options?.topicsVisualStyle
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import Markdown
13+
14+
/// A directive that modifies Swift-DocC's default behavior for automatic subheading generation on
15+
/// article pages.
16+
///
17+
/// By default, articles receive a second-level "Overview" heading unless the author explicitly writes
18+
/// some other H2 heading below the abstract. This allows for opting out of that behavior.
19+
public class AutomaticArticleSubheading: Semantic, AutomaticDirectiveConvertible {
20+
public let originalMarkup: BlockDirective
21+
22+
/// The specified behavior for automatic subheading generation.
23+
@DirectiveArgumentWrapped(name: .unnamed)
24+
public private(set) var behavior: Behavior
25+
26+
/// A behavior for automatic subheading generation.
27+
public enum Behavior: String, CaseIterable, DirectiveArgumentValueConvertible {
28+
/// No subheading should be created for the article.
29+
case disabled
30+
31+
/// An overview subheading should be created containing the article's
32+
/// first paragraph of content.
33+
case enabled
34+
}
35+
36+
static var keyPaths: [String : AnyKeyPath] = [
37+
"behavior" : \AutomaticArticleSubheading._behavior,
38+
]
39+
40+
@available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.")
41+
required init(originalMarkup: BlockDirective) {
42+
self.originalMarkup = originalMarkup
43+
}
44+
}

Sources/SwiftDocC/Semantics/Options/Options.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public class Options: Semantic, AutomaticDirectiveConvertible {
2626
@ChildDirective
2727
public private(set) var _automaticTitleHeading: AutomaticTitleHeading? = nil
2828

29+
@ChildDirective
30+
public private(set) var _automaticArticleSubheading: AutomaticArticleSubheading? = nil
31+
2932
@ChildDirective
3033
public private(set) var _topicsVisualStyle: TopicsVisualStyle? = nil
3134

@@ -39,6 +42,11 @@ public class Options: Semantic, AutomaticDirectiveConvertible {
3942
return _automaticTitleHeading?.behavior
4043
}
4144

45+
/// If given, the authored behavior for automatic article subheading generation.
46+
public var automaticArticleSubheadingBehavior: AutomaticArticleSubheading.Behavior? {
47+
return _automaticArticleSubheading?.behavior
48+
}
49+
4250
/// If given, the authored style for a page's Topics section.
4351
public var topicsVisualStyle: TopicsVisualStyle.Style? {
4452
return _topicsVisualStyle?.style
@@ -58,6 +66,7 @@ public class Options: Semantic, AutomaticDirectiveConvertible {
5866
"_automaticSeeAlso" : \Options.__automaticSeeAlso,
5967
"_automaticTitleHeading" : \Options.__automaticTitleHeading,
6068
"_topicsVisualStyle" : \Options.__topicsVisualStyle,
69+
"_automaticArticleSubheading" : \Options.__automaticArticleSubheading,
6170
]
6271

6372
@available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.")

Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,12 +1860,9 @@ Document
18601860
"""
18611861
)
18621862
}
1863-
1864-
/*
1865-
Asserts if `expectedRoleHeading` does not match the the parsed render node's `roleHeading` after it's parsed.
1866-
Uses 'TestBundle's documentation as a base for compiling, overwriting 'article2' with `content`.
1867-
*/
1868-
private func assertRoleHeadingForArticleInTestBundle(expectedRoleHeading: String?, content: String, file: StaticString = #file, line: UInt = #line) throws {
1863+
1864+
1865+
private func renderNodeForArticleInTestBundle(content: String) throws -> RenderNode {
18691866
// Overwrite the article so we can test the article eyebrow for articles without task groups
18701867
let sourceURL = Bundle.module.url(
18711868
forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")!
@@ -1879,10 +1876,71 @@ Document
18791876
let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/Test-Bundle/article2", sourceLanguage: .swift))
18801877
let article = node.semantic as! Article
18811878
var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: nil)
1882-
let renderNode = translator.visit(article) as! RenderNode
1883-
1879+
return translator.visit(article) as! RenderNode
1880+
}
1881+
1882+
/*
1883+
Asserts if `expectedRoleHeading` does not match the the parsed render node's `roleHeading` after it's parsed.
1884+
Uses 'TestBundle's documentation as a base for compiling, overwriting 'article2' with `content`.
1885+
*/
1886+
private func assertRoleHeadingForArticleInTestBundle(expectedRoleHeading: String?, content: String, file: StaticString = #file, line: UInt = #line) throws {
1887+
let renderNode = try renderNodeForArticleInTestBundle(content: content)
18841888
XCTAssertEqual(expectedRoleHeading, renderNode.metadata.roleHeading, file: (file), line: line)
18851889
}
1890+
1891+
1892+
func testDisablingAutomaticArticleSubheadingGeneration() throws {
1893+
// Assert that by default, articles include an "Overview" heading even if it's not authored.
1894+
do {
1895+
let articleRenderNode = try renderNodeForArticleInTestBundle(
1896+
content: """
1897+
# Article 2
1898+
1899+
This is article 2.
1900+
1901+
This is the article's second paragraph.
1902+
"""
1903+
)
1904+
1905+
let firstContentSection = try XCTUnwrap(
1906+
articleRenderNode.primaryContentSections.first as? ContentRenderSection
1907+
)
1908+
if case let .heading(heading) = firstContentSection.content.first {
1909+
XCTAssertEqual(heading.text, "Overview")
1910+
} else {
1911+
XCTFail("By default an article should receive an autogenerated 'Overview' heading.")
1912+
}
1913+
1914+
XCTAssertEqual(firstContentSection.content.count, 2)
1915+
}
1916+
1917+
// Assert that disabling the automatic behavior with the option directive works as expected.
1918+
do {
1919+
let articleRenderNode = try renderNodeForArticleInTestBundle(
1920+
content: """
1921+
# Article 2
1922+
1923+
@Options {
1924+
@AutomaticArticleSubheading(disabled)
1925+
}
1926+
1927+
This is article 2.
1928+
1929+
This is the second paragraph of the article.
1930+
"""
1931+
)
1932+
1933+
let firstContentSection = try XCTUnwrap(
1934+
articleRenderNode.primaryContentSections.first as? ContentRenderSection
1935+
)
1936+
if case let .paragraph(paragraph) = firstContentSection.content.first {
1937+
XCTAssertEqual(paragraph.inlineContent.first?.plainText, "This is the second paragraph of the article.")
1938+
} else {
1939+
XCTFail("An article with the '@AutomaticArticleSubheading(disabled)' specified should not receive an autogenerated heading.")
1940+
}
1941+
XCTAssertEqual(firstContentSection.content.count, 1)
1942+
}
1943+
}
18861944

18871945
/// Verifies we emit the correct warning for external links in topic task groups.
18881946
func testWarnForExternalLinksInTopicTaskGroups() throws {

Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class DirectiveIndexTests: XCTestCase {
1818
DirectiveIndex.shared.indexedDirectives.keys.sorted(),
1919
[
2020
"Assessments",
21+
"AutomaticArticleSubheading",
2122
"AutomaticSeeAlso",
2223
"AutomaticTitleHeading",
2324
"Chapter",

Tests/SwiftDocCTests/Semantics/Options/OptionsTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ class OptionsTests: XCTestCase {
259259
@AutomaticTitleHeading(pageKind)
260260
@AutomaticSeeAlso(disabled)
261261
@TopicsVisualStyle(detailedGrid)
262+
@AutomaticArticleSubheading(enabled)
262263
}
263264
"""
264265
}
@@ -267,6 +268,7 @@ class OptionsTests: XCTestCase {
267268
XCTAssertEqual(options?.automaticTitleHeadingBehavior, .pageKind)
268269
XCTAssertEqual(options?.automaticSeeAlsoBehavior, .disabled)
269270
XCTAssertEqual(options?.topicsVisualStyle, .detailedGrid)
271+
XCTAssertEqual(options?.automaticArticleSubheadingBehavior, .enabled)
270272
}
271273

272274
func testUnsupportedChild() throws {
@@ -292,4 +294,61 @@ class OptionsTests: XCTestCase {
292294
]
293295
)
294296
}
297+
298+
func testAutomaticArticleSubheading() throws {
299+
do {
300+
let (problems, options) = try parseDirective(Options.self) {
301+
"""
302+
@Options {
303+
}
304+
"""
305+
}
306+
307+
XCTAssertTrue(problems.isEmpty)
308+
let unwrappedOptions = try XCTUnwrap(options)
309+
XCTAssertNil(unwrappedOptions.automaticArticleSubheadingBehavior)
310+
}
311+
312+
do {
313+
let (problems, options) = try parseDirective(Options.self) {
314+
"""
315+
@Options {
316+
@AutomaticArticleSubheading(randomArgument)
317+
}
318+
"""
319+
}
320+
321+
XCTAssertEqual(problems, ["2: warning – org.swift.docc.HasArgument.unlabeled.ConversionFailed"])
322+
let unwrappedOptions = try XCTUnwrap(options)
323+
XCTAssertNil(unwrappedOptions.automaticArticleSubheadingBehavior)
324+
}
325+
326+
do {
327+
let (problems, options) = try parseDirective(Options.self) {
328+
"""
329+
@Options {
330+
@AutomaticArticleSubheading(disabled)
331+
}
332+
"""
333+
}
334+
335+
XCTAssertTrue(problems.isEmpty)
336+
let unwrappedOptions = try XCTUnwrap(options)
337+
XCTAssertEqual(unwrappedOptions.automaticArticleSubheadingBehavior, .disabled)
338+
}
339+
340+
do {
341+
let (problems, options) = try parseDirective(Options.self) {
342+
"""
343+
@Options {
344+
@AutomaticArticleSubheading(enabled)
345+
}
346+
"""
347+
}
348+
349+
XCTAssertTrue(problems.isEmpty)
350+
let unwrappedOptions = try XCTUnwrap(options)
351+
XCTAssertEqual(unwrappedOptions.automaticArticleSubheadingBehavior, .enabled)
352+
}
353+
}
295354
}

0 commit comments

Comments
 (0)