@bivabdas/sharq 1.0.0
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/LICENSE +21 -0
- package/README.md +83 -0
- package/bin/create-sharq.js +33 -0
- package/bin/sharq.js +57 -0
- package/index.js +4 -0
- package/lib/config.js +51 -0
- package/lib/generator.js +172 -0
- package/lib/package-info.js +8 -0
- package/lib/renderer.js +298 -0
- package/lib/scaffold.js +161 -0
- package/lib/server.js +199 -0
- package/lib/sitemap.js +21 -0
- package/package.json +36 -0
- package/scaffold/template/content/hello-sharq.md +17 -0
- package/scaffold/template/public/assets/site.css +224 -0
- package/scaffold/template/templates/archive.html +22 -0
- package/scaffold/template/templates/index.html +26 -0
- package/scaffold/template/templates/post.html +23 -0
package/lib/renderer.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return String(value).replace(/[&<>"']/g, (char) => {
|
|
6
|
+
const map = {
|
|
7
|
+
"&": "&",
|
|
8
|
+
"<": "<",
|
|
9
|
+
">": ">",
|
|
10
|
+
"\"": """,
|
|
11
|
+
"'": "'"
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return map[char];
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sanitizeHref(href) {
|
|
19
|
+
const normalizedHref = href.trim();
|
|
20
|
+
const loweredHref = normalizedHref.toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
loweredHref.startsWith("javascript:") ||
|
|
24
|
+
loweredHref.startsWith("data:") ||
|
|
25
|
+
loweredHref.startsWith("vbscript:")
|
|
26
|
+
) {
|
|
27
|
+
return "#";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return normalizedHref;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseFrontmatter(markdown) {
|
|
34
|
+
if (!markdown.startsWith("---")) {
|
|
35
|
+
throw new Error("Missing frontmatter block.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const match = markdown.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
39
|
+
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error("Invalid frontmatter format.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [, rawFrontmatter, content] = match;
|
|
45
|
+
const data = {};
|
|
46
|
+
|
|
47
|
+
for (const line of rawFrontmatter.split("\n")) {
|
|
48
|
+
if (!line.trim()) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const separatorIndex = line.indexOf(":");
|
|
53
|
+
|
|
54
|
+
if (separatorIndex === -1) {
|
|
55
|
+
throw new Error(`Invalid frontmatter line: ${line}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
59
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
60
|
+
data[key] = value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const field of ["title", "description", "date"]) {
|
|
64
|
+
if (!data[field]) {
|
|
65
|
+
throw new Error(`Missing frontmatter field: ${field}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (data.tags) {
|
|
70
|
+
data.tags = data.tags
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((tag) => tag.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
} else {
|
|
75
|
+
data.tags = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { data, content: content.trim() };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderInlineMarkdown(text) {
|
|
82
|
+
const linkedText = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
|
|
83
|
+
const safeLabel = escapeHtml(label);
|
|
84
|
+
const safeHref = escapeHtml(sanitizeHref(href));
|
|
85
|
+
return `<a href="${safeHref}">${safeLabel}</a>`;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return escapeHtml(linkedText)
|
|
89
|
+
.replace(/<a href="(.+?)">(.+?)<\/a>/g, '<a href="$1">$2</a>')
|
|
90
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
91
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
92
|
+
.replace(/`(.+?)`/g, "<code>$1</code>");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function markdownToHtml(markdown) {
|
|
96
|
+
const lines = markdown.split("\n");
|
|
97
|
+
const html = [];
|
|
98
|
+
let inList = false;
|
|
99
|
+
let listType = null;
|
|
100
|
+
let inCodeBlock = false;
|
|
101
|
+
let codeBuffer = [];
|
|
102
|
+
|
|
103
|
+
const flushList = () => {
|
|
104
|
+
if (inList) {
|
|
105
|
+
html.push(listType === "ol" ? "</ol>" : "</ul>");
|
|
106
|
+
inList = false;
|
|
107
|
+
listType = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const flushCodeBlock = () => {
|
|
112
|
+
if (inCodeBlock) {
|
|
113
|
+
html.push(`<pre><code>${escapeHtml(codeBuffer.join("\n"))}</code></pre>`);
|
|
114
|
+
inCodeBlock = false;
|
|
115
|
+
codeBuffer = [];
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (line.startsWith("```")) {
|
|
121
|
+
flushList();
|
|
122
|
+
if (inCodeBlock) {
|
|
123
|
+
flushCodeBlock();
|
|
124
|
+
} else {
|
|
125
|
+
inCodeBlock = true;
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (inCodeBlock) {
|
|
131
|
+
codeBuffer.push(line);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const trimmed = line.trim();
|
|
136
|
+
|
|
137
|
+
if (!trimmed) {
|
|
138
|
+
flushList();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (trimmed.startsWith("# ")) {
|
|
143
|
+
flushList();
|
|
144
|
+
html.push(`<h1>${renderInlineMarkdown(trimmed.slice(2))}</h1>`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (trimmed.startsWith("## ")) {
|
|
149
|
+
flushList();
|
|
150
|
+
html.push(`<h2>${renderInlineMarkdown(trimmed.slice(3))}</h2>`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (trimmed.startsWith("### ")) {
|
|
155
|
+
flushList();
|
|
156
|
+
html.push(`<h3>${renderInlineMarkdown(trimmed.slice(4))}</h3>`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (trimmed.startsWith("- ")) {
|
|
161
|
+
if (!inList || listType !== "ul") {
|
|
162
|
+
flushList();
|
|
163
|
+
html.push("<ul>");
|
|
164
|
+
inList = true;
|
|
165
|
+
listType = "ul";
|
|
166
|
+
}
|
|
167
|
+
html.push(`<li>${renderInlineMarkdown(trimmed.slice(2))}</li>`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (/^\d+\.\s/.test(trimmed)) {
|
|
172
|
+
if (!inList || listType !== "ol") {
|
|
173
|
+
flushList();
|
|
174
|
+
html.push("<ol>");
|
|
175
|
+
inList = true;
|
|
176
|
+
listType = "ol";
|
|
177
|
+
}
|
|
178
|
+
html.push(`<li>${renderInlineMarkdown(trimmed.replace(/^\d+\.\s/, ""))}</li>`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (trimmed.startsWith("> ")) {
|
|
183
|
+
flushList();
|
|
184
|
+
html.push(`<blockquote><p>${renderInlineMarkdown(trimmed.slice(2))}</p></blockquote>`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
flushList();
|
|
189
|
+
html.push(`<p>${renderInlineMarkdown(trimmed)}</p>`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
flushList();
|
|
193
|
+
flushCodeBlock();
|
|
194
|
+
|
|
195
|
+
return html.join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stripDuplicateTitleHeading(markdown, title) {
|
|
199
|
+
const lines = markdown.split("\n");
|
|
200
|
+
const firstContentLine = lines.findIndex((line) => line.trim());
|
|
201
|
+
|
|
202
|
+
if (firstContentLine === -1) {
|
|
203
|
+
return markdown;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const firstLine = lines[firstContentLine].trim();
|
|
207
|
+
if (firstLine === `# ${title}`) {
|
|
208
|
+
lines.splice(firstContentLine, 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return lines.join("\n").trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function applyTemplate(template, variables) {
|
|
215
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function loadTemplate(config, name) {
|
|
219
|
+
const templatePath = path.join(config.templatesDir, name);
|
|
220
|
+
return fs.readFile(templatePath, "utf8");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function renderPostPage(config, post) {
|
|
224
|
+
const template = await loadTemplate(config, "post.html");
|
|
225
|
+
const cleanedContent = stripDuplicateTitleHeading(post.content, post.title);
|
|
226
|
+
const tagsMarkup = post.tags
|
|
227
|
+
.map((tag) => `<li class="tag">${escapeHtml(tag)}</li>`)
|
|
228
|
+
.join("");
|
|
229
|
+
|
|
230
|
+
return applyTemplate(template, {
|
|
231
|
+
title: escapeHtml(post.title),
|
|
232
|
+
description: escapeHtml(post.description),
|
|
233
|
+
date: escapeHtml(post.date),
|
|
234
|
+
author: escapeHtml(post.author || "sharq"),
|
|
235
|
+
tags: tagsMarkup,
|
|
236
|
+
content: markdownToHtml(cleanedContent)
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function renderIndexPage(config, posts) {
|
|
241
|
+
const template = await loadTemplate(config, "index.html");
|
|
242
|
+
const items = posts
|
|
243
|
+
.map(
|
|
244
|
+
(post) =>
|
|
245
|
+
`<li><div><a href="/blog/${post.slug}">${escapeHtml(post.title)}</a><p>${escapeHtml(post.description)}</p></div><span>${escapeHtml(post.date)}</span></li>`
|
|
246
|
+
)
|
|
247
|
+
.join("\n");
|
|
248
|
+
|
|
249
|
+
return applyTemplate(template, {
|
|
250
|
+
title: escapeHtml(config.siteTitle),
|
|
251
|
+
description: escapeHtml(config.siteDescription),
|
|
252
|
+
date: "",
|
|
253
|
+
content: items || "<li>No posts available yet.</li>"
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function renderArchivePage(config, posts) {
|
|
258
|
+
const template = await loadTemplate(config, "archive.html");
|
|
259
|
+
const items = posts
|
|
260
|
+
.map((post) => {
|
|
261
|
+
const tagMarkup = post.tags.map((tag) => `<li class="tag">${escapeHtml(tag)}</li>`).join("");
|
|
262
|
+
return `
|
|
263
|
+
<article class="archive-item">
|
|
264
|
+
<div class="archive-heading">
|
|
265
|
+
<p class="eyebrow">${escapeHtml(post.date)}</p>
|
|
266
|
+
<h2><a href="/blog/${post.slug}">${escapeHtml(post.title)}</a></h2>
|
|
267
|
+
</div>
|
|
268
|
+
<p>${escapeHtml(post.description)}</p>
|
|
269
|
+
<p class="archive-meta">By ${escapeHtml(post.author || "sharq")}</p>
|
|
270
|
+
<ul class="tag-list">${tagMarkup}</ul>
|
|
271
|
+
</article>
|
|
272
|
+
`;
|
|
273
|
+
})
|
|
274
|
+
.join("\n");
|
|
275
|
+
|
|
276
|
+
return applyTemplate(template, {
|
|
277
|
+
title: `Archive | ${escapeHtml(config.siteTitle)}`,
|
|
278
|
+
description: escapeHtml(`Browse all posts on ${config.siteTitle}`),
|
|
279
|
+
date: "",
|
|
280
|
+
content: items || "<p>No posts available yet.</p>"
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function readPostSource(config, slug) {
|
|
285
|
+
const filePath = path.join(config.contentDir, `${slug}.md`);
|
|
286
|
+
const markdown = await fs.readFile(filePath, "utf8");
|
|
287
|
+
const { data, content } = parseFrontmatter(markdown);
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
slug,
|
|
291
|
+
title: data.title,
|
|
292
|
+
description: data.description,
|
|
293
|
+
date: data.date,
|
|
294
|
+
author: data.author || "",
|
|
295
|
+
tags: data.tags,
|
|
296
|
+
content
|
|
297
|
+
};
|
|
298
|
+
}
|
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
|
|
6
|
+
function toSlug(value) {
|
|
7
|
+
return value
|
|
8
|
+
.trim()
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function titleFromName(value) {
|
|
15
|
+
return value
|
|
16
|
+
.split(/[-_\s]+/)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
19
|
+
.join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function ensureEmptyOrNewDirectory(projectDir) {
|
|
23
|
+
try {
|
|
24
|
+
const entries = await fs.readdir(projectDir);
|
|
25
|
+
if (entries.length > 0) {
|
|
26
|
+
throw new Error(`Target directory is not empty: ${projectDir}`);
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === "ENOENT") {
|
|
30
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function copyDir(sourceDir, targetDir) {
|
|
39
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
40
|
+
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
44
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
45
|
+
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
await copyDir(sourcePath, targetPath);
|
|
48
|
+
} else {
|
|
49
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeDependencySpec(spec) {
|
|
55
|
+
if (!spec) {
|
|
56
|
+
return "latest";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return spec;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveLocalDependencySpec(projectDir, packageRoot) {
|
|
63
|
+
const relativePath = path.relative(projectDir, packageRoot) || ".";
|
|
64
|
+
return `file:${relativePath}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writePackageJson(projectDir, projectName, frameworkDependency) {
|
|
68
|
+
const packageJson = {
|
|
69
|
+
name: projectName,
|
|
70
|
+
version: "0.1.0",
|
|
71
|
+
private: true,
|
|
72
|
+
type: "module",
|
|
73
|
+
scripts: {
|
|
74
|
+
dev: "sharq dev",
|
|
75
|
+
start: "sharq start",
|
|
76
|
+
build: "sharq build"
|
|
77
|
+
},
|
|
78
|
+
dependencies: {
|
|
79
|
+
sharq: frameworkDependency
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await fs.writeFile(path.join(projectDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeConfig(projectDir, { siteTitle, siteDescription }) {
|
|
87
|
+
const configSource = `export default {
|
|
88
|
+
siteTitle: ${JSON.stringify(siteTitle)},
|
|
89
|
+
siteDescription: ${JSON.stringify(siteDescription)}
|
|
90
|
+
};
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
await fs.writeFile(path.join(projectDir, "sharq.config.js"), configSource, "utf8");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function writeReadme(projectDir, { projectName }) {
|
|
97
|
+
const readme = `# ${projectName}
|
|
98
|
+
|
|
99
|
+
Generated with sharq.
|
|
100
|
+
|
|
101
|
+
## Scripts
|
|
102
|
+
|
|
103
|
+
- \`npm run dev\`
|
|
104
|
+
- \`npm start\`
|
|
105
|
+
- \`npm run build\`
|
|
106
|
+
|
|
107
|
+
## Structure
|
|
108
|
+
|
|
109
|
+
- \`content/\` markdown posts
|
|
110
|
+
- \`templates/\` page templates
|
|
111
|
+
- \`public/assets/\` static assets
|
|
112
|
+
- \`sharq/\` production build output
|
|
113
|
+
- \`sharq.config.js\` project-level settings
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
await fs.writeFile(path.join(projectDir, "README.md"), readme, "utf8");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function scaffoldProject(options = {}) {
|
|
120
|
+
const packageRoot = options.packageRoot;
|
|
121
|
+
|
|
122
|
+
let answers = {
|
|
123
|
+
projectName: options.projectName || "",
|
|
124
|
+
siteTitle: options.siteTitle || "",
|
|
125
|
+
siteDescription: options.siteDescription || ""
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (!answers.projectName) {
|
|
129
|
+
const rl = readline.createInterface({ input, output });
|
|
130
|
+
const requestedName = await rl.question("Project name: ");
|
|
131
|
+
const projectName = toSlug(requestedName) || "my-sharq-site";
|
|
132
|
+
const siteTitleInput = await rl.question(`Site title (${titleFromName(projectName)}): `);
|
|
133
|
+
const siteDescriptionInput = await rl.question("Site description (A sharq site): ");
|
|
134
|
+
rl.close();
|
|
135
|
+
|
|
136
|
+
answers = {
|
|
137
|
+
projectName,
|
|
138
|
+
siteTitle: siteTitleInput.trim() || titleFromName(projectName),
|
|
139
|
+
siteDescription: siteDescriptionInput.trim() || "A sharq site"
|
|
140
|
+
};
|
|
141
|
+
} else {
|
|
142
|
+
answers.projectName = toSlug(answers.projectName);
|
|
143
|
+
answers.siteTitle = answers.siteTitle || titleFromName(answers.projectName);
|
|
144
|
+
answers.siteDescription = answers.siteDescription || "A sharq site";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const projectDir = path.resolve(process.cwd(), answers.projectName);
|
|
148
|
+
await ensureEmptyOrNewDirectory(projectDir);
|
|
149
|
+
|
|
150
|
+
const frameworkDependency = options.useLocalDependency
|
|
151
|
+
? resolveLocalDependencySpec(projectDir, packageRoot)
|
|
152
|
+
: normalizeDependencySpec(options.frameworkVersion);
|
|
153
|
+
|
|
154
|
+
const templateDir = path.join(packageRoot, "scaffold", "template");
|
|
155
|
+
await copyDir(templateDir, projectDir);
|
|
156
|
+
await writePackageJson(projectDir, answers.projectName, frameworkDependency);
|
|
157
|
+
await writeConfig(projectDir, answers);
|
|
158
|
+
await writeReadme(projectDir, answers);
|
|
159
|
+
|
|
160
|
+
return { projectDir, frameworkDependency, ...answers };
|
|
161
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveConfig } from "./config.js";
|
|
5
|
+
import { ensurePostGenerated, refreshSiteArtifacts } from "./generator.js";
|
|
6
|
+
|
|
7
|
+
const contentTypes = {
|
|
8
|
+
".html": "text/html; charset=utf-8",
|
|
9
|
+
".xml": "application/xml; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".js": "application/javascript; charset=utf-8",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".svg": "image/svg+xml",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".webp": "image/webp",
|
|
18
|
+
".ico": "image/x-icon",
|
|
19
|
+
".txt": "text/plain; charset=utf-8"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function sendFile(response, filePath) {
|
|
23
|
+
const extension = path.extname(filePath);
|
|
24
|
+
const body = await fs.readFile(filePath);
|
|
25
|
+
response.writeHead(200, { "Content-Type": contentTypes[extension] || "text/plain; charset=utf-8" });
|
|
26
|
+
response.end(body);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sendHtml(response, statusCode, html) {
|
|
30
|
+
response.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" });
|
|
31
|
+
response.end(html);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function logRequest(method, pathname, statusCode) {
|
|
35
|
+
console.log(`[sharq] ${method} ${pathname} -> ${statusCode}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notFound(response, message = "Page not found.") {
|
|
39
|
+
sendHtml(response, 404, `<!doctype html><html><body><h1>404</h1><p>${message}</p></body></html>`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function servePublicPath(config, response, pathname) {
|
|
43
|
+
const relativePath = pathname.replace(/^\/+/, "");
|
|
44
|
+
const filePath = path.resolve(config.publicDir, relativePath);
|
|
45
|
+
|
|
46
|
+
if (!filePath.startsWith(config.publicDir)) {
|
|
47
|
+
throw new Error("Invalid public asset path.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await sendFile(response, filePath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createApp(config) {
|
|
54
|
+
let isReady = false;
|
|
55
|
+
|
|
56
|
+
async function handleRequest(request, response) {
|
|
57
|
+
if (!request.url) {
|
|
58
|
+
notFound(response);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const url = new URL(request.url, config.siteUrl);
|
|
63
|
+
const { pathname } = url;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (!isReady) {
|
|
67
|
+
sendHtml(
|
|
68
|
+
response,
|
|
69
|
+
503,
|
|
70
|
+
"<!doctype html><html><body><h1>503</h1><p>sharq is still preparing generated files.</p></body></html>"
|
|
71
|
+
);
|
|
72
|
+
logRequest(request.method || "GET", pathname, 503);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pathname === "/") {
|
|
77
|
+
await refreshSiteArtifacts(config);
|
|
78
|
+
await sendFile(response, config.homePagePath);
|
|
79
|
+
logRequest(request.method || "GET", pathname, 200);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (pathname === "/sitemap.xml") {
|
|
84
|
+
await refreshSiteArtifacts(config);
|
|
85
|
+
await sendFile(response, config.sitemapPath);
|
|
86
|
+
logRequest(request.method || "GET", pathname, 200);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (pathname === "/archive") {
|
|
91
|
+
await refreshSiteArtifacts(config);
|
|
92
|
+
await sendFile(response, config.archivePagePath);
|
|
93
|
+
logRequest(request.method || "GET", pathname, 200);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (pathname.startsWith("/blog/")) {
|
|
98
|
+
const slug = pathname.replace("/blog/", "").replace(/\/+$/, "");
|
|
99
|
+
|
|
100
|
+
if (!slug) {
|
|
101
|
+
notFound(response, "Missing blog slug.");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const filePath = await ensurePostGenerated(config, slug);
|
|
106
|
+
await sendFile(response, filePath);
|
|
107
|
+
logRequest(request.method || "GET", pathname, 200);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (pathname.startsWith("/assets/")) {
|
|
112
|
+
await servePublicPath(config, response, pathname);
|
|
113
|
+
logRequest(request.method || "GET", pathname, 200);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
notFound(response);
|
|
118
|
+
logRequest(request.method || "GET", pathname, 404);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error && error.code === "ENOENT") {
|
|
121
|
+
notFound(response, "Requested content does not exist.");
|
|
122
|
+
logRequest(request.method || "GET", pathname, 404);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (error instanceof Error && error.message === "Invalid slug format.") {
|
|
127
|
+
notFound(response, "Invalid blog slug.");
|
|
128
|
+
logRequest(request.method || "GET", pathname, 404);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (error instanceof Error && error.message === "Invalid public asset path.") {
|
|
133
|
+
notFound(response, "Invalid asset path.");
|
|
134
|
+
logRequest(request.method || "GET", pathname, 404);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
sendHtml(
|
|
139
|
+
response,
|
|
140
|
+
500,
|
|
141
|
+
`<!doctype html><html><body><h1>500</h1><p>${String(error.message || error)}</p></body></html>`
|
|
142
|
+
);
|
|
143
|
+
logRequest(request.method || "GET", pathname, 500);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function prepare() {
|
|
148
|
+
await refreshSiteArtifacts(config);
|
|
149
|
+
isReady = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createServer() {
|
|
153
|
+
return http.createServer((request, response) => {
|
|
154
|
+
handleRequest(request, response);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { prepare, createServer };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function listenOnAvailablePort(createServerInstance, port, attemptsLeft) {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const server = createServerInstance();
|
|
164
|
+
|
|
165
|
+
const handleError = (error) => {
|
|
166
|
+
server.close();
|
|
167
|
+
|
|
168
|
+
if (error.code === "EADDRINUSE" && attemptsLeft > 0) {
|
|
169
|
+
resolve(listenOnAvailablePort(createServerInstance, port + 1, attemptsLeft - 1));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
reject(error);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
server.once("error", handleError);
|
|
177
|
+
server.listen(port, () => {
|
|
178
|
+
server.off("error", handleError);
|
|
179
|
+
resolve({ server, port });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function startServer(options = {}) {
|
|
185
|
+
const rootDir = options.rootDir || process.cwd();
|
|
186
|
+
const config = await resolveConfig(rootDir);
|
|
187
|
+
const requestedPort = Number(options.port || process.env.PORT || 3000);
|
|
188
|
+
const maxPortAttempts = Number(options.maxPortAttempts || 20);
|
|
189
|
+
const app = createApp(config);
|
|
190
|
+
const { server, port } = await listenOnAvailablePort(() => app.createServer(), requestedPort, maxPortAttempts);
|
|
191
|
+
|
|
192
|
+
if (!process.env.SITE_URL) {
|
|
193
|
+
config.siteUrl = `http://localhost:${port}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await app.prepare();
|
|
197
|
+
console.log(`sharq running at http://localhost:${port}`);
|
|
198
|
+
return { server, port, config };
|
|
199
|
+
}
|
package/lib/sitemap.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
function buildUrl(config, pathname) {
|
|
4
|
+
return `${config.siteUrl}${pathname}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function generateSitemap(config, posts) {
|
|
8
|
+
const urls = [
|
|
9
|
+
buildUrl(config, "/"),
|
|
10
|
+
buildUrl(config, "/archive"),
|
|
11
|
+
...posts.map((post) => buildUrl(config, `/blog/${post.slug}`))
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
15
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
16
|
+
${urls.map((url) => ` <url><loc>${url}</loc></url>`).join("\n")}
|
|
17
|
+
</urlset>
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
await fs.writeFile(config.sitemapPath, xml, "utf8");
|
|
21
|
+
}
|