Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ extensions.
Instead of generating html from md files directly, znai generates DocElement pieces and stores them as JSON. ReactJS App displays
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
render as presentation mode.

# Build Commands

To run tests always use `mvn` command. Do not use `mvnw`.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

package org.testingisdocumenting.znai.structure;

import org.testingisdocumenting.znai.utils.FilePathUtils;

import java.util.Arrays;
import java.util.List;

Expand Down Expand Up @@ -58,14 +60,19 @@ private void parse(final String line) {
} else if (line.startsWith(" ")) {
handleSyntaxError();
} else {
handleChapterEntry(trimmedLine);
// Check if line has extension (indicates standalone page)
if (hasFileExtension(trimmedLine)) {
handleStandalonePageEntry(trimmedLine);
} else {
handleChapterEntry(trimmedLine);
}
}
}

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

private void handleChapterEntry(final String trimmedLine) {
Expand All @@ -80,5 +87,16 @@ private void handlePageEntry(final String line) {
toc.addTocItem(currentChapter, new TocNameAndOpts(line));
}
}

private boolean hasFileExtension(String line) {
TocNameAndOpts nameAndOpts = new TocNameAndOpts(line);
String name = nameAndOpts.getGivenName();
return !FilePathUtils.fileExtension(name).isEmpty();
}

