@stacksjs/actions 0.70.87 → 0.70.90
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/dist/action.js +78 -2
- package/dist/actions.js +2 -2
- package/dist/add.js +20 -2
- package/dist/ai/request-model-access.js +10 -2
- package/dist/auth/client.js +44 -3
- package/dist/auth/prune.js +35 -2
- package/dist/auth/setup.js +152 -36
- package/dist/auth/token.js +10 -2
- package/dist/blog-admin.d.ts +55 -0
- package/dist/blog-admin.js +156 -0
- package/dist/blog.d.ts +11 -0
- package/dist/blog.js +306 -56
- package/dist/build/cli.js +6 -2
- package/dist/build/component-libs.js +7 -2
- package/dist/build/core.js +55 -2
- package/dist/build/desktop.js +5 -2
- package/dist/build/docs.js +5 -2
- package/dist/build/frontend-static.js +123 -8
- package/dist/build/server.js +6 -2
- package/dist/build/stacks.js +8 -2
- package/dist/build/views.js +11 -2
- package/dist/build.js +64 -2
- package/dist/bump.js +162 -7
- package/dist/cdn/invalidate-cache.js +0 -1
- package/dist/changelog.js +27 -3
- package/dist/check/ports.js +16 -2
- package/dist/clean.js +4 -2
- package/dist/commit.js +12 -2
- package/dist/copy-types.js +10 -3
- package/dist/database/seed.js +26 -2
- package/dist/deploy/create-receipt-rule.js +36 -2
- package/dist/deploy/index.js +1318 -44
- package/dist/dev/api.js +94 -6
- package/dist/dev/components.js +7 -2
- package/dist/dev/dashboard-globals.js +89 -3
- package/dist/dev/dashboard-utils.js +380 -2
- package/dist/dev/dashboard.js +435 -8
- package/dist/dev/desktop.js +5 -2
- package/dist/dev/docs.js +24 -2
- package/dist/dev/index.js +29 -2
- package/dist/dev/migrate-watcher.js +74 -2
- package/dist/dev/system-tray.js +5 -2
- package/dist/dev/views.js +147 -2
- package/dist/discover-packages.js +32 -2
- package/dist/domains/add.js +61 -2
- package/dist/domains/purchase.js +66 -2
- package/dist/domains/remove.js +24 -2
- package/dist/examples.js +34 -2
- package/dist/find-projects.js +16 -2
- package/dist/fresh.js +17 -2
- package/dist/generate/action-types.js +9 -4
- package/dist/generate/component-meta.js +9 -3
- package/dist/generate/custom-cli-file.js +20 -3
- package/dist/generate/env-files.js +46 -6
- package/dist/generate/ide-helpers.js +8 -3
- package/dist/generate/index.js +158 -2
- package/dist/generate/lib-entries.js +16 -3
- package/dist/generate/pantry-file.js +46 -2
- package/dist/generate/symlink-core.js +2 -2
- package/dist/generate/types.js +3 -2
- package/dist/generate/vscode-custom-data.js +9 -3
- package/dist/generate/web-types.js +0 -1
- package/dist/helpers/component-meta.js +295 -2
- package/dist/helpers/index.js +1 -2
- package/dist/helpers/lib-entries.js +73 -2
- package/dist/helpers/package-json.js +45 -14
- package/dist/helpers/utils.js +105 -2
- package/dist/helpers/vscode-custom-data.js +30 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.js +50 -2
- package/dist/key-generate.js +24 -2
- package/dist/lint/fix.js +4 -3
- package/dist/lint/index.js +4 -3
- package/dist/lint/lint.js +34 -2
- package/dist/make-command.js +64 -14
- package/dist/make-job.js +41 -16
- package/dist/make-policy.js +74 -26
- package/dist/make-resource.js +50 -21
- package/dist/make.js +328 -2
- package/dist/migrate/database.js +16 -2
- package/dist/migrate/fresh.js +31 -2
- package/dist/orm/base.js +0 -2
- package/dist/orm/test.js +0 -1
- package/dist/prepublish.js +16 -2
- package/dist/queue/clear.js +42 -2
- package/dist/queue/dlq-purge.js +28 -2
- package/dist/queue/dlq-retry.js +35 -2
- package/dist/queue/dlq.js +55 -5
- package/dist/queue/failed.js +69 -5
- package/dist/queue/flush.js +49 -3
- package/dist/queue/inspect.js +154 -8
- package/dist/queue/list.js +69 -5
- package/dist/queue/monitor.js +94 -6
- package/dist/queue/pause.js +28 -2
- package/dist/queue/quarantine.js +42 -3
- package/dist/queue/resume.js +27 -2
- package/dist/queue/retry.js +41 -2
- package/dist/queue/schedule-list.js +33 -5
- package/dist/queue/schedule.js +12 -2
- package/dist/queue/status.js +68 -5
- package/dist/queue/table.js +10 -2
- package/dist/queue/unquarantine.js +27 -2
- package/dist/queue/work.js +57 -2
- package/dist/release.js +21 -3
- package/dist/route/list.js +78 -3
- package/dist/run-action.js +1 -2
- package/dist/saas/setup.js +9 -2
- package/dist/scaffold-crud.js +164 -40
- package/dist/schedule/run.js +7 -2
- package/dist/search/flush.js +10 -2
- package/dist/search/import.js +10 -2
- package/dist/search/settings-list.js +10 -2
- package/dist/search/settings.js +9 -2
- package/dist/serve/api.js +35 -2
- package/dist/setup/index.js +1 -2
- package/dist/setup/ssl.js +271 -2
- package/dist/stacks.js +265 -2
- package/dist/templates.js +301 -2
- package/dist/test/feature.js +11 -2
- package/dist/test/index.js +11 -2
- package/dist/test/ui.js +4 -2
- package/dist/test/unit.js +11 -2
- package/dist/typecheck.js +4 -2
- package/dist/types.js +16 -2
- package/dist/upgrade/binary.js +29 -2
- package/dist/upgrade/bun.js +0 -1
- package/dist/upgrade/dependencies.js +0 -2
- package/dist/upgrade/framework-utils.js +193 -2
- package/dist/upgrade/framework.js +310 -7
- package/dist/upgrade/index.js +17 -2
- package/dist/upgrade/packages.js +99 -12
- package/dist/upgrade/shell.js +40 -4
- package/dist/upgrade.js +38 -2
- package/package.json +16 -16
- package/dist/chunk-0c8sd1t6.js +0 -2
- package/dist/chunk-0g2nzhx9.js +0 -240
- package/dist/chunk-0gadhvn7.js +0 -3
- package/dist/chunk-0h27aa4r.js +0 -93
- package/dist/chunk-3peh8kqs.js +0 -851
- package/dist/chunk-62gdfav8.js +0 -3
- package/dist/chunk-6bk53y02.js +0 -3
- package/dist/chunk-78c5n3s2.js +0 -3
- package/dist/chunk-c74qv38e.js +0 -17
- package/dist/chunk-cdk2r2jd.js +0 -1
- package/dist/chunk-dbz9dp2h.js +0 -3
- package/dist/chunk-e142dheq.js +0 -3
- package/dist/chunk-ec4gky40.js +0 -3
- package/dist/chunk-h3d81wrg.js +0 -3
- package/dist/chunk-m3q0x4h0.js +0 -688
- package/dist/chunk-mqgbyhph.js +0 -5
- package/dist/chunk-n24m8prb.js +0 -3
- package/dist/chunk-n5eqwhck.js +0 -7
- package/dist/chunk-nsvnqv8y.js +0 -5
- package/dist/chunk-nty6mmx4.js +0 -283
- package/dist/chunk-s735m9mg.js +0 -1
- package/dist/chunk-tpb8jgsk.js +0 -7
- package/dist/chunk-tsbk5vne.js +0 -3
- package/dist/chunk-xg9xscqz.js +0 -1
- package/dist/chunk-y6e448he.js +0 -7
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const CONTENT_DIR = join(process.cwd(), "content/blog"), FIELD_ORDER = ["title", "description", "date", "author", "authorBio", "poster", "featured", "draft"];
|
|
4
|
+
|
|
5
|
+
export class BlogAdminError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(message, status = 422) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "BlogAdminError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function slugify(input) {
|
|
14
|
+
return String(input).normalize("NFKD").replace(/[\u0300-\u036F]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
15
|
+
}
|
|
16
|
+
function assertSafeSlug(slug) {
|
|
17
|
+
if (!slug)
|
|
18
|
+
throw new BlogAdminError("A slug is required.");
|
|
19
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
|
|
20
|
+
throw new BlogAdminError(`Invalid slug "${slug}". Use lowercase letters, numbers, and hyphens.`);
|
|
21
|
+
return slug;
|
|
22
|
+
}
|
|
23
|
+
function parseFrontmatter(md) {
|
|
24
|
+
const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
25
|
+
if (!m)
|
|
26
|
+
return { data: {}, body: md };
|
|
27
|
+
const data = {};
|
|
28
|
+
for (const line of (m[1] ?? "").split(`
|
|
29
|
+
`)) {
|
|
30
|
+
const i = line.indexOf(":");
|
|
31
|
+
if (i === -1)
|
|
32
|
+
continue;
|
|
33
|
+
const key = line.slice(0, i).trim();
|
|
34
|
+
let val = line.slice(i + 1).trim();
|
|
35
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'"))
|
|
36
|
+
val = val.slice(1, -1);
|
|
37
|
+
data[key] = val;
|
|
38
|
+
}
|
|
39
|
+
return { data, body: m[2] ?? "" };
|
|
40
|
+
}
|
|
41
|
+
function serialiseValue(key, value) {
|
|
42
|
+
if (/[\r\n]/.test(value))
|
|
43
|
+
throw new BlogAdminError(`"${key}" cannot contain line breaks.`);
|
|
44
|
+
if (!(/^["']|["']$|^\s|\s$|:\s/.test(value) || value === ""))
|
|
45
|
+
return value;
|
|
46
|
+
if (value.includes('"'))
|
|
47
|
+
return `'${value}'`;
|
|
48
|
+
return `"${value}"`;
|
|
49
|
+
}
|
|
50
|
+
function toFileContents(post) {
|
|
51
|
+
const fm = [];
|
|
52
|
+
for (const key of FIELD_ORDER) {
|
|
53
|
+
const raw = post[key];
|
|
54
|
+
if (typeof raw === "boolean") {
|
|
55
|
+
if (raw)
|
|
56
|
+
fm.push(`${key}: true`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const value = String(raw ?? "");
|
|
60
|
+
if (!value)
|
|
61
|
+
continue;
|
|
62
|
+
fm.push(`${key}: ${serialiseValue(key, value)}`);
|
|
63
|
+
}
|
|
64
|
+
const body = post.body.replace(/\s+$/, "");
|
|
65
|
+
return `---
|
|
66
|
+
${fm.join(`
|
|
67
|
+
`)}
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
${body}
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
function readingTimeFor(body) {
|
|
74
|
+
const wordCount = body.split(/\s+/).filter(Boolean).length;
|
|
75
|
+
return { wordCount, readingTime: Math.max(1, Math.round(wordCount / 200)) };
|
|
76
|
+
}
|
|
77
|
+
function hydrate(slug, raw, updatedAt) {
|
|
78
|
+
const { data, body } = parseFrontmatter(raw), { wordCount, readingTime } = readingTimeFor(body);
|
|
79
|
+
return {
|
|
80
|
+
slug,
|
|
81
|
+
title: data.title ?? "",
|
|
82
|
+
description: data.description ?? "",
|
|
83
|
+
date: data.date ?? "",
|
|
84
|
+
author: data.author ?? "",
|
|
85
|
+
authorBio: data.authorBio ?? "",
|
|
86
|
+
poster: data.poster ?? "",
|
|
87
|
+
featured: data.featured === "true",
|
|
88
|
+
draft: data.draft === "true",
|
|
89
|
+
body: body.trim(),
|
|
90
|
+
wordCount,
|
|
91
|
+
readingTime,
|
|
92
|
+
updatedAt
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function fileFor(slug) {
|
|
96
|
+
return join(CONTENT_DIR, `${assertSafeSlug(slug)}.md`);
|
|
97
|
+
}
|
|
98
|
+
export function listBlogPosts() {
|
|
99
|
+
if (!existsSync(CONTENT_DIR))
|
|
100
|
+
return [];
|
|
101
|
+
return readdirSync(CONTENT_DIR).filter((f) => f.endsWith(".md") && !f.startsWith(".")).map((f) => {
|
|
102
|
+
const path = join(CONTENT_DIR, f);
|
|
103
|
+
return hydrate(f.replace(/\.md$/, ""), readFileSync(path, "utf-8"), statSync(path).mtime.toISOString());
|
|
104
|
+
}).filter((p) => !!p.title).sort((a, b) => {
|
|
105
|
+
if (a.featured !== b.featured)
|
|
106
|
+
return a.featured ? -1 : 1;
|
|
107
|
+
return (b.date || "").localeCompare(a.date || "");
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export function getBlogPost(slug) {
|
|
111
|
+
const file = fileFor(slug);
|
|
112
|
+
if (!existsSync(file))
|
|
113
|
+
return null;
|
|
114
|
+
return hydrate(slug, readFileSync(file, "utf-8"), statSync(file).mtime.toISOString());
|
|
115
|
+
}
|
|
116
|
+
export function saveBlogPost(input) {
|
|
117
|
+
const title = String(input.title ?? "").trim();
|
|
118
|
+
if (!title)
|
|
119
|
+
throw new BlogAdminError("A title is required.");
|
|
120
|
+
const slug = assertSafeSlug(String(input.slug ?? "").trim() || slugify(title)), originalSlug = input.originalSlug ? assertSafeSlug(input.originalSlug) : "", isRename = !!originalSlug && originalSlug !== slug;
|
|
121
|
+
if (existsSync(fileFor(slug)) && (!originalSlug || isRename))
|
|
122
|
+
throw new BlogAdminError(`A post with the slug "${slug}" already exists.`, 409);
|
|
123
|
+
if (originalSlug && !existsSync(fileFor(originalSlug)))
|
|
124
|
+
throw new BlogAdminError(`No post found with the slug "${originalSlug}".`, 404);
|
|
125
|
+
const date = String(input.date ?? "").trim() || new Date().toISOString().slice(0, 10);
|
|
126
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
127
|
+
throw new BlogAdminError("Date must be in YYYY-MM-DD format.");
|
|
128
|
+
const body = String(input.body ?? "").trim(), { wordCount, readingTime } = readingTimeFor(body), post = {
|
|
129
|
+
slug,
|
|
130
|
+
title,
|
|
131
|
+
description: String(input.description ?? "").trim(),
|
|
132
|
+
date,
|
|
133
|
+
author: String(input.author ?? "").trim(),
|
|
134
|
+
authorBio: String(input.authorBio ?? "").trim(),
|
|
135
|
+
poster: String(input.poster ?? "").trim(),
|
|
136
|
+
featured: input.featured === !0,
|
|
137
|
+
draft: input.draft === !0,
|
|
138
|
+
body,
|
|
139
|
+
wordCount,
|
|
140
|
+
readingTime,
|
|
141
|
+
updatedAt: new Date().toISOString()
|
|
142
|
+
};
|
|
143
|
+
if (!existsSync(CONTENT_DIR))
|
|
144
|
+
mkdirSync(CONTENT_DIR, { recursive: !0 });
|
|
145
|
+
writeFileSync(fileFor(slug), toFileContents(post), "utf-8");
|
|
146
|
+
if (isRename)
|
|
147
|
+
unlinkSync(fileFor(originalSlug));
|
|
148
|
+
return post;
|
|
149
|
+
}
|
|
150
|
+
export function deleteBlogPost(slug) {
|
|
151
|
+
const file = fileFor(slug);
|
|
152
|
+
if (!existsSync(file))
|
|
153
|
+
return !1;
|
|
154
|
+
unlinkSync(file);
|
|
155
|
+
return !0;
|
|
156
|
+
}
|
package/dist/blog.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An origin safe to bake into absolute links, or '' when it is not usable.
|
|
3
|
+
*
|
|
4
|
+
* The deploy-time build takes its baseUrl from `APP_URL`, which is not always
|
|
5
|
+
* plaintext: a release that loads an env file it cannot decrypt leaves the
|
|
6
|
+
* literal ciphertext there, and prefixing it with `https://` produced feed and
|
|
7
|
+
* sitemap URLs like `https://encrypted:tLq7…==/blog/introducing-stacks` on the
|
|
8
|
+
* live site. Anything that is not a parseable http(s) origin with a dotted
|
|
9
|
+
* hostname is rejected here so callers fall back to the configured site url.
|
|
10
|
+
*/
|
|
11
|
+
export declare function usableOrigin(value?: string): string;
|
|
1
12
|
/**
|
|
2
13
|
* Render a blog request via BunPress, or return null to let the caller handle
|
|
3
14
|
* non-blog paths. Handles the listing, posts, the RSS feed, and the sitemap.
|
package/dist/blog.js
CHANGED
|
@@ -1,30 +1,155 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const CONTENT_DIR = join(process.cwd(), "content/blog"), THEME_FILE = join(CONTENT_DIR, ".theme.css"), STACKS_DEFAULTS = {
|
|
5
|
+
title: "The Stacks Blog",
|
|
6
|
+
description: "Notes from building a full-stack TypeScript framework whose only dependencies are TypeScript and Bun.",
|
|
7
|
+
siteTitle: "Stacks Blog",
|
|
8
|
+
author: "The Stacks Team",
|
|
9
|
+
url: "https://stacksjs.com",
|
|
10
|
+
nav: [
|
|
11
|
+
{ text: "Blog", link: "/blog" },
|
|
12
|
+
{ text: "Docs", link: "/docs" },
|
|
13
|
+
{ text: "GitHub", link: "https://github.com/stacksjs/stacks" }
|
|
14
|
+
],
|
|
15
|
+
themes: ["colored", "light", "dark"],
|
|
16
|
+
defaultTheme: "colored",
|
|
17
|
+
colophon: 'Built with Stacks \xB7 TypeScript & Bun \xB7 <a href="/blog/feed.xml">RSS</a>',
|
|
18
|
+
emptyTitle: "No posts yet",
|
|
19
|
+
emptyText: "The first one is in the works. Leave your email and it will land in your inbox the moment it ships."
|
|
20
|
+
};
|
|
21
|
+
let sitePromise = null;
|
|
22
|
+
function site() {
|
|
23
|
+
if (!sitePromise)
|
|
24
|
+
sitePromise = (async () => {
|
|
25
|
+
try {
|
|
26
|
+
const cfg = (await import(join(process.cwd(), "config/blog.ts"))).default ?? {}, merged = { ...STACKS_DEFAULTS, ...cfg };
|
|
27
|
+
if (!cfg.siteTitle && cfg.title)
|
|
28
|
+
merged.siteTitle = cfg.title;
|
|
29
|
+
return merged;
|
|
30
|
+
} catch {
|
|
31
|
+
return STACKS_DEFAULTS;
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
return sitePromise;
|
|
35
|
+
}
|
|
36
|
+
let bunpressPromise = null;
|
|
37
|
+
async function loadBunPress() {
|
|
38
|
+
if (!bunpressPromise)
|
|
39
|
+
bunpressPromise = (async () => {
|
|
40
|
+
const candidates = [
|
|
41
|
+
join(homedir(), "Code/Tools/bunpress/packages/bunpress/dist/src/index.js"),
|
|
42
|
+
join(process.cwd(), "pantry/@stacksjs/bunpress/dist/src/index.js"),
|
|
43
|
+
"@stacksjs/bunpress"
|
|
44
|
+
];
|
|
45
|
+
for (const entry of candidates)
|
|
46
|
+
try {
|
|
47
|
+
if (entry.startsWith("@") || existsSync(entry))
|
|
48
|
+
return await import(entry);
|
|
49
|
+
} catch {}
|
|
50
|
+
return null;
|
|
51
|
+
})();
|
|
52
|
+
return bunpressPromise;
|
|
53
|
+
}
|
|
54
|
+
function escapeHtml(s) {
|
|
55
|
+
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
56
|
+
}
|
|
57
|
+
function escapeXml(s) {
|
|
58
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
59
|
+
}
|
|
60
|
+
export function usableOrigin(value) {
|
|
61
|
+
if (!value)
|
|
62
|
+
return "";
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(/^https?:\/\//.test(value) ? value : `https://${value}`);
|
|
65
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
66
|
+
return "";
|
|
67
|
+
if (!url.hostname.includes(".") || !/^[a-z0-9.-]+$/i.test(url.hostname))
|
|
68
|
+
return "";
|
|
69
|
+
return url.origin;
|
|
70
|
+
} catch {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function postUrl(baseUrl, slug) {
|
|
75
|
+
return `${baseUrl.replace(/\/$/, "")}/blog/${slug}`;
|
|
76
|
+
}
|
|
77
|
+
function rssDate(date) {
|
|
78
|
+
if (!date)
|
|
79
|
+
return "";
|
|
80
|
+
const parsed = new Date(date);
|
|
81
|
+
return Number.isNaN(parsed.getTime()) ? date : parsed.toUTCString();
|
|
82
|
+
}
|
|
83
|
+
async function fallbackFeedXml(baseUrl) {
|
|
84
|
+
const cfg = await site(), origin = usableOrigin(baseUrl) || usableOrigin(cfg.url) || cfg.url, items = listPosts().slice(0, 50).map((p) => {
|
|
85
|
+
const url = postUrl(origin, p.slug);
|
|
86
|
+
return ` <item>
|
|
87
|
+
<title>${escapeXml(p.fm.title || p.slug)}</title>
|
|
88
|
+
<link>${escapeXml(url)}</link>
|
|
89
|
+
<guid>${escapeXml(url)}</guid>
|
|
90
|
+
<pubDate>${escapeXml(rssDate(p.fm.date))}</pubDate>
|
|
91
|
+
<description>${escapeXml(p.fm.description || "")}</description>
|
|
92
|
+
<dc:creator>${escapeXml(p.fm.author || cfg.author)}</dc:creator>
|
|
93
|
+
</item>`;
|
|
94
|
+
}).join(`
|
|
95
|
+
`);
|
|
96
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
97
|
+
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
11
98
|
<channel>
|
|
12
|
-
<title>${
|
|
13
|
-
<link>${
|
|
14
|
-
<description>${
|
|
15
|
-
${
|
|
99
|
+
<title>${escapeXml(cfg.title)}</title>
|
|
100
|
+
<link>${escapeXml(`${origin}/blog`)}</link>
|
|
101
|
+
<description>${escapeXml(cfg.description)}</description>
|
|
102
|
+
${items}
|
|
16
103
|
</channel>
|
|
17
|
-
</rss
|
|
104
|
+
</rss>`;
|
|
105
|
+
}
|
|
106
|
+
async function fallbackSitemapXml(baseUrl) {
|
|
107
|
+
const cfg = await site(), origin = usableOrigin(baseUrl) || usableOrigin(cfg.url) || cfg.url;
|
|
108
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
18
109
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
19
|
-
${[`${
|
|
110
|
+
${[`${origin}/blog`, ...listPosts().map((p) => postUrl(origin, p.slug))].map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`).join(`
|
|
20
111
|
`)}
|
|
21
|
-
</urlset
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
112
|
+
</urlset>`;
|
|
113
|
+
}
|
|
114
|
+
function parseFrontmatter(md) {
|
|
115
|
+
const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
116
|
+
if (!m)
|
|
117
|
+
return { data: {}, body: md };
|
|
118
|
+
const data = {}, frontmatter = m[1] ?? "";
|
|
119
|
+
for (const line of frontmatter.split(`
|
|
120
|
+
`)) {
|
|
121
|
+
const i = line.indexOf(":");
|
|
122
|
+
if (i === -1)
|
|
123
|
+
continue;
|
|
124
|
+
const key = line.slice(0, i).trim();
|
|
125
|
+
let val = line.slice(i + 1).trim();
|
|
126
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'"))
|
|
127
|
+
val = val.slice(1, -1);
|
|
128
|
+
data[key] = val;
|
|
129
|
+
}
|
|
130
|
+
return { data, body: m[2] ?? "" };
|
|
131
|
+
}
|
|
132
|
+
function formatDate(d) {
|
|
133
|
+
if (!d)
|
|
134
|
+
return "";
|
|
135
|
+
const date = new Date(d);
|
|
136
|
+
if (Number.isNaN(date.getTime()))
|
|
137
|
+
return d;
|
|
138
|
+
return `${["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`;
|
|
139
|
+
}
|
|
140
|
+
const THEME_BUTTONS = {
|
|
141
|
+
colored: { label: "Park theme", glyph: "\uD83C\uDF32" },
|
|
142
|
+
light: { label: "Light theme", glyph: "\u2600" },
|
|
143
|
+
dark: { label: "Dark theme", glyph: "\uD83C\uDF19" }
|
|
144
|
+
};
|
|
145
|
+
async function blogChrome() {
|
|
146
|
+
const cfg = await site(), modes = cfg.themes.length ? cfg.themes : STACKS_DEFAULTS.themes, fallback = modes.includes(cfg.defaultTheme) ? cfg.defaultTheme : modes[0], buttons = modes.map((m) => `<button type="button" data-t="${m}" title="${THEME_BUTTONS[m].label}" aria-label="${THEME_BUTTONS[m].label}" onclick="stxBlogTheme('${m}')">${THEME_BUTTONS[m].glyph}</button>`).join(`
|
|
147
|
+
`), toggle = modes.length > 1 ? `<div class="blog-theme-toggle" role="group" aria-label="Theme">
|
|
148
|
+
${buttons}
|
|
149
|
+
</div>` : "";
|
|
150
|
+
return `<script>
|
|
26
151
|
(function () {
|
|
27
|
-
var modes = ${JSON.stringify(
|
|
152
|
+
var modes = ${JSON.stringify(modes)}
|
|
28
153
|
function ok(theme) {
|
|
29
154
|
return modes.indexOf(theme) !== -1
|
|
30
155
|
}
|
|
@@ -35,68 +160,132 @@ ${[`${J}/blog`,...x().map((W)=>S(J,W.slug))].map((W)=>` <url><loc>${L(W)}</loc>
|
|
|
35
160
|
}
|
|
36
161
|
catch {}
|
|
37
162
|
|
|
163
|
+
// 'stacks-theme' is the shared key; the marketing page writes it too, so a
|
|
164
|
+
// choice made on / carries to /blog. 'stacks-blog-theme' is the old
|
|
165
|
+
// blog-only key, read once so an existing choice is not thrown away.
|
|
38
166
|
var savedTheme = ''
|
|
39
167
|
try {
|
|
40
|
-
savedTheme = localStorage.getItem('stacks-blog-theme') || ''
|
|
168
|
+
savedTheme = localStorage.getItem('stacks-theme') || localStorage.getItem('stacks-blog-theme') || ''
|
|
169
|
+
}
|
|
170
|
+
catch {}
|
|
171
|
+
|
|
172
|
+
// A visitor who has never chosen gets their OS preference rather than a
|
|
173
|
+
// hard-coded default, which is what prefers-color-scheme is for. The
|
|
174
|
+
// configured default still answers for everyone who prefers light.
|
|
175
|
+
var prefersDark = false
|
|
176
|
+
try {
|
|
177
|
+
prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
41
178
|
}
|
|
42
179
|
catch {}
|
|
43
180
|
|
|
44
|
-
var
|
|
181
|
+
var systemTheme = (prefersDark && ok('dark')) ? 'dark' : ${JSON.stringify(fallback)}
|
|
182
|
+
var theme = ok(queryTheme) ? queryTheme : (ok(savedTheme) ? savedTheme : systemTheme)
|
|
45
183
|
document.documentElement.setAttribute('data-theme', theme)
|
|
46
184
|
window.stxBlogTheme = function (nextTheme) {
|
|
47
185
|
try {
|
|
48
|
-
localStorage.setItem('stacks-
|
|
186
|
+
localStorage.setItem('stacks-theme', nextTheme)
|
|
49
187
|
}
|
|
50
188
|
catch {}
|
|
51
189
|
document.documentElement.setAttribute('data-theme', nextTheme)
|
|
52
190
|
}
|
|
53
191
|
})()
|
|
54
192
|
</script>
|
|
55
|
-
${
|
|
193
|
+
${toggle}`;
|
|
194
|
+
}
|
|
195
|
+
async function blogFooter() {
|
|
196
|
+
return `<div class="blog-landscape" aria-hidden="true">
|
|
56
197
|
<span class="ridge"></span>
|
|
57
198
|
<span class="river"></span>
|
|
58
|
-
<p class="colophon">${(await
|
|
59
|
-
</div
|
|
60
|
-
|
|
199
|
+
<p class="colophon">${(await site()).colophon}</p>
|
|
200
|
+
</div>`;
|
|
201
|
+
}
|
|
202
|
+
async function blogConfig(bp, fm) {
|
|
203
|
+
const cfg = await site(), themeCss = existsSync(THEME_FILE) ? readFileSync(THEME_FILE, "utf-8") : "", baseCss = bp.defaultConfig.markdown?.css || "";
|
|
204
|
+
return {
|
|
205
|
+
...bp.defaultConfig,
|
|
206
|
+
title: fm.title || cfg.title,
|
|
207
|
+
description: fm.description || cfg.description,
|
|
208
|
+
docsDir: CONTENT_DIR,
|
|
209
|
+
theme: "vitepress",
|
|
210
|
+
markdown: { ...bp.defaultConfig.markdown, css: `${baseCss}
|
|
211
|
+
${themeCss}` },
|
|
212
|
+
themeConfig: {
|
|
213
|
+
siteTitle: cfg.siteTitle,
|
|
214
|
+
nav: cfg.nav
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function listPosts() {
|
|
219
|
+
if (!existsSync(CONTENT_DIR))
|
|
220
|
+
return [];
|
|
221
|
+
return readdirSync(CONTENT_DIR).filter((f) => f.endsWith(".md")).map((f) => {
|
|
222
|
+
const { data } = parseFrontmatter(readFileSync(join(CONTENT_DIR, f), "utf-8"));
|
|
223
|
+
return { slug: f.replace(/\.md$/, ""), fm: data };
|
|
224
|
+
}).filter((p) => !!p.fm.title && !!p.fm.date && p.fm.draft !== "true").sort((a, b) => {
|
|
225
|
+
const fa = a.fm.featured === "true" ? 1 : 0, fb = b.fm.featured === "true" ? 1 : 0;
|
|
226
|
+
if (fa !== fb)
|
|
227
|
+
return fb - fa;
|
|
228
|
+
return (b.fm.date || "").localeCompare(a.fm.date || "");
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async function notFoundHtml(bp) {
|
|
232
|
+
const body = `
|
|
61
233
|
<a class="blog-back" href="/blog">\u2190 All posts</a>
|
|
62
234
|
<div class="blog-404">
|
|
63
235
|
<span class="blog-404-art" aria-hidden="true"></span>
|
|
64
236
|
<h1>Nothing here</h1>
|
|
65
237
|
<p>This post doesn't exist (or it moved). Head back to <a href="/blog">all posts</a>.</p>
|
|
66
|
-
</div
|
|
238
|
+
</div>`;
|
|
239
|
+
return bp.wrapInLayout(await blogChrome() + body + await blogFooter(), await blogConfig(bp, { title: "Not found" }), "/blog", "page");
|
|
240
|
+
}
|
|
241
|
+
async function postHtml(bp, slug, origin) {
|
|
242
|
+
const cfg = await site(), file = join(CONTENT_DIR, `${slug}.md`);
|
|
243
|
+
if (!existsSync(file))
|
|
244
|
+
return null;
|
|
245
|
+
const raw = readFileSync(file, "utf-8"), { html, frontmatter: fm } = await bp.markdownToHtml(raw, CONTENT_DIR);
|
|
246
|
+
if (!fm.title || !fm.date || fm.draft === !0 || fm.draft === "true")
|
|
247
|
+
return null;
|
|
248
|
+
const author = fm.author || cfg.author, initial = escapeHtml(author.charAt(0).toUpperCase()), header = `
|
|
67
249
|
<a class="blog-back" href="/blog">\u2190 All posts</a>
|
|
68
250
|
<div class="blog-post-head">
|
|
69
|
-
<h1>${
|
|
251
|
+
<h1>${escapeHtml(fm.title || slug)}</h1>
|
|
70
252
|
<p class="blog-post-meta">
|
|
71
|
-
<span class="author">${
|
|
253
|
+
<span class="author">${escapeHtml(author)}</span>
|
|
72
254
|
<span class="dot">\xB7</span>
|
|
73
|
-
<time>${
|
|
255
|
+
<time>${escapeHtml(formatDate(fm.date))}</time>
|
|
74
256
|
</p>
|
|
75
|
-
${
|
|
76
|
-
</div>`,
|
|
77
|
-
<div class="avatar">${
|
|
78
|
-
<div><span class="name">${
|
|
79
|
-
</aside
|
|
257
|
+
${fm.poster ? `<figure class="blog-post-poster"><img src="${escapeHtml(fm.poster)}" alt="${escapeHtml(fm.title || "")}"></figure>` : ""}
|
|
258
|
+
</div>`, authorCard = fm.authorBio ? `<aside class="blog-author-card">
|
|
259
|
+
<div class="avatar">${initial}</div>
|
|
260
|
+
<div><span class="name">${escapeHtml(author)}</span><p class="bio">${escapeHtml(fm.authorBio)}</p></div>
|
|
261
|
+
</aside>` : "", shareUrl = `${origin}/blog/${slug}`, share = `
|
|
80
262
|
<div class="blog-share">
|
|
81
263
|
<span class="blog-share-label">Share</span>
|
|
82
|
-
<a href="https://twitter.com/intent/tweet?url=${encodeURIComponent(
|
|
83
|
-
<a href="https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(
|
|
84
|
-
</div>`,
|
|
264
|
+
<a href="https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(fm.title || "")}" target="_blank" rel="noreferrer">X / Twitter</a>
|
|
265
|
+
<a href="https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}" target="_blank" rel="noreferrer">LinkedIn</a>
|
|
266
|
+
</div>`, others = listPosts().filter((p) => p.slug !== slug).slice(0, 3), more = others.length ? `<nav class="blog-more">
|
|
85
267
|
<h2>More from the blog</h2>
|
|
86
|
-
<ul>${
|
|
87
|
-
</nav
|
|
88
|
-
|
|
268
|
+
<ul>${others.map((p) => `<li><a href="/blog/${escapeHtml(p.slug)}">${escapeHtml(p.fm.title || p.slug)}</a></li>`).join("")}</ul>
|
|
269
|
+
</nav>` : "";
|
|
270
|
+
return bp.wrapInLayout(await blogChrome() + header + html + authorCard + share + more + await blogFooter(), await blogConfig(bp, fm), `/blog/${slug}`, "page");
|
|
271
|
+
}
|
|
272
|
+
async function indexHtml(bp) {
|
|
273
|
+
const cfg = await site(), cards = listPosts().map((p) => {
|
|
274
|
+
const featured = p.fm.featured === "true", media = p.fm.poster ? `<div class="blog-card-media"><img src="${escapeHtml(p.fm.poster)}" alt="" loading="lazy"></div>` : "";
|
|
275
|
+
return `<a class="blog-card${featured ? " is-featured" : ""}" href="/blog/${escapeHtml(p.slug)}">
|
|
276
|
+
${media}
|
|
89
277
|
<div>
|
|
90
|
-
${
|
|
91
|
-
<h2 class="blog-card-title">${
|
|
92
|
-
<p class="blog-card-excerpt">${
|
|
93
|
-
<div class="blog-card-meta">${
|
|
278
|
+
${featured ? '<span class="blog-card-flag">Featured</span>' : ""}
|
|
279
|
+
<h2 class="blog-card-title">${escapeHtml(p.fm.title || p.slug)}</h2>
|
|
280
|
+
<p class="blog-card-excerpt">${escapeHtml((p.fm.description || "").slice(0, 180))}</p>
|
|
281
|
+
<div class="blog-card-meta">${escapeHtml(p.fm.author || cfg.author)} \xB7 ${escapeHtml(formatDate(p.fm.date))}</div>
|
|
94
282
|
</div>
|
|
95
|
-
</a
|
|
96
|
-
`
|
|
283
|
+
</a>`;
|
|
284
|
+
}).join(`
|
|
285
|
+
`), emptyState = `
|
|
97
286
|
<div class="blog-empty">
|
|
98
|
-
<h2>${
|
|
99
|
-
<p>${
|
|
287
|
+
<h2>${escapeHtml(cfg.emptyTitle)}</h2>
|
|
288
|
+
<p>${escapeHtml(cfg.emptyText)}</p>
|
|
100
289
|
<form class="blog-subscribe" method="POST" action="/api/email/subscribe">
|
|
101
290
|
<input type="email" name="email" placeholder="you@example.com" autocomplete="email" aria-label="Email address" required>
|
|
102
291
|
<input type="hidden" name="source" value="blog-empty">
|
|
@@ -139,10 +328,71 @@ ${Y}`},themeConfig:{siteTitle:J.siteTitle,nav:J.nav}}}function x(){if(!R(M))retu
|
|
|
139
328
|
.finally(function () { if (button) button.disabled = false })
|
|
140
329
|
})
|
|
141
330
|
})()
|
|
142
|
-
</script>`,
|
|
331
|
+
</script>`, body = `
|
|
143
332
|
<div class="blog-listing-head">
|
|
144
333
|
<span class="sign" aria-hidden="true"></span>
|
|
145
|
-
<h1>${
|
|
146
|
-
<p>${
|
|
334
|
+
<h1>${escapeHtml(cfg.title)}</h1>
|
|
335
|
+
<p>${escapeHtml(cfg.description)}</p>
|
|
147
336
|
</div>
|
|
148
|
-
<div class="blog-cards">${
|
|
337
|
+
<div class="blog-cards">${cards || emptyState}</div>`;
|
|
338
|
+
return bp.wrapInLayout(await blogChrome() + body + await blogFooter(), await blogConfig(bp, { title: cfg.title, description: cfg.description }), "/blog", "page");
|
|
339
|
+
}
|
|
340
|
+
async function feedXml(baseUrl) {
|
|
341
|
+
return fallbackFeedXml(baseUrl);
|
|
342
|
+
}
|
|
343
|
+
async function sitemapXml(baseUrl) {
|
|
344
|
+
return fallbackSitemapXml(baseUrl);
|
|
345
|
+
}
|
|
346
|
+
export async function renderBlog(req) {
|
|
347
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
348
|
+
return null;
|
|
349
|
+
const url = new URL(req.url), { pathname } = url, isFeed = pathname === "/blog/feed.xml", isSitemap = pathname === "/blog/sitemap.xml", isPage = pathname === "/blog" || pathname === "/blog/" || /^\/blog\/[^/.]+$/.test(pathname);
|
|
350
|
+
if (!isFeed && !isSitemap && !isPage)
|
|
351
|
+
return null;
|
|
352
|
+
const bp = await loadBunPress();
|
|
353
|
+
if (!bp)
|
|
354
|
+
return null;
|
|
355
|
+
const html = (s, status = 200) => new Response(s, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
356
|
+
if (isFeed)
|
|
357
|
+
return new Response(await feedXml(url.origin), { headers: { "Content-Type": "application/rss+xml; charset=utf-8" } });
|
|
358
|
+
if (isSitemap)
|
|
359
|
+
return new Response(await sitemapXml(url.origin), { headers: { "Content-Type": "application/xml; charset=utf-8" } });
|
|
360
|
+
if (pathname === "/blog" || pathname === "/blog/")
|
|
361
|
+
return html(await indexHtml(bp));
|
|
362
|
+
const slug = pathname.replace(/^\/blog\//, "").replace(/\/$/, ""), post = await postHtml(bp, slug, url.origin);
|
|
363
|
+
return post ? html(post) : html(await notFoundHtml(bp), 404);
|
|
364
|
+
}
|
|
365
|
+
export async function renderBlogFeed(req) {
|
|
366
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
367
|
+
return null;
|
|
368
|
+
const { pathname } = new URL(req.url);
|
|
369
|
+
if (pathname === "/blog/feed.xml")
|
|
370
|
+
return new Response(await fallbackFeedXml(""), { headers: { "Content-Type": "application/rss+xml; charset=utf-8" } });
|
|
371
|
+
if (pathname === "/blog/sitemap.xml")
|
|
372
|
+
return new Response(await fallbackSitemapXml(""), { headers: { "Content-Type": "application/xml; charset=utf-8" } });
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
export async function buildBlog(options = { outDir: "dist/blog" }) {
|
|
376
|
+
const outDir = options.outDir || "dist/blog", siteUrl = (await site()).url, baseUrl = usableOrigin(options.baseUrl) || usableOrigin(siteUrl) || siteUrl.replace(/\/$/, "");
|
|
377
|
+
if (options.baseUrl && !usableOrigin(options.baseUrl))
|
|
378
|
+
console.warn(`[blog] ignoring unusable baseUrl ${JSON.stringify(options.baseUrl)} \u2014 falling back to ${baseUrl}`);
|
|
379
|
+
const bp = await loadBunPress();
|
|
380
|
+
if (!bp)
|
|
381
|
+
throw Error("[blog] BunPress not found \u2014 cannot build the blog.");
|
|
382
|
+
mkdirSync(outDir, { recursive: !0 });
|
|
383
|
+
const write = (rel, content) => {
|
|
384
|
+
const target = join(outDir, rel);
|
|
385
|
+
mkdirSync(join(target, ".."), { recursive: !0 });
|
|
386
|
+
writeFileSync(target, content, "utf-8");
|
|
387
|
+
};
|
|
388
|
+
write("index.html", await indexHtml(bp));
|
|
389
|
+
const posts = listPosts();
|
|
390
|
+
for (const p of posts) {
|
|
391
|
+
const html = await postHtml(bp, p.slug, baseUrl);
|
|
392
|
+
if (html)
|
|
393
|
+
write(join(p.slug, "index.html"), html);
|
|
394
|
+
}
|
|
395
|
+
write("feed.xml", await feedXml(baseUrl));
|
|
396
|
+
write("sitemap.xml", await sitemapXml(baseUrl));
|
|
397
|
+
console.log(`[blog] built ${posts.length} post(s) + listing, feed.xml, sitemap.xml \u2192 ${outDir}`);
|
|
398
|
+
}
|
package/dist/build/cli.js
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { log } from "@stacksjs/cli";
|
|
3
|
+
import { buddyPath } from "@stacksjs/path";
|
|
4
|
+
log.info("Building CLI...");
|
|
5
|
+
$.cwd(buddyPath());
|
|
6
|
+
await $`bun ${buddyPath("build.ts")}`;
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
import{
|
|
1
|
+
import { runCommand } from "@stacksjs/cli";
|
|
2
|
+
import { NpmScript } from "@stacksjs/enums";
|
|
3
|
+
import { frameworkPath } from "@stacksjs/path";
|
|
4
|
+
await runCommand(NpmScript.BuildComponents, {
|
|
5
|
+
cwd: frameworkPath(),
|
|
6
|
+
verbose: !0
|
|
7
|
+
});
|