Skip to content

Commit 5689ba1

Browse files
flow: support for pages outside chapters (#1330)
1 parent 688d3bf commit 5689ba1

File tree

22 files changed

+4086
-623
lines changed

22 files changed

+4086
-623
lines changed

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ extensions.
99
Instead of generating html from md files directly, znai generates DocElement pieces and stores them as JSON. ReactJS App displays
1010
JSON pieces at runtime. This allows UI to customize rendering depending on the context. E.g. it can render it as part of search preview. Or
1111
render as presentation mode.
12+
13+
# Build Commands
14+
15+
To run tests always use `mvn` command. Do not use `mvnw`.

znai-core/src/main/java/org/testingisdocumenting/znai/structure/PlainTextTocGenerator.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
package org.testingisdocumenting.znai.structure;
1919

20+
import org.testingisdocumenting.znai.utils.FilePathUtils;
21+
2022
import java.util.Arrays;
2123
import java.util.List;
2224

@@ -58,14 +60,19 @@ private void parse(final String line) {
5860
} else if (line.startsWith(" ")) {
5961
handleSyntaxError();
6062
} else {
61-
handleChapterEntry(trimmedLine);
63+
// Check if line has extension (indicates standalone page)
64+
if (hasFileExtension(trimmedLine)) {
65+
handleStandalonePageEntry(trimmedLine);
66+
} else {
67+
handleChapterEntry(trimmedLine);
68+
}
6269
}
6370
}
6471

6572
private void handleSyntaxError() {
6673
throw new IllegalArgumentException(
6774
"toc line should either start with " + INDENTATION.length() + " spaces to denote " +
68-
"page file name, or start without spaces to denote chapter dir name");
75+
"page file name, or start without spaces to denote chapter dir name or standalone page (with file extension)");
6976
}
7077

7178
private void handleChapterEntry(final String trimmedLine) {
@@ -80,5 +87,16 @@ private void handlePageEntry(final String line) {
8087
toc.addTocItem(currentChapter, new TocNameAndOpts(line));
8188
}
8289
}
90+
91+
private boolean hasFileExtension(String line) {
92+
TocNameAndOpts nameAndOpts = new TocNameAndOpts(line);
93+
String name = nameAndOpts.getGivenName();
94+
return !FilePathUtils.fileExtension(name).isEmpty();
95+
}
96+
97+
private void handleStandalonePageEntry(final String line) {
98+
// Create TocItem with empty chapter for standalone pages
99+
toc.addTocItem(new TocNameAndOpts(""), new TocNameAndOpts(line));
100+
}
83101
}
84102
}

znai-core/src/main/java/org/testingisdocumenting/znai/structure/TocItem.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,18 @@ public String getFileNameWithoutExtension() {
8181
}
8282

8383
public String getFilePath() {
84+
String dirPrefix = getDirName().isEmpty() ? "" : getDirName() + "/";
85+
8486
if (page.hasPath()) {
8587
String path = page.getPath();
8688
if (path.startsWith("/")) {
8789
return path;
8890
}
8991

90-
return getDirName() + "/" + path;
92+
return dirPrefix + path;
9193
}
9294

93-
return getDirName() + "/" + getFileNameWithoutExtension() +
95+
return dirPrefix + getFileNameWithoutExtension() +
9496
(getFileExtension().isEmpty() ? "" : "." + getFileExtension());
9597
}
9698

znai-core/src/test/groovy/org/testingisdocumenting/znai/search/PageLocalSearchEntriesTest.groovy

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,16 @@ class PageLocalSearchEntriesTest {
3535
["dir-name@@file-name@@section-one", "Dir Name", "File Name", "section one", "hello world", "snippet-one"],
3636
["dir-name@@file-name@@section-two", "Dir Name", "File Name", "section two", "how is the weather", ""]]
3737
}
38+
39+
@Test
40+
void "should handle empty chapter"() {
41+
def searchEntries = new PageLocalSearchEntries(
42+
new TocItem("", "overview.md", "md"), // Standalone page
43+
[
44+
new PageSearchEntry(new PageSectionIdTitle("overview", [:]), [SearchScore.STANDARD.text("project overview")]),
45+
])
46+
47+
searchEntries.toListOfLists().should == [
48+
["@@overview@@overview", "", "Overview", "overview", "project overview", ""]]
49+
}
3850
}

znai-core/src/test/groovy/org/testingisdocumenting/znai/structure/PlainTextTocGeneratorTest.groovy

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ package org.testingisdocumenting.znai.structure
1919

2020
import org.junit.Test
2121