private void handleStandalonePageEntry(final String line) {
// Create TocItem with empty chapter for standalone pages
toc.addTocItem(new TocNameAndOpts(""), new TocNameAndOpts(line));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,18 @@ public String getFileNameWithoutExtension() {
}

public String getFilePath() {
String dirPrefix = getDirName().isEmpty() ? "" : getDirName() + "/";

if (page.hasPath()) {
String path = page.getPath();
if (path.startsWith("/")) {
return path;
}

return getDirName() + "/" + path;
return dirPrefix + path;
}

return getDirName() + "/" + getFileNameWithoutExtension() +
return dirPrefix + getFileNameWithoutExtension() +
(getFileExtension().isEmpty() ? "" : "." + getFileExtension());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,16 @@ class PageLocalSearchEntriesTest {
["dir-name@@file-name@@section-one", "Dir Name", "File Name", "section one", "hello world", "snippet-one"],
["dir-name@@file-name@@section-two", "Dir Name", "File Name", "section two", "how is the weather", ""]]
}

@Test
void "should handle empty chapter"() {
def searchEntries = new PageLocalSearchEntries(
new TocItem("", "overview.md", "md"), // Standalone page
[
new PageSearchEntry(new PageSectionIdTitle("overview", [:]), [SearchScore.STANDARD.text("project overview")]),
])

searchEntries.toListOfLists().should == [
["@@overview@@overview", "", "Overview", "overview", "project overview", ""]]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package org.testingisdocumenting.znai.structure

import org.junit.Test

import static org.testingisdocumenting.webtau.Matchers.code
import static org.testingisdocumenting.webtau.Matchers.throwException

class PlainTextTocGeneratorTest {
@Test
void "should create top level TOC from nested text structure"() {
Expand Down Expand Up @@ -55,4 +58,144 @@ chapter2 {title: "chapter TWO"}
def tocItem = toc.findTocItem("chapter2", "page-c")
tocItem.getChapterTitle().should == "chapter TWO"
}

@Test
void "should support standalone pages with file extension"() {
def toc = new PlainTextTocGenerator("md").generate("""
overview.md
chapter1
page-a
page-b
api-reference.md
changelog.md""")

def overview = toc.findTocItem("", "overview")
overview.should != null
overview.getFilePath().should == "overview.md"
overview.getChapterTitle().should == ""
overview.getPageTitle().should == "Overview"

def apiRef = toc.findTocItem("", "api-reference")
apiRef.should != null
apiRef.getFilePath().should == "api-reference.md"
apiRef.getPageTitle().should == "Api Reference"

def pageA = toc.findTocItem("chapter1", "page-a")
pageA.should != null
pageA.getFilePath().should == "chapter1/page-a.md"
}

@Test
void "should support all standalone pages without chapters"() {
def toc = new PlainTextTocGenerator("md").generate("""
getting-started.md
installation.md
configuration.md
troubleshooting.md""")

toc.getTocItems().size().should == 4

def gettingStarted = toc.findTocItem("", "getting-started")
gettingStarted.getFilePath().should == "getting-started.md"
gettingStarted.getPageTitle().should == "Getting Started"
}

@Test
void "should handle different file extensions for standalone pages"() {
def toc = new PlainTextTocGenerator("md").generate("""
readme.mdx
config.md
chapter1
page-a
api.mmx""")

def readme = toc.findTocItem("", "readme")
readme.getFileExtension().should == "mdx"
readme.getFilePath().should == "readme.mdx"

def api = toc.findTocItem("", "api")
api.getFileExtension().should == "mmx"
api.getFilePath().should == "api.mmx"
}

@Test
void "should support mixed format with standalone pages and chapters"() {
def toc = new PlainTextTocGenerator("md").generate("""
introduction.md
getting-started.md
fundamentals
concepts
terminology
examples
advanced
performance
troubleshooting
api-reference.md
changelog.md""")

def items = toc.getTocItems()
items[0].getPageTitle().should == "Introduction"

items[1].getPageTitle().should == "Getting Started"

items[2].getDirName().should == "fundamentals"
items[2].getChapterTitle().should == "Fundamentals"

items[5].getDirName().should == "advanced"

items[7].getPageTitle().should == "Api Reference"
}

@Test
void "should handle empty toc"() {
def toc = new PlainTextTocGenerator("md").generate("")
toc.getTocItems().size().should == 0
}

@Test
void "should handle whitespace and empty lines"() {
def toc = new PlainTextTocGenerator("md").generate("""

overview.md

chapter1
page-a

page-b

api.md

""")

toc.getTocItems().size().should == 4
toc.findTocItem("", "overview").should != null
toc.findTocItem("chapter1", "page-a").should != null
toc.findTocItem("chapter1", "page-b").should != null
toc.findTocItem("", "api").should != null
}

@Test
void "should handle standalone pages with JSON options"() {
def toc = new PlainTextTocGenerator("md").generate("""
overview.md {title: "Project Overview"}
chapter1
page-a
api-reference.md {title: "API Docs"}""")

def overview = toc.findTocItem("", "overview")
overview.getPageTitle().should == "Project Overview"

def api = toc.findTocItem("", "api-reference")
api.getPageTitle().should == "API Docs"
}

@Test
void "should throw error for indented page without chapter"() {
code {
new PlainTextTocGenerator("md").generate("""
page-without-chapter
""")
} should throwException(IllegalArgumentException,
"chapter is not specified, use a line without indentation to specify a chapter")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ class TocItemTest {
"dir-name/../file.mdx")
}

@Test
void "should identify standalone pages correctly"() {
def standalonePage = new TocItem('', 'overview.md', 'md')
standalonePage.isIndex().should == false

def chapterPage = new TocItem('chapter1', 'page.md', 'md')
chapterPage.isIndex().should == false
}

private static void shouldThrow(String dirName, String fileName) {
code {
new TocItem(dirName, fileName, 'md')
Expand Down
11 changes: 11 additions & 0 deletions znai-docs/znai/flow/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ Take a look at the left side bar and compare it with the file content.
The top entry, `introduction`, corresponds to the directory of the same name.
The nested entry, `rationale`, corresponds to the file `rationale.md`.

# Pages Without Chapters

You can define TOC without having chapters. Specify file names with extension without adding any indentation:
```
page-one.md
page-two.md
optional-chapter
page-three.md
page-four.md
```

# Sub Headings

Only a first level heading is treated as a first class citizen:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Add: Initial support for [pages defined outside of chapters](flow/structure#pages-without-chapters)
4 changes: 4 additions & 0 deletions znai-docs/znai/release-notes/2025.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.78

:include-markdowns: 1.78

# 1.77

:include-markdowns: 1.77
Expand Down
Loading