@rahuldshetty/inscribe 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.
- package/README.md +65 -0
- package/cli/api/builder.ts +145 -0
- package/cli/api/inscribe_reader.ts +14 -0
- package/cli/api/renderer.ts +199 -0
- package/cli/api/server.ts +144 -0
- package/cli/api/theme_resolver.ts +31 -0
- package/cli/index.ts +127 -0
- package/cli/schemas/blog.ts +23 -0
- package/cli/schemas/folder.ts +8 -0
- package/cli/schemas/inscribe.ts +14 -0
- package/cli/utils/markdown.ts +72 -0
- package/cli/utils/minifier.ts +20 -0
- package/dist/index.js +102296 -0
- package/package.json +71 -0
- package/template/blogs/doc1.md +16 -0
- package/template/blogs/doc2.md +16 -0
- package/template/blogs/sample.mdx +22 -0
- package/template/docs/hello-docs.md +8 -0
- package/template/inscribe.yaml +5 -0
- package/template/layouts/base.njk +113 -0
- package/template/layouts/blog.njk +79 -0
- package/template/layouts/blog_index.njk +83 -0
- package/template/layouts/doc.njk +106 -0
- package/template/layouts/doc_index.njk +140 -0
- package/template/layouts/home.njk +28 -0
- package/template/layouts/partials/footer.njk +7 -0
- package/template/layouts/partials/header.njk +25 -0
- package/template/themes/default.css +41 -0
- package/template/themes/medium.css +39 -0
- package/template/themes/nord.css +38 -0
- package/template/themes/sepia.css +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://via.placeholder.com/150x150.png?text=Inscribe" alt="Inscribe Logo" width="120" />
|
|
3
|
+
<h1>Inscribe</h1>
|
|
4
|
+
<p><strong>A minimalist, high-performance Static Site Generator (SSG)</strong></p>
|
|
5
|
+
<p>
|
|
6
|
+
<img src="https://img.shields.io/badge/status-under%20development-orange" alt="Project Status" />
|
|
7
|
+
<img src="https://img.shields.io/badge/license-MIT-blue" alt="License" />
|
|
8
|
+
<img src="https://img.shields.io/badge/built%20with-Bun-black" alt="Built with Bun" />
|
|
9
|
+
</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Inscribe is a modern static site generator built with **Bun**, **MDX**, and **Preact**. It's designed to be fast, simple, and developer-friendly, making it perfect for blogs, documentation, and personal portfolios.
|
|
15
|
+
|
|
16
|
+
> [!IMPORTANT]
|
|
17
|
+
> This project is currently **under active development**. Features and APIs are subject to change.
|
|
18
|
+
|
|
19
|
+
## ✨ Features
|
|
20
|
+
|
|
21
|
+
- [x] **CLI** – Simple commands to scaffold, develop, and build your site.
|
|
22
|
+
- [x] **Dev Server** – Local development server with instant live reload.
|
|
23
|
+
- [x] **MDX & Markdown** – Write content using powerful MDX and standard Markdown.
|
|
24
|
+
- [ ] **Search** – Integrated full-text search.
|
|
25
|
+
- [ ] **Themes** – Customizable and extensible theme system.
|
|
26
|
+
- [ ] **Plugins** – Flexible plugin architecture for extending functionality.
|
|
27
|
+
|
|
28
|
+
## 🚀 Quick Start
|
|
29
|
+
|
|
30
|
+
### Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Using Bun (Recommended)
|
|
34
|
+
bun install -g inscribe-ssg
|
|
35
|
+
|
|
36
|
+
# Using npm
|
|
37
|
+
npm install -g inscribe-ssg
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Usage
|
|
41
|
+
|
|
42
|
+
Scaffold a new project, start the development server, or build for production.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Initialize a new project in the current directory
|
|
46
|
+
inscribe init
|
|
47
|
+
|
|
48
|
+
# Run the development server with live reload
|
|
49
|
+
inscribe dev
|
|
50
|
+
|
|
51
|
+
# Build the static site for production
|
|
52
|
+
inscribe build
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 🛠️ Commands
|
|
56
|
+
|
|
57
|
+
| Command | Description |
|
|
58
|
+
| :------------- | :-------------------------------------------------------------- |
|
|
59
|
+
| `init` | Create a new scaffold for your blog or portfolio. |
|
|
60
|
+
| `dev [path]` | Start the local development server (default: `.` port `3000`). |
|
|
61
|
+
| `build [path]` | Generate a production-ready static website (default: `./dist`). |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<p align="center">Made with ❤️ by <a href="https://github.com/rahuldshetty">rahuldshetty</a></p>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { minifyHtml } from "../utils/minifier";
|
|
4
|
+
import { parseBlogPost, renderSectionPage, renderSectionIndexPage, renderHomePage, NavState } from "./renderer";
|
|
5
|
+
import { Blog } from "../schemas/blog";
|
|
6
|
+
import { InscribeConfig } from "../schemas/inscribe";
|
|
7
|
+
import { readInscribeFile } from "./inscribe_reader";
|
|
8
|
+
import { parseFolderMetadata } from "../utils/markdown";
|
|
9
|
+
import { FolderMetadata } from "../schemas/folder";
|
|
10
|
+
|
|
11
|
+
export interface BuildOptions {
|
|
12
|
+
sourceDir: string;
|
|
13
|
+
outputDir: string;
|
|
14
|
+
env: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const buildSection = async (
|
|
18
|
+
type: 'blog' | 'doc',
|
|
19
|
+
sourceDir: string,
|
|
20
|
+
outputDir: string,
|
|
21
|
+
files: string[],
|
|
22
|
+
isRelease: boolean,
|
|
23
|
+
inscribe: InscribeConfig,
|
|
24
|
+
navState: NavState
|
|
25
|
+
): Promise<{ posts: Blog[]; folderMetadata: Record<string, FolderMetadata> }> => {
|
|
26
|
+
const posts: Blog[] = [];
|
|
27
|
+
const singularFolder = type === 'blog' ? 'blog' : 'doc';
|
|
28
|
+
const sectionRoot = path.join(sourceDir, type === 'doc' ? (inscribe.doc_path || 'docs') : (inscribe.blog_path || 'blog'));
|
|
29
|
+
|
|
30
|
+
await fs.ensureDir(path.join(outputDir, singularFolder));
|
|
31
|
+
|
|
32
|
+
const folderMetadata: Record<string, FolderMetadata> = {};
|
|
33
|
+
|
|
34
|
+
for (const filePath of files) {
|
|
35
|
+
if (filePath.endsWith("index.md")) {
|
|
36
|
+
const dir = path.dirname(filePath);
|
|
37
|
+
const relativeDir = path.relative(sectionRoot, dir);
|
|
38
|
+
folderMetadata[relativeDir || ''] = parseFolderMetadata(dir);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const post = await parseBlogPost(filePath);
|
|
43
|
+
(post as any).relativePath = path.relative(sectionRoot, filePath);
|
|
44
|
+
posts.push(post);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
posts.sort((a, b) => {
|
|
48
|
+
const weightA = a.metadata.weight ?? 0;
|
|
49
|
+
const weightB = b.metadata.weight ?? 0;
|
|
50
|
+
if (weightA !== weightB) return weightA - weightB;
|
|
51
|
+
return a.metadata.slug.localeCompare(b.metadata.slug);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const post of posts) {
|
|
55
|
+
let fullHtml = await renderSectionPage(type, post, posts, folderMetadata, inscribe, sourceDir, navState);
|
|
56
|
+
if (isRelease) fullHtml = await minifyHtml(fullHtml);
|
|
57
|
+
await fs.writeFile(path.join(outputDir, singularFolder, `${post.metadata.slug}.html`), fullHtml);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { posts, folderMetadata };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function build(options: BuildOptions) {
|
|
64
|
+
const { sourceDir, outputDir, env } = options;
|
|
65
|
+
const isRelease = env === "release";
|
|
66
|
+
|
|
67
|
+
// Read inscribe config
|
|
68
|
+
const inscribe = await readInscribeFile(sourceDir);
|
|
69
|
+
|
|
70
|
+
const blogPath = inscribe.blog_path === "null" || !inscribe.blog_path ? null : inscribe.blog_path;
|
|
71
|
+
const docPath = inscribe.doc_path === "null" || !inscribe.doc_path ? null : inscribe.doc_path;
|
|
72
|
+
const blogDir = blogPath ? path.join(sourceDir, blogPath) : null;
|
|
73
|
+
const docDir = docPath ? path.join(sourceDir, docPath) : null;
|
|
74
|
+
|
|
75
|
+
const hasBlog = blogDir ? fs.existsSync(blogDir) : false;
|
|
76
|
+
const hasDocs = docDir ? fs.existsSync(docDir) : false;
|
|
77
|
+
const hasHome = inscribe.show_home !== false;
|
|
78
|
+
|
|
79
|
+
if (!hasBlog && !hasDocs && !hasHome) {
|
|
80
|
+
throw new Error("At least one section (home, blog, or doc) must be active to generate the site.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const navState: NavState = { hasHome, hasBlog, hasDocs };
|
|
84
|
+
|
|
85
|
+
// Ensure output directory exists
|
|
86
|
+
await fs.ensureDir(outputDir);
|
|
87
|
+
|
|
88
|
+
let redirectUrl = "";
|
|
89
|
+
|
|
90
|
+
// Build blogs
|
|
91
|
+
if (hasBlog && blogDir) {
|
|
92
|
+
const blogFiles = fs.readdirSync(blogDir, { recursive: true })
|
|
93
|
+
.filter((f): f is string => typeof f === "string" && (f.endsWith(".md") || f.endsWith(".mdx")))
|
|
94
|
+
.map((file) => path.join(blogDir, file));
|
|
95
|
+
|
|
96
|
+
console.log('No. of blog pages identified:', blogFiles.length);
|
|
97
|
+
|
|
98
|
+
const { posts: blogs } = await buildSection('blog', sourceDir, outputDir, blogFiles, isRelease, inscribe, navState);
|
|
99
|
+
|
|
100
|
+
await fs.ensureDir(path.join(outputDir, "blogs"));
|
|
101
|
+
let blogIndex = renderSectionIndexPage('blog', blogs, {}, inscribe, sourceDir, navState);
|
|
102
|
+
if (isRelease) blogIndex = await minifyHtml(blogIndex);
|
|
103
|
+
await fs.writeFile(path.join(outputDir, "blogs", "index.html"), blogIndex);
|
|
104
|
+
|
|
105
|
+
if (!redirectUrl) redirectUrl = "/blogs/";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build docs
|
|
109
|
+
if (hasDocs && docDir) {
|
|
110
|
+
const docFiles = fs.readdirSync(docDir, { recursive: true })
|
|
111
|
+
.filter((f): f is string => typeof f === "string" && (f.endsWith(".md") || f.endsWith(".mdx")))
|
|
112
|
+
.map((file) => path.join(docDir, file));
|
|
113
|
+
|
|
114
|
+
console.log('No. of doc pages identified:', docFiles.length);
|
|
115
|
+
|
|
116
|
+
// folderMetadata is computed once inside buildSection and reused for the index page
|
|
117
|
+
const { posts: docs, folderMetadata } = await buildSection('doc', sourceDir, outputDir, docFiles, isRelease, inscribe, navState);
|
|
118
|
+
|
|
119
|
+
await fs.ensureDir(path.join(outputDir, "docs"));
|
|
120
|
+
if (docs.length > 0) {
|
|
121
|
+
const firstLevelDoc = docs.find(p => !((p as any).relativePath).includes('/') && !((p as any).relativePath).includes('\\'));
|
|
122
|
+
const firstDocSlug = (firstLevelDoc || docs[0]).metadata.slug;
|
|
123
|
+
const redirectHtml = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=/doc/${firstDocSlug}"></head><body>Redirecting...</body></html>`;
|
|
124
|
+
await fs.writeFile(path.join(outputDir, "docs", "index.html"), redirectHtml);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!redirectUrl) redirectUrl = "/docs/";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Generate index.html
|
|
131
|
+
let indexPage = "";
|
|
132
|
+
if (hasHome) {
|
|
133
|
+
indexPage = renderHomePage(inscribe, sourceDir, navState);
|
|
134
|
+
} else if (redirectUrl) {
|
|
135
|
+
indexPage = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${redirectUrl}"></head><body>Redirecting...</body></html>`;
|
|
136
|
+
} else {
|
|
137
|
+
indexPage = `<!DOCTYPE html><html><body>No content available.</body></html>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isRelease && hasHome) {
|
|
141
|
+
indexPage = await minifyHtml(indexPage);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await fs.writeFile(path.join(outputDir, "index.html"), indexPage);
|
|
145
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { parse as yamlParse } from 'yaml';
|
|
3
|
+
import { InscribeSchema, InscribeConfig } from "../schemas/inscribe";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
export const readInscribeFile = async (rootDir: string): Promise<InscribeConfig> => {
|
|
7
|
+
const filePath = path.join(rootDir, "inscribe.yaml");
|
|
8
|
+
if (!fs.existsSync(filePath)) {
|
|
9
|
+
return InscribeSchema.parse({});
|
|
10
|
+
}
|
|
11
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
12
|
+
const parsedYaml = yamlParse(content);
|
|
13
|
+
return InscribeSchema.parse(parsedYaml);
|
|
14
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import nunjucks from "nunjucks";
|
|
4
|
+
import { markdown2HTML, parseFrontMatter } from "../utils/markdown";
|
|
5
|
+
import { Blog, BlogScehma } from "../schemas/blog";
|
|
6
|
+
import { InscribeConfig } from "../schemas/inscribe";
|
|
7
|
+
import { resolveThemeCSS } from "./theme_resolver";
|
|
8
|
+
|
|
9
|
+
export const parseBlogPost = async (filePath: string) => {
|
|
10
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
11
|
+
const { data, body } = parseFrontMatter(content);
|
|
12
|
+
const isMDX = filePath.endsWith(".mdx");
|
|
13
|
+
|
|
14
|
+
// Validate with Zod
|
|
15
|
+
const validated = BlogScehma.parse({
|
|
16
|
+
metadata: data,
|
|
17
|
+
markdown: body,
|
|
18
|
+
isMDX
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return validated;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Configure Nunjucks with a search path:
|
|
26
|
+
* 1. User project layouts
|
|
27
|
+
* 2. CLI built-in layouts
|
|
28
|
+
*/
|
|
29
|
+
const getRenderer = (sourceDir: string) => {
|
|
30
|
+
const userLayouts = path.resolve(sourceDir, "layouts");
|
|
31
|
+
const builtInLayouts = path.resolve(__dirname, "../../template/layouts");
|
|
32
|
+
|
|
33
|
+
const searchPaths = [userLayouts, builtInLayouts];
|
|
34
|
+
|
|
35
|
+
return new nunjucks.Environment(
|
|
36
|
+
new nunjucks.FileSystemLoader(searchPaths),
|
|
37
|
+
{ autoescape: true }
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
import { FolderMetadata } from "../schemas/folder";
|
|
42
|
+
|
|
43
|
+
export interface NavState {
|
|
44
|
+
hasHome: boolean;
|
|
45
|
+
hasBlog: boolean;
|
|
46
|
+
hasDocs: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SidebarItem =
|
|
50
|
+
| { type: 'folder'; node: SidebarNode }
|
|
51
|
+
| { type: 'file'; post: Blog };
|
|
52
|
+
|
|
53
|
+
export interface SidebarNode {
|
|
54
|
+
title: string;
|
|
55
|
+
path: string;
|
|
56
|
+
weight: number;
|
|
57
|
+
items: SidebarItem[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const getSidebarTree = (posts: Blog[], folderMetadata: Record<string, FolderMetadata> = {}): SidebarNode[] => {
|
|
61
|
+
// Root node — holds top-level files and sub-folder nodes
|
|
62
|
+
const root: SidebarNode = { title: '', path: '', weight: 0, items: [] };
|
|
63
|
+
|
|
64
|
+
// Map from path string to its SidebarNode (for quick lookup)
|
|
65
|
+
const nodeMap = new Map<string, SidebarNode>();
|
|
66
|
+
nodeMap.set('', root);
|
|
67
|
+
|
|
68
|
+
// Helper: ensure all ancestor nodes exist for a given path
|
|
69
|
+
const ensureNode = (dirPath: string): SidebarNode => {
|
|
70
|
+
if (nodeMap.has(dirPath)) return nodeMap.get(dirPath)!;
|
|
71
|
+
|
|
72
|
+
// Ensure parent exists first
|
|
73
|
+
const parentPath = path.dirname(dirPath).replace(/\\/g, '/');
|
|
74
|
+
const parent = ensureNode(parentPath === '.' ? '' : parentPath);
|
|
75
|
+
|
|
76
|
+
const meta = folderMetadata[dirPath] || { title: path.basename(dirPath), weight: 0 };
|
|
77
|
+
const node: SidebarNode = {
|
|
78
|
+
title: meta.title || path.basename(dirPath),
|
|
79
|
+
path: dirPath,
|
|
80
|
+
weight: meta.weight || 0,
|
|
81
|
+
items: [],
|
|
82
|
+
};
|
|
83
|
+
nodeMap.set(dirPath, node);
|
|
84
|
+
parent.items.push({ type: 'folder', node });
|
|
85
|
+
return node;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Place each post in the correct node
|
|
89
|
+
for (const post of posts) {
|
|
90
|
+
let dir = path.dirname((post as any).relativePath || '').replace(/\\/g, '/');
|
|
91
|
+
if (dir === '.') dir = '';
|
|
92
|
+
const node = ensureNode(dir);
|
|
93
|
+
node.items.push({ type: 'file', post });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Recursively sort items within each node
|
|
97
|
+
const sortNode = (node: SidebarNode) => {
|
|
98
|
+
node.items.sort((a, b) => {
|
|
99
|
+
const wA = a.type === 'folder' ? a.node.weight : (a.post.metadata.weight ?? 0);
|
|
100
|
+
const wB = b.type === 'folder' ? b.node.weight : (b.post.metadata.weight ?? 0);
|
|
101
|
+
|
|
102
|
+
if (wA !== wB) return wA - wB;
|
|
103
|
+
|
|
104
|
+
const tA = a.type === 'folder' ? a.node.title : a.post.metadata.title;
|
|
105
|
+
const tB = b.type === 'folder' ? b.node.title : b.post.metadata.title;
|
|
106
|
+
return tA.localeCompare(tB);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const item of node.items) {
|
|
110
|
+
if (item.type === 'folder') {
|
|
111
|
+
sortNode(item.node);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
sortNode(root);
|
|
116
|
+
|
|
117
|
+
return root.items.length > 0 ? [root] : [];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const renderSectionPage = async (
|
|
121
|
+
type: 'blog' | 'doc',
|
|
122
|
+
post: Blog,
|
|
123
|
+
allPosts: Blog[],
|
|
124
|
+
folderMetadata: Record<string, FolderMetadata>,
|
|
125
|
+
inscribe: InscribeConfig,
|
|
126
|
+
sourceDir: string,
|
|
127
|
+
navState: NavState,
|
|
128
|
+
isDev: boolean = false
|
|
129
|
+
) => {
|
|
130
|
+
const html = await markdown2HTML(post.markdown, post.isMDX);
|
|
131
|
+
const env = getRenderer(sourceDir);
|
|
132
|
+
const themeCSS = resolveThemeCSS(inscribe.theme ?? 'default', sourceDir);
|
|
133
|
+
|
|
134
|
+
const template = type === 'blog' ? "blog.njk" : "doc.njk";
|
|
135
|
+
|
|
136
|
+
const sidebarTree = type === 'doc' ? getSidebarTree(allPosts, folderMetadata) : [];
|
|
137
|
+
const currentDirPath = path.dirname((post as any).relativePath || '').replace(/\\/g, '/').replace(/^\.$/, '');
|
|
138
|
+
|
|
139
|
+
return env.render(template, {
|
|
140
|
+
post, // rename to post instead of blog to be generic
|
|
141
|
+
allPosts,
|
|
142
|
+
sidebarTree,
|
|
143
|
+
currentDirPath,
|
|
144
|
+
blog: post,
|
|
145
|
+
doc: post,
|
|
146
|
+
config: inscribe,
|
|
147
|
+
content: html,
|
|
148
|
+
themeCSS,
|
|
149
|
+
navState,
|
|
150
|
+
isDev
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const renderSectionIndexPage = (
|
|
155
|
+
type: 'blog' | 'doc',
|
|
156
|
+
posts: Blog[],
|
|
157
|
+
folderMetadata: Record<string, FolderMetadata>,
|
|
158
|
+
inscribe: InscribeConfig,
|
|
159
|
+
sourceDir: string,
|
|
160
|
+
navState: NavState,
|
|
161
|
+
isDev: boolean = false
|
|
162
|
+
) => {
|
|
163
|
+
const env = getRenderer(sourceDir);
|
|
164
|
+
const themeCSS = resolveThemeCSS(inscribe.theme ?? 'default', sourceDir);
|
|
165
|
+
|
|
166
|
+
const template = type === 'blog' ? "blog_index.njk" : "doc_index.njk";
|
|
167
|
+
|
|
168
|
+
const sidebarTree = type === 'doc' ? getSidebarTree(posts, folderMetadata) : [];
|
|
169
|
+
|
|
170
|
+
return env.render(template, {
|
|
171
|
+
posts, // rename to posts
|
|
172
|
+
allPosts: posts,
|
|
173
|
+
sidebarTree,
|
|
174
|
+
currentDirPath: '',
|
|
175
|
+
blogs: posts,
|
|
176
|
+
docs: posts,
|
|
177
|
+
config: inscribe,
|
|
178
|
+
themeCSS,
|
|
179
|
+
navState,
|
|
180
|
+
isDev
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const renderHomePage = (
|
|
185
|
+
inscribe: InscribeConfig,
|
|
186
|
+
sourceDir: string,
|
|
187
|
+
navState: NavState,
|
|
188
|
+
isDev: boolean = false
|
|
189
|
+
) => {
|
|
190
|
+
const env = getRenderer(sourceDir);
|
|
191
|
+
const themeCSS = resolveThemeCSS(inscribe.theme ?? 'default', sourceDir);
|
|
192
|
+
|
|
193
|
+
return env.render("home.njk", {
|
|
194
|
+
config: inscribe,
|
|
195
|
+
themeCSS,
|
|
196
|
+
navState,
|
|
197
|
+
isDev
|
|
198
|
+
});
|
|
199
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { parseBlogPost, renderSectionPage, renderSectionIndexPage, renderHomePage, NavState } from "./renderer";
|
|
4
|
+
import { readInscribeFile } from "./inscribe_reader";
|
|
5
|
+
import { parseFolderMetadata } from "../utils/markdown";
|
|
6
|
+
import { Blog } from "../schemas/blog";
|
|
7
|
+
|
|
8
|
+
export const LocalServer = (sourceDir: string, isDev: boolean = false, port = 3000) => {
|
|
9
|
+
return {
|
|
10
|
+
port: port,
|
|
11
|
+
async fetch(req: Request, server: any) {
|
|
12
|
+
const url = new URL(req.url);
|
|
13
|
+
|
|
14
|
+
// Handle WebSocket upgrade
|
|
15
|
+
if (isDev && url.pathname === "/_reload") {
|
|
16
|
+
const success = server.upgrade(req);
|
|
17
|
+
if (success) return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Read inscribe config
|
|
21
|
+
const inscribe = await readInscribeFile(sourceDir);
|
|
22
|
+
|
|
23
|
+
const blogPath = inscribe.blog_path === "null" || !inscribe.blog_path ? null : inscribe.blog_path;
|
|
24
|
+
const docPath = inscribe.doc_path === "null" || !inscribe.doc_path ? null : inscribe.doc_path;
|
|
25
|
+
const blogDir = blogPath ? path.join(sourceDir, blogPath) : null;
|
|
26
|
+
const docDir = docPath ? path.join(sourceDir, docPath) : null;
|
|
27
|
+
|
|
28
|
+
const hasBlog = blogDir ? fs.existsSync(blogDir) : false;
|
|
29
|
+
const hasDocs = docDir ? fs.existsSync(docDir) : false;
|
|
30
|
+
const hasHome = inscribe.show_home !== false;
|
|
31
|
+
const navState: NavState = { hasHome, hasBlog, hasDocs };
|
|
32
|
+
|
|
33
|
+
// Helper: collect relative .md/.mdx file paths under a directory
|
|
34
|
+
const getFiles = (dir: string | null): string[] =>
|
|
35
|
+
dir && fs.existsSync(dir)
|
|
36
|
+
? (fs.readdirSync(dir, { recursive: true }) as string[]).filter(
|
|
37
|
+
(f) => f.endsWith(".md") || f.endsWith(".mdx")
|
|
38
|
+
)
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
// Helper: parse all posts in a directory, attaching relativePath and sorting
|
|
42
|
+
const getAllPosts = async (dir: string, files: string[]): Promise<Blog[]> => {
|
|
43
|
+
const posts = await Promise.all(
|
|
44
|
+
files
|
|
45
|
+
.filter((f) => !f.endsWith("index.md"))
|
|
46
|
+
.map(async (f) => {
|
|
47
|
+
const post = await parseBlogPost(path.join(dir, f));
|
|
48
|
+
(post as any).relativePath = f;
|
|
49
|
+
return post;
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
return posts.sort((a, b) => {
|
|
53
|
+
const wA = a.metadata.weight ?? 0;
|
|
54
|
+
const wB = b.metadata.weight ?? 0;
|
|
55
|
+
if (wA !== wB) return wA - wB;
|
|
56
|
+
return a.metadata.slug.localeCompare(b.metadata.slug);
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Helper: build folder-metadata map from index.md files
|
|
61
|
+
const getFolderMetadata = (dir: string, files: string[]): Record<string, any> => {
|
|
62
|
+
const meta: Record<string, any> = {};
|
|
63
|
+
for (const f of files) {
|
|
64
|
+
if (f.endsWith("index.md")) {
|
|
65
|
+
const absDir = path.dirname(path.join(dir, f));
|
|
66
|
+
const relDir = path.relative(dir, absDir);
|
|
67
|
+
meta[relDir.replace(/\\/g, '/') || ''] = parseFolderMetadata(absDir);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return meta;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const blogFiles = getFiles(blogDir);
|
|
74
|
+
const docFilesRaw = getFiles(docDir);
|
|
75
|
+
|
|
76
|
+
// Home page
|
|
77
|
+
if (url.pathname === "/") {
|
|
78
|
+
if (hasHome) {
|
|
79
|
+
const html = renderHomePage(inscribe, sourceDir, navState, isDev);
|
|
80
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
81
|
+
}
|
|
82
|
+
const redirectUrl = hasBlog ? "/blogs/" : hasDocs ? "/docs/" : "";
|
|
83
|
+
if (redirectUrl) return Response.redirect(`http://${url.host}${redirectUrl}`, 302);
|
|
84
|
+
return new Response("No content available", { status: 404 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Blogs index
|
|
88
|
+
if ((url.pathname === "/blogs" || url.pathname === "/blogs/") && hasBlog && blogDir) {
|
|
89
|
+
const blogs = await getAllPosts(blogDir, blogFiles);
|
|
90
|
+
const html = renderSectionIndexPage('blog', blogs, {}, inscribe, sourceDir, navState, isDev);
|
|
91
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Docs index
|
|
95
|
+
if ((url.pathname === "/docs" || url.pathname === "/docs/") && hasDocs && docDir) {
|
|
96
|
+
const docs = await getAllPosts(docDir, docFilesRaw);
|
|
97
|
+
if (docs.length > 0) {
|
|
98
|
+
const firstLevelDoc = docs.find(p => !((p as any).relativePath).includes('/') && !((p as any).relativePath).includes('\\'));
|
|
99
|
+
const firstDocSlug = (firstLevelDoc || docs[0]).metadata.slug;
|
|
100
|
+
return Response.redirect(`http://${url.host}/doc/${firstDocSlug}`, 302);
|
|
101
|
+
}
|
|
102
|
+
const docFolderMetadata = getFolderMetadata(docDir, docFilesRaw);
|
|
103
|
+
const html = renderSectionIndexPage('doc', docs, docFolderMetadata, inscribe, sourceDir, navState, isDev);
|
|
104
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Blog page — match by frontmatter slug, not filename
|
|
108
|
+
if (url.pathname.startsWith("/blog/") && blogDir) {
|
|
109
|
+
const slug = url.pathname.replace("/blog/", "").replace(/\/$/, "");
|
|
110
|
+
const blogs = await getAllPosts(blogDir, blogFiles);
|
|
111
|
+
const blog = blogs.find((p) => p.metadata.slug === slug);
|
|
112
|
+
if (blog) {
|
|
113
|
+
const fullHtml = await renderSectionPage('blog', blog, blogs, {}, inscribe, sourceDir, navState, isDev);
|
|
114
|
+
return new Response(fullHtml, { headers: { "Content-Type": "text/html" } });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Doc page — match by frontmatter slug, not filename
|
|
119
|
+
if (url.pathname.startsWith("/doc/") && docDir) {
|
|
120
|
+
const slug = url.pathname.replace("/doc/", "").replace(/\/$/, "");
|
|
121
|
+
const docs = await getAllPosts(docDir, docFilesRaw);
|
|
122
|
+
const docFolderMetadata = getFolderMetadata(docDir, docFilesRaw);
|
|
123
|
+
const doc = docs.find((p) => p.metadata.slug === slug);
|
|
124
|
+
if (doc) {
|
|
125
|
+
const fullHtml = await renderSectionPage('doc', doc, docs, docFolderMetadata, inscribe, sourceDir, navState, isDev);
|
|
126
|
+
return new Response(fullHtml, { headers: { "Content-Type": "text/html" } });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Response("Not Found", { status: 404 });
|
|
131
|
+
},
|
|
132
|
+
websocket: {
|
|
133
|
+
message(ws: any, message: string) { },
|
|
134
|
+
open(ws: any) {
|
|
135
|
+
ws.subscribe("reload");
|
|
136
|
+
},
|
|
137
|
+
close(ws: any) {
|
|
138
|
+
ws.unsubscribe("reload");
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a theme name to its CSS content.
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. <sourceDir>/themes/<themeName>.css (user override)
|
|
9
|
+
* 2. <builtInThemesDir>/<themeName>.css (built-in preset)
|
|
10
|
+
*
|
|
11
|
+
* Throws a descriptive error if neither location has the file.
|
|
12
|
+
*/
|
|
13
|
+
export const resolveThemeCSS = (themeName: string, sourceDir: string): string => {
|
|
14
|
+
const userThemePath = path.resolve(sourceDir, "themes", `${themeName}.css`);
|
|
15
|
+
const builtInThemePath = path.resolve(__dirname, "../../template/themes", `${themeName}.css`);
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(userThemePath)) {
|
|
18
|
+
return fs.readFileSync(userThemePath, "utf-8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (fs.existsSync(builtInThemePath)) {
|
|
22
|
+
return fs.readFileSync(builtInThemePath, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Theme "${themeName}" not found.\n` +
|
|
27
|
+
` Checked: ${userThemePath}\n` +
|
|
28
|
+
` Checked: ${builtInThemePath}\n` +
|
|
29
|
+
` Place a "${themeName}.css" file in your project's "themes/" folder, or use a built-in theme name.`
|
|
30
|
+
);
|
|
31
|
+
};
|