docia 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +71 -0
  2. package/package.json +35 -0
  3. package/src/book/index.ts +11 -0
  4. package/src/book/summary.ts +327 -0
  5. package/src/book/types.ts +48 -0
  6. package/src/build/build-site.ts +277 -0
  7. package/src/build/client-assets.ts +109 -0
  8. package/src/build/index.ts +12 -0
  9. package/src/build/seo.ts +114 -0
  10. package/src/cli-types.ts +16 -0
  11. package/src/cli.ts +111 -0
  12. package/src/client/main.ts +28 -0
  13. package/src/client/router.ts +244 -0
  14. package/src/client/search.ts +619 -0
  15. package/src/client/styles.css +811 -0
  16. package/src/commands/build.ts +194 -0
  17. package/src/commands/check.ts +208 -0
  18. package/src/commands/dev.ts +33 -0
  19. package/src/commands/index.ts +59 -0
  20. package/src/commands/init.ts +80 -0
  21. package/src/commands/new.ts +125 -0
  22. package/src/commands/serve.ts +69 -0
  23. package/src/config/defaults.ts +42 -0
  24. package/src/config/define-config.ts +5 -0
  25. package/src/config/load-config.ts +473 -0
  26. package/src/config/types.ts +76 -0
  27. package/src/dev/index.ts +1 -0
  28. package/src/dev/start-dev-server.ts +213 -0
  29. package/src/errors.ts +16 -0
  30. package/src/index.ts +13 -0
  31. package/src/markdown/engine.ts +277 -0
  32. package/src/markdown/index.ts +7 -0
  33. package/src/render/index.ts +3 -0
  34. package/src/render/layout.tsx +616 -0
  35. package/src/search/index.ts +40 -0
  36. package/src/search/types.ts +7 -0
  37. package/src/server/static.ts +191 -0
  38. package/src/templates/init-template.ts +87 -0
  39. package/src/utils/args.ts +148 -0
  40. package/src/utils/html.ts +39 -0
  41. package/src/utils/process.ts +23 -0
  42. package/src/utils/strings.ts +42 -0
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # docia
2
+
3
+ SEO-first static docs generator in Bun, inspired by mdBook.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun install
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ ```bash
14
+ bun run src/cli.ts --help
15
+ ```
16
+
17
+ ## Documentation
18
+
19
+ Project usage docs are written with `docia` itself in `docs/book`.
20
+
21
+ Run the docs locally:
22
+
23
+ ```bash
24
+ bun run src/cli.ts dev --config docs/docia.config.ts
25
+ ```
26
+
27
+ Build docs output:
28
+
29
+ ```bash
30
+ bun run src/cli.ts build --config docs/docia.config.ts
31
+ ```
32
+
33
+ ### Commands
34
+
35
+ - `bun run src/cli.ts init`
36
+ - `bun run src/cli.ts build`
37
+ - `bun run src/cli.ts dev`
38
+ - `bun run src/cli.ts serve`
39
+ - `bun run src/cli.ts check`
40
+ - `bun run src/cli.ts new <chapter-name>`
41
+
42
+ `dev` runs an initial build, serves `dist/`, and rebuilds on file changes.
43
+
44
+ ## Example Project
45
+
46
+ See `examples/team-handbook` for a complete docs sample.
47
+
48
+ ```bash
49
+ bun run src/cli.ts build --config examples/team-handbook/docia.config.ts
50
+ bun run src/cli.ts serve --config examples/team-handbook/docia.config.ts --build
51
+ ```
52
+
53
+ ## Status
54
+
55
+ Foundation is in progress:
56
+
57
+ - CLI command skeleton
58
+ - Config loading and validation (`docia.config.ts`)
59
+ - Project scaffolding via `init`
60
+ - `SUMMARY.md` parser + chapter graph (nesting, prev/next)
61
+ - Bun-native Markdown rendering (`Bun.markdown.html/render`)
62
+ - Client assets bundled with `Bun.build` (JS + CSS loaders)
63
+ - Static HTML build output with sidebar, TOC, and pagination
64
+ - SPA-style client routing after first page load
65
+ - Shiki build-time syntax highlighting for code blocks
66
+ - Build-time JSX page rendering (Preact SSR, no hydration required)
67
+ - Basic SEO output (`canonical`, meta description, JSON-LD, `robots.txt`, `llms.txt`)
68
+ - LLM-friendly page actions (copy markdown, view markdown, open in ChatGPT/Claude)
69
+ - Sidebar socials, "Powered by docsia", and optional GitHub edit links
70
+ - `dev` and `serve` on `Bun.serve()`
71
+ - `check` validations for missing files, duplicate routes/output paths, broken markdown links, and orphan markdown files
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "docia",
3
+ "version": "0.0.1",
4
+ "module": "src/cli.ts",
5
+ "type": "module",
6
+ "bin": {
7
+ "docia": "./src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "docia": "bun run src/cli.ts",
15
+ "build": "bun run src/cli.ts build",
16
+ "dev": "bun run src/cli.ts dev",
17
+ "serve": "bun run src/cli.ts serve",
18
+ "check": "bun run src/cli.ts check",
19
+ "test": "bun test"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest",
23
+ "@types/react": "^19.2.13",
24
+ "@types/react-dom": "^19.2.3"
25
+ },
26
+ "peerDependencies": {
27
+ "typescript": "^5"
28
+ },
29
+ "dependencies": {
30
+ "picocolors": "^1.1.1",
31
+ "react": "^19.2.4",
32
+ "react-dom": "^19.2.4",
33
+ "shiki": "^3.22.0"
34
+ }
35
+ }
@@ -0,0 +1,11 @@
1
+ export { loadSummaryGraph } from "./summary";
2
+
3
+ export type {
4
+ SummaryBaseEntry,
5
+ SummaryChapterEntry,
6
+ SummaryEntry,
7
+ SummaryEntryKind,
8
+ SummaryGraph,
9
+ SummaryLinkEntry,
10
+ SummarySectionEntry,
11
+ } from "./types";
@@ -0,0 +1,327 @@
1
+ import { posix, resolve } from "node:path";
2
+ import type { ResolvedConfig } from "../config/types";
3
+ import { CliError } from "../errors";
4
+ import type {
5
+ SummaryChapterEntry,
6
+ SummaryEntry,
7
+ SummaryGraph,
8
+ SummaryLinkEntry,
9
+ SummarySectionEntry,
10
+ } from "./types";
11
+
12
+ const MARKDOWN_PATH_PATTERN = /\.(md|markdown|mdown)$/i;
13
+ const LIST_ITEM_PATTERN = /^(\s*)[-*+]\s+(.+)$/;
14
+ const MARKDOWN_LINK_PATTERN = /^\[([^\]]+)\]\((.+)\)\s*$/;
15
+ const EXTERNAL_LINK_PATTERN = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|#|\/\/)/;
16
+
17
+ interface ParsedSummaryItem {
18
+ depth: number;
19
+ line: number;
20
+ title: string;
21
+ href?: string;
22
+ }
23
+
24
+ interface EntryStackItem {
25
+ depth: number;
26
+ entry: SummaryEntry;
27
+ }
28
+
29
+ function toLeadingSpaces(input: string): string {
30
+ return input.replaceAll("\t", " ");
31
+ }
32
+
33
+ function parseSummaryItems(text: string): ParsedSummaryItem[] {
34
+ const lines = text.split(/\r?\n/);
35
+ const items: ParsedSummaryItem[] = [];
36
+
37
+ for (let index = 0; index < lines.length; index += 1) {
38
+ const line = lines[index];
39
+ const match = LIST_ITEM_PATTERN.exec(line ?? "");
40
+ if (!match) {
41
+ continue;
42
+ }
43
+
44
+ const indent = toLeadingSpaces(match[1] ?? "");
45
+ const raw = (match[2] ?? "").trim();
46
+ if (raw.length === 0) {
47
+ continue;
48
+ }
49
+
50
+ const depth = Math.floor(indent.length / 2);
51
+ const linkMatch = MARKDOWN_LINK_PATTERN.exec(raw);
52
+ if (linkMatch) {
53
+ const title = (linkMatch[1] ?? "").trim();
54
+ const href = (linkMatch[2] ?? "").trim();
55
+
56
+ if (title.length === 0) {
57
+ throw new CliError(`Invalid SUMMARY entry at line ${index + 1}: missing title.`);
58
+ }
59
+ if (href.length === 0) {
60
+ throw new CliError(`Invalid SUMMARY entry at line ${index + 1}: missing href.`);
61
+ }
62
+
63
+ items.push({
64
+ depth,
65
+ line: index + 1,
66
+ title,
67
+ href,
68
+ });
69
+
70
+ continue;
71
+ }
72
+
73
+ items.push({
74
+ depth,
75
+ line: index + 1,
76
+ title: raw,
77
+ });
78
+ }
79
+
80
+ return items;
81
+ }
82
+
83
+ function normalizeSourcePath(href: string, line: number): string {
84
+ const trimmed = href.trim();
85
+ const withoutHash = trimmed.split("#", 1)[0] ?? "";
86
+ const withoutQuery = withoutHash.split("?", 1)[0] ?? "";
87
+
88
+ let normalized = withoutQuery.replaceAll("\\", "/").replace(/^\.?\//, "");
89
+ if (normalized.startsWith("/")) {
90
+ normalized = normalized.slice(1);
91
+ }
92
+
93
+ normalized = posix.normalize(normalized);
94
+
95
+ if (normalized.length === 0 || normalized === ".") {
96
+ throw new CliError(`Invalid SUMMARY link at line ${line}: empty file path.`);
97
+ }
98
+
99
+ if (normalized === ".." || normalized.startsWith("../")) {
100
+ throw new CliError(
101
+ `Invalid SUMMARY link at line ${line}: path cannot traverse outside source dir.`,
102
+ );
103
+ }
104
+
105
+ return normalized;
106
+ }
107
+
108
+ function escapeRegExp(input: string): string {
109
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ }
111
+
112
+ function stripMarkdownExtension(pathValue: string): string {
113
+ return pathValue.replace(MARKDOWN_PATH_PATTERN, "");
114
+ }
115
+
116
+ function trimReadmeSuffix(pathValue: string): string {
117
+ const readmePattern = new RegExp(`(?:^|/)${escapeRegExp("README")}$`, "i");
118
+ return pathValue.replace(readmePattern, "");
119
+ }
120
+
121
+ function toRoutePath(sourcePath: string, prettyUrls: boolean): string {
122
+ const normalized = stripMarkdownExtension(sourcePath);
123
+ const withoutReadme = trimReadmeSuffix(normalized);
124
+ const cleaned = withoutReadme.replace(/^\//, "").replace(/\/$/, "");
125
+
126
+ if (prettyUrls) {
127
+ if (cleaned.length === 0) {
128
+ return "/";
129
+ }
130
+
131
+ return `/${cleaned}/`;
132
+ }
133
+
134
+ if (cleaned.length === 0) {
135
+ return "/index.html";
136
+ }
137
+
138
+ return `/${cleaned}.html`;
139
+ }
140
+
141
+ function toOutputPath(sourcePath: string, prettyUrls: boolean): string {
142
+ const normalized = stripMarkdownExtension(sourcePath);
143
+ const withoutReadme = trimReadmeSuffix(normalized);
144
+ const cleaned = withoutReadme.replace(/^\//, "").replace(/\/$/, "");
145
+
146
+ if (prettyUrls) {
147
+ if (cleaned.length === 0) {
148
+ return "index.html";
149
+ }
150
+
151
+ return `${cleaned}/index.html`;
152
+ }
153
+
154
+ if (cleaned.length === 0) {
155
+ return "index.html";
156
+ }
157
+
158
+ return `${cleaned}.html`;
159
+ }
160
+
161
+ function createEntryId(prefix: string, line: number): string {
162
+ return `${prefix}-${line}`;
163
+ }
164
+
165
+ function isExternalLink(href: string): boolean {
166
+ return EXTERNAL_LINK_PATTERN.test(href.trim());
167
+ }
168
+
169
+ function isMarkdownHref(href: string): boolean {
170
+ const pathPart = href.split("#", 1)[0]?.split("?", 1)[0] ?? "";
171
+ return MARKDOWN_PATH_PATTERN.test(pathPart);
172
+ }
173
+
174
+ function flattenChapters(entries: SummaryEntry[]): SummaryChapterEntry[] {
175
+ const chapters: SummaryChapterEntry[] = [];
176
+
177
+ const visit = (entryList: SummaryEntry[]): void => {
178
+ for (const entry of entryList) {
179
+ if (entry.kind === "chapter") {
180
+ chapters.push(entry);
181
+ }
182
+
183
+ if (entry.children.length > 0) {
184
+ visit(entry.children);
185
+ }
186
+ }
187
+ };
188
+
189
+ visit(entries);
190
+ return chapters;
191
+ }
192
+
193
+ export async function loadSummaryGraph(config: ResolvedConfig): Promise<SummaryGraph> {
194
+ const summaryPath = resolve(config.srcDirAbsolute, "SUMMARY.md");
195
+ const summaryFile = Bun.file(summaryPath);
196
+
197
+ if (!(await summaryFile.exists())) {
198
+ throw new CliError(
199
+ `Could not find SUMMARY.md in source directory: ${config.srcDirAbsolute}`,
200
+ );
201
+ }
202
+
203
+ const summaryText = await summaryFile.text();
204
+ const items = parseSummaryItems(summaryText);
205
+
206
+ if (items.length === 0) {
207
+ throw new CliError(`SUMMARY.md does not include any chapter entries: ${summaryPath}`);
208
+ }
209
+
210
+ const rootEntries: SummaryEntry[] = [];
211
+ const stack: EntryStackItem[] = [];
212
+ const entryById = new Map<string, SummaryEntry>();
213
+ const chapterBySourcePath = new Map<string, SummaryChapterEntry>();
214
+
215
+ for (const item of items) {
216
+ while (stack.length > 0) {
217
+ const top = stack[stack.length - 1];
218
+ if (top && top.depth >= item.depth) {
219
+ stack.pop();
220
+ continue;
221
+ }
222
+
223
+ break;
224
+ }
225
+
226
+ const parent = stack[stack.length - 1]?.entry;
227
+ if (!parent && item.depth > 0) {
228
+ throw new CliError(
229
+ `Invalid SUMMARY nesting at line ${item.line}: indentation starts before a parent entry.`,
230
+ );
231
+ }
232
+
233
+ if (parent && item.depth > parent.depth + 1) {
234
+ throw new CliError(
235
+ `Invalid SUMMARY nesting at line ${item.line}: indentation jumps more than one level.`,
236
+ );
237
+ }
238
+
239
+ let entry: SummaryEntry;
240
+
241
+ if (item.href === undefined) {
242
+ const sectionEntry: SummarySectionEntry = {
243
+ id: createEntryId("section", item.line),
244
+ kind: "section",
245
+ title: item.title,
246
+ depth: item.depth,
247
+ line: item.line,
248
+ parentId: parent?.id ?? null,
249
+ children: [],
250
+ };
251
+ entry = sectionEntry;
252
+ } else if (isExternalLink(item.href) || !isMarkdownHref(item.href)) {
253
+ const linkEntry: SummaryLinkEntry = {
254
+ id: createEntryId("link", item.line),
255
+ kind: "link",
256
+ title: item.title,
257
+ href: item.href,
258
+ external: isExternalLink(item.href),
259
+ depth: item.depth,
260
+ line: item.line,
261
+ parentId: parent?.id ?? null,
262
+ children: [],
263
+ };
264
+ entry = linkEntry;
265
+ } else {
266
+ const sourcePath = normalizeSourcePath(item.href, item.line);
267
+ const sourceAbsolutePath = resolve(config.srcDirAbsolute, sourcePath);
268
+
269
+ if (chapterBySourcePath.has(sourcePath)) {
270
+ const existing = chapterBySourcePath.get(sourcePath);
271
+ throw new CliError(
272
+ `Duplicate SUMMARY chapter path \`${sourcePath}\` at line ${item.line}. First defined at line ${existing?.line}.`,
273
+ );
274
+ }
275
+
276
+ const chapterEntry: SummaryChapterEntry = {
277
+ id: createEntryId("chapter", item.line),
278
+ kind: "chapter",
279
+ title: item.title,
280
+ href: item.href,
281
+ sourcePath,
282
+ sourceAbsolutePath,
283
+ routePath: toRoutePath(sourcePath, config.prettyUrls),
284
+ outputPath: toOutputPath(sourcePath, config.prettyUrls),
285
+ depth: item.depth,
286
+ line: item.line,
287
+ parentId: parent?.id ?? null,
288
+ children: [],
289
+ order: -1,
290
+ previousChapterId: null,
291
+ nextChapterId: null,
292
+ };
293
+
294
+ chapterBySourcePath.set(sourcePath, chapterEntry);
295
+ entry = chapterEntry;
296
+ }
297
+
298
+ if (parent) {
299
+ parent.children.push(entry);
300
+ } else {
301
+ rootEntries.push(entry);
302
+ }
303
+
304
+ entryById.set(entry.id, entry);
305
+ stack.push({ depth: item.depth, entry });
306
+ }
307
+
308
+ const chapters = flattenChapters(rootEntries);
309
+ chapters.forEach((chapter, index) => {
310
+ const previous = chapters[index - 1];
311
+ const next = chapters[index + 1];
312
+
313
+ chapter.order = index;
314
+ chapter.previousChapterId = previous?.id ?? null;
315
+ chapter.nextChapterId = next?.id ?? null;
316
+ });
317
+
318
+ return {
319
+ summaryPath,
320
+ entries: rootEntries,
321
+ chapters,
322
+ chapterBySourcePath,
323
+ entryById,
324
+ firstChapterId: chapters[0]?.id ?? null,
325
+ lastChapterId: chapters[chapters.length - 1]?.id ?? null,
326
+ };
327
+ }
@@ -0,0 +1,48 @@
1
+ export type SummaryEntryKind = "section" | "chapter" | "link";
2
+
3
+ export interface SummaryBaseEntry {
4
+ id: string;
5
+ kind: SummaryEntryKind;
6
+ title: string;
7
+ depth: number;
8
+ line: number;
9
+ parentId: string | null;
10
+ children: SummaryEntry[];
11
+ }
12
+
13
+ export interface SummarySectionEntry extends SummaryBaseEntry {
14
+ kind: "section";
15
+ }
16
+
17
+ export interface SummaryLinkEntry extends SummaryBaseEntry {
18
+ kind: "link";
19
+ href: string;
20
+ external: boolean;
21
+ }
22
+
23
+ export interface SummaryChapterEntry extends SummaryBaseEntry {
24
+ kind: "chapter";
25
+ href: string;
26
+ sourcePath: string;
27
+ sourceAbsolutePath: string;
28
+ routePath: string;
29
+ outputPath: string;
30
+ order: number;
31
+ previousChapterId: string | null;
32
+ nextChapterId: string | null;
33
+ }
34
+
35
+ export type SummaryEntry =
36
+ | SummarySectionEntry
37
+ | SummaryLinkEntry
38
+ | SummaryChapterEntry;
39
+
40
+ export interface SummaryGraph {
41
+ summaryPath: string;
42
+ entries: SummaryEntry[];
43
+ chapters: SummaryChapterEntry[];
44
+ chapterBySourcePath: Map<string, SummaryChapterEntry>;
45
+ entryById: Map<string, SummaryEntry>;
46
+ firstChapterId: string | null;
47
+ lastChapterId: string | null;
48
+ }