// The manual's table of contents, with the titles read from the manual itself. // // Chapter files are numbered so their order is their filename, and each one // begins with its own title as a first-level heading. Reading that heading from // the file rather than repeating it in QML means a chapter cannot be renamed in // one place and not the other -- which is what had already happened by the time // anybody compared the two. There is deliberately no `label:` beside a filename // below; if one appears, the drift is back. // // Two pages need this list: the reader, and About's Manual card. Neither owns // it, so it lives here and both instantiate it. This is the one place the // chapter files are named. // // The chapters live beside the shell in manual/, reached through // Quickshell.shellDir: that resolves whether or not the repository is where it // usually is, and a path walked upward out of the shell directory does not. import Quickshell import Quickshell.Io import QtQml import QtQuick Item { id: root // Display order is filename order. readonly property var chapters: [ { file: "01-coming-from-another-desktop.md" }, { file: "02-the-keyboard.md" }, { file: "03-windows-and-workspaces.md" }, { file: "04-when-something-breaks.md" }, { file: "05-making-it-yours.md" } ] // file -> first heading, filled in as each file is read. property var headings: ({}) // [{ file, title }]. A chapter whose file has not been read yet -- or // cannot be -- falls back to a title made out of its filename, so the list // is never blank and never a row of paths. readonly property var titled: root.chapters.map(chapter => ({ file: chapter.file, title: String(root.headings[chapter.file] ?? "") !== "" ? root.headings[chapter.file] : root.titleFromFilename(chapter.file) })) function titleFromFilename(file: string): string { const stem = file.replace(/^\d+-/, "").replace(/\.md$/, "").replace(/-/g, " "); return stem.charAt(0).toUpperCase() + stem.slice(1); } // The first level-one heading, which every chapter opens with. function headingOf(text: string): string { for (const line of String(text ?? "").split("\n")) { const match = /^#\s+(.+?)\s*$/.exec(line); if (match) return match[1]; } return ""; } // Reassigned rather than mutated: a property change on a plain object does // not fire, and `titled` would keep answering with the old headings. function absorb(file: string, text: string): void { const next = Object.assign({}, root.headings); next[file] = root.headingOf(text); root.headings = next; } Instantiator { model: root.chapters delegate: FileView { required property var modelData path: Quickshell.shellDir + "/manual/" + modelData.file printErrors: false onLoaded: root.absorb(modelData.file, this.text()) onLoadFailed: root.absorb(modelData.file, "") } } }