wuppycms 0.0.0-stage → 0.2.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/dist/admin-fo4l4lu2.mjs +59 -0
- package/dist/astro.d.mts +20 -0
- package/dist/astro.mjs +302 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +210 -0
- package/dist/components-BrGAEPly.mjs +39 -0
- package/dist/config-CM8_-x_W.mjs +1242 -0
- package/dist/dist-D7fysV_5.mjs +9048 -0
- package/dist/frontmatter-DGaz9--t.mjs +24 -0
- package/dist/generate-iX4B_2U2.mjs +473 -0
- package/dist/i18n-CgLAHd9Q.mjs +79 -0
- package/dist/index-B6AHmgP9.d.mts +34 -0
- package/dist/index.d.mts +8636 -0
- package/dist/index.mjs +83 -0
- package/dist/live-runtime.d.mts +120 -0
- package/dist/live-runtime.mjs +207 -0
- package/dist/markdown.mjs +4 -0
- package/dist/mdx.d.mts +68 -0
- package/dist/mdx.mjs +4 -0
- package/dist/media-DGfJXgBA.mjs +356 -0
- package/dist/page-cache-middleware.d.mts +8 -0
- package/dist/page-cache-middleware.mjs +101 -0
- package/dist/publishing.d.mts +17 -0
- package/dist/publishing.mjs +42 -0
- package/dist/read-Bh4-a3Ap.mjs +18 -0
- package/dist/redirect-middleware.d.mts +6 -0
- package/dist/redirect-middleware.mjs +37 -0
- package/dist/render-DzH7Wil3.mjs +116 -0
- package/dist/routes/admin.d.mts +7 -0
- package/dist/routes/admin.mjs +10 -0
- package/dist/routes/api.d.mts +7 -0
- package/dist/routes/api.mjs +97 -0
- package/dist/routes/icons.d.mts +12 -0
- package/dist/routes/icons.mjs +36 -0
- package/dist/routes/robots.d.mts +7 -0
- package/dist/routes/robots.mjs +15 -0
- package/dist/routes/rss.d.mts +7 -0
- package/dist/routes/rss.mjs +20 -0
- package/dist/routes/sitemap.d.mts +7 -0
- package/dist/routes/sitemap.mjs +23 -0
- package/dist/routes/uploads.d.mts +7 -0
- package/dist/routes/uploads.mjs +11 -0
- package/dist/schemas-dHRJ1HeG.mjs +275 -0
- package/dist/seo.d.mts +125 -0
- package/dist/seo.mjs +130 -0
- package/dist/server-D92ryW2y.mjs +5883 -0
- package/dist/service-CgcvCG7l.mjs +353 -0
- package/dist/site-dF-xVPqe.mjs +148 -0
- package/dist/site.d.mts +28 -0
- package/dist/site.mjs +44 -0
- package/dist/sitemap-Ba0vil6a.mjs +46 -0
- package/dist/theme-pages-CRsBm1EX.mjs +246 -0
- package/dist/theme-seo-middleware.d.mts +7 -0
- package/dist/theme-seo-middleware.mjs +62 -0
- package/dist/visual-middleware.d.mts +7 -0
- package/dist/visual-middleware.mjs +71 -0
- package/package.json +72 -3
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { C as collectionSchema, _ as isPrivateAddress, d as resolveRedirect, f as createWebhookSender, g as fetchPublic, h as assertPublicUrl, l as redirectsSchema, m as UnsafeUrlError, n as loadConfig, p as webhooksSchema, t as defineConfig, v as redact } from "./config-CM8_-x_W.mjs";
|
|
2
|
+
import { n as serializeDocument, t as parseDocument } from "./frontmatter-DGaz9--t.mjs";
|
|
3
|
+
import { a as FilesystemContent, n as ContentService, s as assertSafeSlug, t as ContentExistsError } from "./service-CgcvCG7l.mjs";
|
|
4
|
+
import { a as AuditLog, c as resolveSessionPolicy, d as creditsOf, f as buildDashboard, i as sanitizeHtml, l as CSRF_HEADER, n as CommentService, o as resolveOAuthProviders, r as JsonStore, s as resolvePasskeyOptions, t as createServer, u as createApp } from "./server-D92ryW2y.mjs";
|
|
5
|
+
import "./generate-iX4B_2U2.mjs";
|
|
6
|
+
import "./theme-pages-CRsBm1EX.mjs";
|
|
7
|
+
import "./site-dF-xVPqe.mjs";
|
|
8
|
+
import { isPublished } from "./publishing.mjs";
|
|
9
|
+
import { i as settingsSchema, n as dataNames, r as dataSchemas, t as dataDefaults } from "./schemas-dHRJ1HeG.mjs";
|
|
10
|
+
import "./render-DzH7Wil3.mjs";
|
|
11
|
+
import { a as translationStatus, i as splitLocale, n as localeSwitcher, r as resolveLocale, t as hreflangAlternates } from "./i18n-CgLAHd9Q.mjs";
|
|
12
|
+
import { a as detectImage, n as MediaService, o as serveUpload, t as MAX_UPLOAD_BYTES } from "./media-DGfJXgBA.mjs";
|
|
13
|
+
import { buildRobots, buildSeo, buildSitemap, renderHead } from "./seo.mjs";
|
|
14
|
+
import { t as readData } from "./read-Bh4-a3Ap.mjs";
|
|
15
|
+
|
|
16
|
+
//#region src/data/helpers.ts
|
|
17
|
+
/** Template helpers: read the site data files under `.wuppy/data`. All take `root` for tests; templates omit it. */
|
|
18
|
+
/** The menu `name` in `locale`, else the menu without a locale. */
|
|
19
|
+
async function getMenu(name, locale, root) {
|
|
20
|
+
const named = (await readData("menus", [], root)).filter((m) => m.name === name);
|
|
21
|
+
return named.find((m) => locale && m.locale === locale) ?? named.find((m) => !m.locale) ?? null;
|
|
22
|
+
}
|
|
23
|
+
const trimSlash = (p) => p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
24
|
+
/** `/blog/*` matches the blog and everything below it; any other pattern must equal the path (trailing slash ignored). */
|
|
25
|
+
function pathMatches(pattern, path) {
|
|
26
|
+
if (pattern.endsWith("/*")) {
|
|
27
|
+
const base = trimSlash(pattern.slice(0, -2)) || "";
|
|
28
|
+
return trimSlash(path) === (base || "/") || trimSlash(path).startsWith(`${base}/`);
|
|
29
|
+
}
|
|
30
|
+
return trimSlash(pattern) === trimSlash(path);
|
|
31
|
+
}
|
|
32
|
+
/** Widgets of an area visible for the given locale, collection and page path, with HTML widgets already sanitized. */
|
|
33
|
+
async function getWidgetArea(name, where = {}, root) {
|
|
34
|
+
return ((await readData("widgets", [], root)).find((a) => a.name === name)?.widgets ?? []).filter((w) => !w.locales?.length || where.locale !== void 0 && w.locales.includes(where.locale)).filter((w) => !w.collections?.length || where.collection !== void 0 && w.collections.includes(where.collection)).filter((w) => !w.paths?.length || where.path !== void 0 && w.paths.some((p) => pathMatches(p, where.path))).map((w) => w.type === "html" ? {
|
|
35
|
+
...w,
|
|
36
|
+
body: sanitizeHtml(w.body)
|
|
37
|
+
} : w);
|
|
38
|
+
}
|
|
39
|
+
const localized = (b, locale) => {
|
|
40
|
+
const t = locale ? b.translations[locale] : void 0;
|
|
41
|
+
return t ? {
|
|
42
|
+
...b,
|
|
43
|
+
name: t.name || b.name,
|
|
44
|
+
bio: t.bio ?? b.bio
|
|
45
|
+
} : b;
|
|
46
|
+
};
|
|
47
|
+
/** A byline, with the name and bio of `locale` when translated. The email is never returned. */
|
|
48
|
+
async function getByline(slug, root, locale) {
|
|
49
|
+
const found = (await readData("bylines", [], root)).find((b) => b.slug === slug);
|
|
50
|
+
return found ? localized({
|
|
51
|
+
...found,
|
|
52
|
+
email: ""
|
|
53
|
+
}, locale) : null;
|
|
54
|
+
}
|
|
55
|
+
/** Credits of a `byline` field value, resolved to profiles; unknown slugs are skipped. */
|
|
56
|
+
async function getCredits(value, root, locale) {
|
|
57
|
+
const all = await readData("bylines", [], root);
|
|
58
|
+
return creditsOf(value).flatMap(({ byline, role }) => {
|
|
59
|
+
const found = all.find((b) => b.slug === byline);
|
|
60
|
+
return found ? [{
|
|
61
|
+
byline: localized({
|
|
62
|
+
...found,
|
|
63
|
+
email: ""
|
|
64
|
+
}, locale),
|
|
65
|
+
role
|
|
66
|
+
}] : [];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** The published section `slug`; the variant for `locale` wins over the language-neutral one. */
|
|
70
|
+
async function getSection(slug, root, locale) {
|
|
71
|
+
const all = (await readData("sections", [], root)).filter((s) => s.slug === slug && !s.draft);
|
|
72
|
+
return locale && all.find((s) => s.locale === locale) || all.find((s) => !s.locale) || null;
|
|
73
|
+
}
|
|
74
|
+
/** Published sections in the order of `slugs`; unknown and draft ones are skipped. */
|
|
75
|
+
async function getSections(slugs, root, locale) {
|
|
76
|
+
return (await Promise.all(slugs.map((slug) => getSection(slug, root, locale)))).flatMap((s) => s ?? []);
|
|
77
|
+
}
|
|
78
|
+
async function getTaxonomy(name, root) {
|
|
79
|
+
return (await readData("taxonomies", [], root)).find((t) => t.name === name) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { AuditLog, CSRF_HEADER, CommentService, ContentExistsError, ContentService, FilesystemContent, JsonStore, MAX_UPLOAD_BYTES, MediaService, UnsafeUrlError, assertPublicUrl, assertSafeSlug, buildDashboard, buildRobots, buildSeo, buildSitemap, collectionSchema, createApp, createServer, createWebhookSender, dataDefaults, dataNames, dataSchemas, defineConfig, detectImage, fetchPublic, getByline, getCredits, getMenu, getSection, getSections, getTaxonomy, getWidgetArea, hreflangAlternates, isPrivateAddress, isPublished, loadConfig, localeSwitcher, parseDocument, pathMatches, readData, redact, redirectsSchema, renderHead, resolveLocale, resolveOAuthProviders, resolvePasskeyOptions, resolveRedirect, resolveSessionPolicy, sanitizeHtml, serializeDocument, serveUpload, settingsSchema, splitLocale, translationStatus, webhooksSchema };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { MarkdownHeading, MarkdownSettings, markdownRenderer, mdxContent } from "./mdx.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/astro/live-runtime.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Runtime half of live content. The site's pages keep calling `getCollection()` / `getEntry()` from `astro:content`;
|
|
6
|
+
* for Wuppy collections these read the files on disk, so a save in the admin shows on the next request, no rebuild.
|
|
7
|
+
* Astro's own helpers (frontmatter parser, Markdown renderer, digest) are passed in by the generated module.
|
|
8
|
+
*/
|
|
9
|
+
interface AstroEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
collection: string;
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
body?: string;
|
|
14
|
+
filePath?: string;
|
|
15
|
+
digest?: string;
|
|
16
|
+
rendered?: {
|
|
17
|
+
html: string;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
deferredRender?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface SchemaLike {
|
|
23
|
+
safeParseAsync(data: unknown): Promise<{
|
|
24
|
+
success: boolean;
|
|
25
|
+
data?: unknown;
|
|
26
|
+
error?: {
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
/** `dir` is relative to the root or absolute (a theme reading the site's entries); `rename` is site -> theme field. */
|
|
32
|
+
type LiveCollection = {
|
|
33
|
+
name: string;
|
|
34
|
+
dir: string;
|
|
35
|
+
rename?: Record<string, string>;
|
|
36
|
+
};
|
|
37
|
+
interface LiveDeps {
|
|
38
|
+
root: string;
|
|
39
|
+
/** Defaults to the collections in the site's wuppy.config, read once at runtime. */
|
|
40
|
+
collections?: LiveCollection[];
|
|
41
|
+
/** The build-time collection: entries whose file did not change are returned from it as they are. */
|
|
42
|
+
built(name: string): Promise<AstroEntry[]>;
|
|
43
|
+
schema(name: string): SchemaLike | undefined;
|
|
44
|
+
parseFrontmatter(source: string): {
|
|
45
|
+
frontmatter: Record<string, unknown>;
|
|
46
|
+
content: string;
|
|
47
|
+
};
|
|
48
|
+
digest(source: string): Promise<string>;
|
|
49
|
+
renderMarkdown(body: string, frontmatter: Record<string, unknown>, filePath: string): Promise<AstroEntry["rendered"]>;
|
|
50
|
+
}
|
|
51
|
+
type PageBody = {
|
|
52
|
+
data: Record<string, unknown>;
|
|
53
|
+
headings: MarkdownHeading[];
|
|
54
|
+
html?: string;
|
|
55
|
+
Content?: unknown;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A theme's Markdown page body (`rel` like `about`), read on each request from the site's Pages entry, or from the theme
|
|
59
|
+
* file itself when the site has none. `.mdx` compiles to a component (theme components allowed); `.md` to HTML.
|
|
60
|
+
*/
|
|
61
|
+
declare function themePage(rel: string, themeFile: string, markdown?: MarkdownSettings): Promise<PageBody>;
|
|
62
|
+
/**
|
|
63
|
+
* The blocks added in the admin to a theme `.astro` page: the body of the site's Pages entry that names the page as its
|
|
64
|
+
* `template`, or undefined when there is none.
|
|
65
|
+
*/
|
|
66
|
+
declare function themeBody(rel: string, markdown?: MarkdownSettings): Promise<PageBody | undefined>;
|
|
67
|
+
/** Astro's default entry id: path without extension, each segment slugged like github-slugger. */
|
|
68
|
+
declare function defaultId(path: string): string;
|
|
69
|
+
declare function createLive(deps: LiveDeps): {
|
|
70
|
+
isLive: (name: string) => Promise<boolean>; /** Whether `render(entry)` must compile the body now instead of using Astro's built module. */
|
|
71
|
+
rendersAtRuntime: (entry: unknown) => boolean;
|
|
72
|
+
getCollection(name: string, filter?: (entry: AstroEntry) => unknown): Promise<AstroEntry[]>;
|
|
73
|
+
getEntry(name: string, id: string): Promise<AstroEntry | undefined>;
|
|
74
|
+
};
|
|
75
|
+
type Params = Record<string, string | number | undefined>;
|
|
76
|
+
type Factory = ((result: {
|
|
77
|
+
params?: Params;
|
|
78
|
+
}, props: Record<string, unknown>, slots: unknown) => unknown) & Record<string, unknown>;
|
|
79
|
+
type StaticPath = {
|
|
80
|
+
params: Params;
|
|
81
|
+
props?: Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
/** Astro's `paginate()` for a page rendered on request: same shape, URLs built from the route pattern (no `base`). */
|
|
84
|
+
declare function paginator(pattern: string): (data: unknown[], args?: {
|
|
85
|
+
pageSize?: number;
|
|
86
|
+
params?: Params;
|
|
87
|
+
props?: Record<string, unknown>;
|
|
88
|
+
}) => {
|
|
89
|
+
params: {
|
|
90
|
+
page: string | undefined;
|
|
91
|
+
};
|
|
92
|
+
props: {
|
|
93
|
+
page: {
|
|
94
|
+
data: unknown[];
|
|
95
|
+
start: number;
|
|
96
|
+
end: number;
|
|
97
|
+
size: number;
|
|
98
|
+
total: number;
|
|
99
|
+
currentPage: number;
|
|
100
|
+
lastPage: number;
|
|
101
|
+
url: {
|
|
102
|
+
current: string;
|
|
103
|
+
next: string | undefined;
|
|
104
|
+
prev: string | undefined;
|
|
105
|
+
first: string | undefined;
|
|
106
|
+
last: string | undefined;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
}[];
|
|
111
|
+
/**
|
|
112
|
+
* A page that used to be prerendered gets its props from `getStaticPaths()`. Rendered on request, Astro no longer
|
|
113
|
+
* calls it, so this wrapper does: it finds the path matching the request and passes its props, or answers 404.
|
|
114
|
+
*/
|
|
115
|
+
declare function livePage(factory: Factory, getStaticPaths: (opts: {
|
|
116
|
+
paginate: ReturnType<typeof paginator>;
|
|
117
|
+
routePattern: string;
|
|
118
|
+
}) => Promise<StaticPath[]>, pattern: string): Factory;
|
|
119
|
+
//#endregion
|
|
120
|
+
export { AstroEntry, LiveDeps, createLive, defaultId, livePage, markdownRenderer, mdxContent, paginator, themeBody, themePage };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { n as loadConfig } from "./config-CM8_-x_W.mjs";
|
|
2
|
+
import { t as parseDocument } from "./frontmatter-DGaz9--t.mjs";
|
|
3
|
+
import { a as pagesDir, i as loadSiteConfig, o as renameFields } from "./site-dF-xVPqe.mjs";
|
|
4
|
+
import { n as markdownRenderer, r as mdxContent, t as markdownContent } from "./render-DzH7Wil3.mjs";
|
|
5
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
6
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
//#region src/astro/live-runtime.ts
|
|
9
|
+
/** The collections in wuppy.config; a theme run for a site (WUPPY_BASE_ROOT) reads them from the site's folders. */
|
|
10
|
+
async function configured(root) {
|
|
11
|
+
const site = process.env.WUPPY_BASE_ROOT;
|
|
12
|
+
if (!site || resolve(site) === resolve(root)) return (await loadConfig(root)).collections;
|
|
13
|
+
const { links } = await loadSiteConfig(site, root);
|
|
14
|
+
return links.map((l) => ({
|
|
15
|
+
name: l.name,
|
|
16
|
+
dir: join(site, l.dir),
|
|
17
|
+
rename: l.rename
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
let pages;
|
|
21
|
+
/** The site's Pages entry files for a theme page `rel`, `.mdx` first. */
|
|
22
|
+
async function entryFiles(rel) {
|
|
23
|
+
const site = process.env.WUPPY_BASE_ROOT || process.cwd();
|
|
24
|
+
pages ??= loadSiteConfig(site, process.cwd()).then((c) => pagesDir(c.collections));
|
|
25
|
+
const dir = await pages;
|
|
26
|
+
return dir ? [".mdx", ".md"].map((ext) => join(site, dir, rel + ext)) : [];
|
|
27
|
+
}
|
|
28
|
+
async function renderBody(file, source, markdown) {
|
|
29
|
+
const { data, body } = parseDocument(source);
|
|
30
|
+
const { layout: _layout, ...rest } = data;
|
|
31
|
+
if (file.endsWith(".md")) return {
|
|
32
|
+
data: rest,
|
|
33
|
+
...await markdownContent(body, {
|
|
34
|
+
root: process.cwd(),
|
|
35
|
+
markdown
|
|
36
|
+
})
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
data: rest,
|
|
40
|
+
...await mdxContent(body, { markdown })
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A theme's Markdown page body (`rel` like `about`), read on each request from the site's Pages entry, or from the theme
|
|
45
|
+
* file itself when the site has none. `.mdx` compiles to a component (theme components allowed); `.md` to HTML.
|
|
46
|
+
*/
|
|
47
|
+
async function themePage(rel, themeFile, markdown) {
|
|
48
|
+
for (const file of [...await entryFiles(rel), themeFile]) {
|
|
49
|
+
const source = await readFile(file, "utf8").catch(() => null);
|
|
50
|
+
if (source !== null) return renderBody(file, source, markdown);
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`[wuppy] ${themeFile} is gone`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The blocks added in the admin to a theme `.astro` page: the body of the site's Pages entry that names the page as its
|
|
56
|
+
* `template`, or undefined when there is none.
|
|
57
|
+
*/
|
|
58
|
+
async function themeBody(rel, markdown) {
|
|
59
|
+
for (const file of await entryFiles(rel)) {
|
|
60
|
+
const source = await readFile(file, "utf8").catch(() => null);
|
|
61
|
+
if (source === null) continue;
|
|
62
|
+
if (typeof parseDocument(source).data.template !== "string") return void 0;
|
|
63
|
+
return renderBody(file, source, markdown);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function files(dir) {
|
|
67
|
+
return (await readdir(dir, { recursive: true }).catch(() => [])).filter((n) => /\.mdx?$/.test(n) && !n.split(/[\\/]/).some((part) => part.startsWith("_"))).toSorted().map((n) => join(dir, n));
|
|
68
|
+
}
|
|
69
|
+
/** Astro's default entry id: path without extension, each segment slugged like github-slugger. */
|
|
70
|
+
function defaultId(path) {
|
|
71
|
+
return path.replace(/\.[^./]+$/, "").split("/").map((s) => s.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc}\s-]/gu, "").replace(/\s/g, "-")).join("/").replace(/\/index$/, "");
|
|
72
|
+
}
|
|
73
|
+
function createLive(deps) {
|
|
74
|
+
let collections;
|
|
75
|
+
const named = () => collections ??= (deps.collections ? Promise.resolve(deps.collections) : configured(deps.root)).then((list) => new Map(list.map((c) => [c.name, c])), (e) => {
|
|
76
|
+
console.error(`[wuppy] live content is off: ${e.message}`);
|
|
77
|
+
return /* @__PURE__ */ new Map();
|
|
78
|
+
});
|
|
79
|
+
const runtimeMdx = /* @__PURE__ */ new WeakSet();
|
|
80
|
+
const cache = /* @__PURE__ */ new Map();
|
|
81
|
+
async function build(name, filePath, source, built) {
|
|
82
|
+
const digest = await deps.digest(source);
|
|
83
|
+
if (built?.digest === digest) return built;
|
|
84
|
+
const collection = (await named()).get(name);
|
|
85
|
+
const doc = deps.parseFrontmatter(source);
|
|
86
|
+
const frontmatter = renameFields(doc.frontmatter, collection.rename ?? {});
|
|
87
|
+
const body = doc.content.trim();
|
|
88
|
+
const id = (typeof frontmatter.slug === "string" ? frontmatter.slug : void 0) ?? built?.id ?? defaultId(relative(resolve(deps.root, collection.dir), resolve(deps.root, filePath)).split(sep).join("/"));
|
|
89
|
+
let data = frontmatter;
|
|
90
|
+
const schema = deps.schema(name);
|
|
91
|
+
if (schema) {
|
|
92
|
+
const parsed = await schema.safeParseAsync(frontmatter);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
console.error(`[wuppy] ${filePath} does not match the collection schema: ${parsed.error?.message}`);
|
|
95
|
+
return built ?? null;
|
|
96
|
+
}
|
|
97
|
+
data = parsed.data;
|
|
98
|
+
}
|
|
99
|
+
const entry = {
|
|
100
|
+
id,
|
|
101
|
+
collection: name,
|
|
102
|
+
data,
|
|
103
|
+
body,
|
|
104
|
+
filePath,
|
|
105
|
+
digest
|
|
106
|
+
};
|
|
107
|
+
if (filePath.endsWith(".md")) entry.rendered = await deps.renderMarkdown(body, frontmatter, filePath);
|
|
108
|
+
else runtimeMdx.add(entry);
|
|
109
|
+
return entry;
|
|
110
|
+
}
|
|
111
|
+
async function load(name) {
|
|
112
|
+
const collection = (await named()).get(name);
|
|
113
|
+
const built = new Map((await deps.built(name).catch(() => [])).map((e) => [e.filePath, e]));
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const abs of await files(resolve(deps.root, collection.dir))) {
|
|
116
|
+
const info = await stat(abs).catch(() => null);
|
|
117
|
+
if (!info) continue;
|
|
118
|
+
let hit = cache.get(abs);
|
|
119
|
+
if (!hit || hit.mtimeMs !== info.mtimeMs || hit.size !== info.size) {
|
|
120
|
+
const filePath = relative(deps.root, abs).split(sep).join("/");
|
|
121
|
+
const source = await readFile(abs, "utf8").catch(() => null);
|
|
122
|
+
if (source === null) continue;
|
|
123
|
+
hit = {
|
|
124
|
+
mtimeMs: info.mtimeMs,
|
|
125
|
+
size: info.size,
|
|
126
|
+
entry: await build(name, filePath, source, built.get(filePath))
|
|
127
|
+
};
|
|
128
|
+
cache.set(abs, hit);
|
|
129
|
+
}
|
|
130
|
+
if (hit.entry) out.push(hit.entry);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
isLive: async (name) => (await named()).has(name),
|
|
136
|
+
rendersAtRuntime: (entry) => typeof entry === "object" && entry !== null && runtimeMdx.has(entry),
|
|
137
|
+
async getCollection(name, filter) {
|
|
138
|
+
const all = await load(name);
|
|
139
|
+
return filter ? all.filter((e) => filter(e)) : all;
|
|
140
|
+
},
|
|
141
|
+
async getEntry(name, id) {
|
|
142
|
+
return (await load(name)).find((e) => e.id === id);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const same = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])].every((k) => String(a[k] ?? "") === String(b[k] ?? ""));
|
|
147
|
+
/** Astro's `paginate()` for a page rendered on request: same shape, URLs built from the route pattern (no `base`). */
|
|
148
|
+
function paginator(pattern) {
|
|
149
|
+
const rest = pattern.includes("[...page]");
|
|
150
|
+
const url = (params) => pattern.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, k) => params[k] === void 0 ? "" : String(params[k])).replace(/\/{2,}/g, "/").replace(/(.)\/$/, "$1");
|
|
151
|
+
const page = (n) => rest && n === 1 ? void 0 : n;
|
|
152
|
+
return (data, args = {}) => {
|
|
153
|
+
const size = args.pageSize || 10;
|
|
154
|
+
const last = Math.max(1, Math.ceil(data.length / size));
|
|
155
|
+
const at = (n) => ({
|
|
156
|
+
...args.params,
|
|
157
|
+
page: n === void 0 ? void 0 : String(n)
|
|
158
|
+
});
|
|
159
|
+
return Array.from({ length: last }, (_, i) => {
|
|
160
|
+
const n = i + 1;
|
|
161
|
+
const start = (n - 1) * size;
|
|
162
|
+
return {
|
|
163
|
+
params: at(page(n)),
|
|
164
|
+
props: {
|
|
165
|
+
...args.props,
|
|
166
|
+
page: {
|
|
167
|
+
data: data.slice(start, start + size),
|
|
168
|
+
start,
|
|
169
|
+
end: Math.min(start + size, data.length) - 1,
|
|
170
|
+
size,
|
|
171
|
+
total: data.length,
|
|
172
|
+
currentPage: n,
|
|
173
|
+
lastPage: last,
|
|
174
|
+
url: {
|
|
175
|
+
current: url(at(page(n))),
|
|
176
|
+
next: n === last ? void 0 : url(at(n + 1)),
|
|
177
|
+
prev: n === 1 ? void 0 : url(at(page(n - 1))),
|
|
178
|
+
first: n === 1 ? void 0 : url(at(page(1))),
|
|
179
|
+
last: n === last ? void 0 : url(at(last))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A page that used to be prerendered gets its props from `getStaticPaths()`. Rendered on request, Astro no longer
|
|
189
|
+
* calls it, so this wrapper does: it finds the path matching the request and passes its props, or answers 404.
|
|
190
|
+
*/
|
|
191
|
+
function livePage(factory, getStaticPaths, pattern) {
|
|
192
|
+
const wrapped = (async (result, props, slots) => {
|
|
193
|
+
const match = (await getStaticPaths({
|
|
194
|
+
paginate: paginator(pattern),
|
|
195
|
+
routePattern: pattern
|
|
196
|
+
})).flat().find((p) => same(p.params, result.params ?? {}));
|
|
197
|
+
if (!match) return new Response(null, { status: 404 });
|
|
198
|
+
return factory(result, {
|
|
199
|
+
...match.props,
|
|
200
|
+
...props
|
|
201
|
+
}, slots);
|
|
202
|
+
});
|
|
203
|
+
return Object.assign(wrapped, factory);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//#endregion
|
|
207
|
+
export { createLive, defaultId, livePage, markdownRenderer, mdxContent, paginator, themeBody, themePage };
|
package/dist/mdx.d.mts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
//#region src/markdown/render.d.ts
|
|
2
|
+
interface MarkdownHeading {
|
|
3
|
+
depth: number;
|
|
4
|
+
slug: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/** The JSON-safe parts of Astro's `markdown` config (see the live content shim). */
|
|
8
|
+
interface MarkdownSettings {
|
|
9
|
+
features?: Record<string, unknown>;
|
|
10
|
+
shared?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
/** The JSON-safe parts of Astro's `markdown` config used by MDX. */
|
|
13
|
+
interface MdxMarkdownSettings {
|
|
14
|
+
features?: Record<string, unknown>;
|
|
15
|
+
shared?: {
|
|
16
|
+
syntaxHighlight?: unknown;
|
|
17
|
+
shikiConfig?: unknown;
|
|
18
|
+
gfm?: boolean;
|
|
19
|
+
smartypants?: boolean;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
interface MarkdownRenderer {
|
|
23
|
+
render(source: string, options: {
|
|
24
|
+
frontmatter: Record<string, unknown>;
|
|
25
|
+
fileURL?: URL;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
code: string;
|
|
28
|
+
metadata: {
|
|
29
|
+
headings?: MarkdownHeading[];
|
|
30
|
+
} & Record<string, unknown>;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Astro's own Markdown renderer (heading ids, code highlighting, GFM), loaded from Astro's dependency at runtime: it
|
|
35
|
+
* has a native binding, which cannot be bundled.
|
|
36
|
+
*/
|
|
37
|
+
declare function markdownRenderer(root?: string, settings?: MarkdownSettings): Promise<MarkdownRenderer>;
|
|
38
|
+
/**
|
|
39
|
+
* Markdown stored outside content collections (a section body, a custom widget) as HTML, the way Astro renders an
|
|
40
|
+
* `.md` entry: `const { html } = await markdownContent(section.body)` then `<Fragment set:html={html} />`. The text is
|
|
41
|
+
* checked first with the same rules as the admin, so a body changed outside the admin cannot inject scripts either.
|
|
42
|
+
*/
|
|
43
|
+
declare function markdownContent(source: string, options?: {
|
|
44
|
+
root?: string;
|
|
45
|
+
markdown?: MarkdownSettings;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
html: string;
|
|
48
|
+
headings: MarkdownHeading[];
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* MDX text stored at runtime (a section body, an entry edited since the last build) compiled to what Astro's `render()`
|
|
52
|
+
* gives: `const { Content, headings } = await mdxContent(section.body)` then `<Content components={{ Callout }} />`.
|
|
53
|
+
* The text is checked first with the same rules as the admin (no imports, exports or expressions; only the `components`
|
|
54
|
+
* listed in wuppy.config), so a body changed outside the admin cannot run code either. Headings get Astro's ids and
|
|
55
|
+
* code blocks its highlighting. Needs `@astrojs/mdx` in the site, which renders the result.
|
|
56
|
+
*/
|
|
57
|
+
declare function mdxContent(source: string, options?: {
|
|
58
|
+
allowed?: readonly string[];
|
|
59
|
+
root?: string;
|
|
60
|
+
markdown?: MdxMarkdownSettings;
|
|
61
|
+
}): Promise<{
|
|
62
|
+
Content: (props?: {
|
|
63
|
+
components?: Record<string, unknown>;
|
|
64
|
+
} & Record<string, unknown>) => unknown;
|
|
65
|
+
headings: MarkdownHeading[];
|
|
66
|
+
}>;
|
|
67
|
+
//#endregion
|
|
68
|
+
export { MarkdownHeading, MarkdownSettings, MdxMarkdownSettings, markdownContent, markdownRenderer, mdxContent };
|
package/dist/mdx.mjs
ADDED