22+
import static org.testingisdocumenting.webtau.Matchers.code
23+
import static org.testingisdocumenting.webtau.Matchers.throwException
24+
2225
class PlainTextTocGeneratorTest {
2326
@Test
2427
void "should create top level TOC from nested text structure"() {
@@ -55,4 +58,144 @@ chapter2 {title: "chapter TWO"}
5558
def tocItem = toc.findTocItem("chapter2", "page-c")
5659
tocItem.getChapterTitle().should == "chapter TWO"
5760
}
61+
62+
@Test
63+
void "should support standalone pages with file extension"() {
64+
def toc = new PlainTextTocGenerator("md").generate("""
65+
overview.md
66+
chapter1
67+
page-a
68+
page-b
69+
api-reference.md
70+
changelog.md""")
71+
72+
def overview = toc.findTocItem("", "overview")
73+
overview.should != null
74+
overview.getFilePath().should == "overview.md"
75+
overview.getChapterTitle().should == ""
76+
overview.getPageTitle().should == "Overview"
77+
78+
def apiRef = toc.findTocItem("", "api-reference")
79+
apiRef.should != null
80+
apiRef.getFilePath().should == "api-reference.md"
81+
apiRef.getPageTitle().should == "Api Reference"
82+
83+
def pageA = toc.findTocItem("chapter1", "page-a")
84+
pageA.should != null
85+
pageA.getFilePath().should == "chapter1/page-a.md"
86+
}
87+
88+
@Test
89+
void "should support all standalone pages without chapters"() {
90+
def toc = new PlainTextTocGenerator("md").generate("""
91+
getting-started.md
92+
installation.md
93+
configuration.md
94+
troubleshooting.md""")
95+
96+
toc.getTocItems().size().should == 4
97+
98+
def gettingStarted = toc.findTocItem("", "getting-started")
99+
gettingStarted.getFilePath().should == "getting-started.md"
100+
gettingStarted.getPageTitle().should == "Getting Started"
101+
}
102+
103+
@Test
104+
void "should handle different file extensions for standalone pages"() {
105+
def toc = new PlainTextTocGenerator("md").generate("""
106+
readme.mdx
107+
config.md
108+
chapter1
109+
page-a
110+
api.mmx""")
111+
112+
def readme = toc.findTocItem("", "readme")
113+
readme.getFileExtension().should == "mdx"
114+
readme.getFilePath().should == "readme.mdx"
115+
116+
def api = toc.findTocItem("", "api")
117+
api.getFileExtension().should == "mmx"
118+
api.getFilePath().should == "api.mmx"
119+
}
120+
121+
@Test
122+
void "should support mixed format with standalone pages and chapters"() {
123+
def toc = new PlainTextTocGenerator("md").generate("""
124+
introduction.md
125+
getting-started.md
126+
fundamentals
127+
concepts
128+
terminology
129+
examples
130+
advanced
131+
performance
132+
troubleshooting
133+
api-reference.md
134+
changelog.md""")
135+
136+
def items = toc.getTocItems()
137+
items[0].getPageTitle().should == "Introduction"
138+
139+
items[1].getPageTitle().should == "Getting Started"
140+
141+
items[2].getDirName().should == "fundamentals"
142+
items[2].getChapterTitle().should == "Fundamentals"
143+
144+
items[5].getDirName().should == "advanced"
145+
146+
items[7].getPageTitle().should == "Api Reference"
147+
}
148+
149+
@Test
150+
void "should handle empty toc"() {
151+
def toc = new PlainTextTocGenerator("md").generate("")
152+
toc.getTocItems().size().should == 0
153+
}
154+
155+
@Test
156+
void "should handle whitespace and empty lines"() {
157+
def toc = new PlainTextTocGenerator("md").generate("""
158+
159+
overview.md
160+
161+
chapter1
162+
page-a
163+
164+
page-b
165+
166+
api.md
167+
168+
""")
169+
170+
toc.getTocItems().size().should == 4
171+
toc.findTocItem("", "overview").should != null
172+
toc.findTocItem("chapter1", "page-a").should != null
173+
toc.findTocItem("chapter1", "page-b").should != null
174+
toc.findTocItem("", "api").should != null
175+
}
176+
177+
@Test
178+
void "should handle standalone pages with JSON options"() {
179+
def toc = new PlainTextTocGenerator("md").generate("""
180+
overview.md {title: "Project Overview"}
181+
chapter1
182+
page-a
183+
api-reference.md {title: "API Docs"}""")
184+
185+
def overview = toc.findTocItem("", "overview")
186+
overview.getPageTitle().should == "Project Overview"
187+
188+
def api = toc.findTocItem("", "api-reference")
189+
api.getPageTitle().should == "API Docs"
190+
}
191+
192+
@Test
193+
void "should throw error for indented page without chapter"() {
194+
code {
195+
new PlainTextTocGenerator("md").generate("""
196+
page-without-chapter
197+
""")
198+
} should throwException(IllegalArgumentException,
199+
"chapter is not specified, use a line without indentation to specify a chapter")
200+
}
58201
}

znai-core/src/test/groovy/org/testingisdocumenting/znai/structure/TocItemTest.groovy

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ class TocItemTest {
8888
"dir-name/../file.mdx")
8989
}
9090

91+
@Test
92+
void "should identify standalone pages correctly"() {
93+
def standalonePage = new TocItem('', 'overview.md', 'md')
94+
standalonePage.isIndex().should == false
95+
96+
def chapterPage = new TocItem('chapter1', 'page.md', 'md')
97+
chapterPage.isIndex().should == false
98+
}
99+
91100
private static void shouldThrow(String dirName, String fileName) {
92101
code {
93102
new TocItem(dirName, fileName, 'md')

znai-docs/znai/flow/structure.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ Take a look at the left side bar and compare it with the file content.
3636
The top entry, `introduction`, corresponds to the directory of the same name.
3737
The nested entry, `rationale`, corresponds to the file `rationale.md`.
3838

39+
# Pages Without Chapters
40+
41+
You can define TOC without having chapters. Specify file names with extension without adding any indentation:
42+
```
43+
page-one.md
44+
page-two.md
45+
optional-chapter
46+
page-three.md
47+
page-four.md
48+
```
49+
3950
# Sub Headings
4051

4152
Only a first level heading is treated as a first class citizen:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Add: Initial support for [pages defined outside of chapters](flow/structure#pages-without-chapters)

znai-docs/znai/release-notes/2025.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 1.78
2+
3+
:include-markdowns: 1.78
4+
15
# 1.77
26

37
:include-markdowns: 1.77

0 commit comments

Comments
 (0)