barakopress 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/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/cms.d.ts +47 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +131 -0
- package/dist/cms.js.map +1 -0
- package/dist/config.d.ts +91 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +75 -0
- package/dist/config.js.map +1 -0
- package/dist/delivery.d.ts +45 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +62 -0
- package/dist/delivery.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +5 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +91 -0
- package/dist/markdown.js.map +1 -0
- package/dist/routes/feed.d.ts +3 -0
- package/dist/routes/feed.d.ts.map +1 -0
- package/dist/routes/feed.js +73 -0
- package/dist/routes/feed.js.map +1 -0
- package/dist/routes/revalidate.d.ts +25 -0
- package/dist/routes/revalidate.d.ts.map +1 -0
- package/dist/routes/revalidate.js +136 -0
- package/dist/routes/revalidate.js.map +1 -0
- package/dist/routes/robots.d.ts +4 -0
- package/dist/routes/robots.d.ts.map +1 -0
- package/dist/routes/robots.js +18 -0
- package/dist/routes/robots.js.map +1 -0
- package/dist/routes/sitemap.d.ts +4 -0
- package/dist/routes/sitemap.d.ts.map +1 -0
- package/dist/routes/sitemap.js +33 -0
- package/dist/routes/sitemap.js.map +1 -0
- package/dist/screens/archive.d.ts +13 -0
- package/dist/screens/archive.d.ts.map +1 -0
- package/dist/screens/archive.js +47 -0
- package/dist/screens/archive.js.map +1 -0
- package/dist/screens/blog-index.d.ts +9 -0
- package/dist/screens/blog-index.d.ts.map +1 -0
- package/dist/screens/blog-index.js +38 -0
- package/dist/screens/blog-index.js.map +1 -0
- package/dist/screens/blog-post.d.ts +20 -0
- package/dist/screens/blog-post.d.ts.map +1 -0
- package/dist/screens/blog-post.js +103 -0
- package/dist/screens/blog-post.js.map +1 -0
- package/dist/screens/post-view.d.ts +8 -0
- package/dist/screens/post-view.d.ts.map +1 -0
- package/dist/screens/post-view.js +19 -0
- package/dist/screens/post-view.js.map +1 -0
- package/package.json +76 -0
- package/src/cms.ts +196 -0
- package/src/config.ts +159 -0
- package/src/delivery.ts +134 -0
- package/src/index.ts +69 -0
- package/src/markdown.ts +96 -0
- package/src/routes/feed.ts +79 -0
- package/src/routes/revalidate.ts +157 -0
- package/src/routes/robots.ts +20 -0
- package/src/routes/sitemap.ts +35 -0
- package/src/screens/archive.tsx +79 -0
- package/src/screens/blog-index.tsx +122 -0
- package/src/screens/blog-post.tsx +110 -0
- package/src/screens/post-view.tsx +84 -0
- package/src/styles.css +314 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The seam.
|
|
3
|
+
*
|
|
4
|
+
* Everything a site differs on lives here, so a consumer configures the engine instead of forking
|
|
5
|
+
* it. That matters because barakoCMS content types are defined at runtime: a client's posts are as
|
|
6
|
+
* likely to be `article` with a `Headline` as they are to be the blog blueprint's `post` and
|
|
7
|
+
* `Title`. An engine that compiles one shape in is one client's blog wearing a package name.
|
|
8
|
+
*
|
|
9
|
+
* The defaults are the `blog` blueprint the API ships, so a site that used the blueprint passes
|
|
10
|
+
* nothing and gets the same behaviour as before this existed.
|
|
11
|
+
*
|
|
12
|
+
* Config reaches the screens through factories rather than a global, because a Next route is a
|
|
13
|
+
* file and a package cannot write files into someone else's app. A consumer's route file calls
|
|
14
|
+
* `createBlogIndex(config)` and exports the result. One import, one call, full control.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface TypeNames {
|
|
18
|
+
/** The content type holding posts. */
|
|
19
|
+
post: string;
|
|
20
|
+
/** The type an author reference points at. Omit if the model has no authors. */
|
|
21
|
+
author?: string;
|
|
22
|
+
/** The type a category reference points at. Omit if the model has no categories. */
|
|
23
|
+
category?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which field on the post type holds what.
|
|
28
|
+
*
|
|
29
|
+
* Only `title` and `body` are required; everything else is a feature the site does without if the
|
|
30
|
+
* model has no field for it. An absent name means the engine never asks for that data and never
|
|
31
|
+
* renders it, rather than rendering an empty slot.
|
|
32
|
+
*/
|
|
33
|
+
export interface FieldMap {
|
|
34
|
+
title: string;
|
|
35
|
+
body: string;
|
|
36
|
+
slug?: string;
|
|
37
|
+
excerpt?: string;
|
|
38
|
+
publishedAt?: string;
|
|
39
|
+
coverImage?: string;
|
|
40
|
+
coverImageAlt?: string;
|
|
41
|
+
featured?: string;
|
|
42
|
+
tags?: string;
|
|
43
|
+
/** The reference field pointing at the author type. */
|
|
44
|
+
author?: string;
|
|
45
|
+
/** The reference field pointing at the category type. */
|
|
46
|
+
category?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Where the consumer mounted each route, so generated links match the app's real shape. */
|
|
50
|
+
export interface RouteMap {
|
|
51
|
+
/** The post route's prefix, for example "/blog" or "/writing". */
|
|
52
|
+
post: string;
|
|
53
|
+
author?: string;
|
|
54
|
+
category?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PageSizes {
|
|
58
|
+
index: number;
|
|
59
|
+
feed: number;
|
|
60
|
+
sitemap: number;
|
|
61
|
+
archive: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SiteIdentity {
|
|
65
|
+
name: string;
|
|
66
|
+
tagline?: string;
|
|
67
|
+
/** Absolute origin, used for every absolute link in the feed, sitemap and robots. */
|
|
68
|
+
url: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PressConfig {
|
|
72
|
+
types: TypeNames;
|
|
73
|
+
fields: FieldMap;
|
|
74
|
+
routes: RouteMap;
|
|
75
|
+
site: SiteIdentity;
|
|
76
|
+
pageSizes: PageSizes;
|
|
77
|
+
/** The cache tag this site purges. Two sites on one server need two tags. */
|
|
78
|
+
cacheTag: string;
|
|
79
|
+
/** How long a cached read may live with no webhook. Zero disables the backstop. */
|
|
80
|
+
backstopSeconds: number;
|
|
81
|
+
/** Passed to toLocaleDateString. */
|
|
82
|
+
locale: string;
|
|
83
|
+
/** Where the CMS is, from this server. */
|
|
84
|
+
cmsUrl: string;
|
|
85
|
+
/** Tenant slug, for a multi-tenant deployment. */
|
|
86
|
+
tenant?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type PressConfigInput = {
|
|
90
|
+
types?: Partial<TypeNames>;
|
|
91
|
+
fields?: Partial<FieldMap>;
|
|
92
|
+
routes?: Partial<RouteMap>;
|
|
93
|
+
site?: Partial<SiteIdentity>;
|
|
94
|
+
pageSizes?: Partial<PageSizes>;
|
|
95
|
+
} & Partial<Omit<PressConfig, "types" | "fields" | "routes" | "site" | "pageSizes">>;
|
|
96
|
+
|
|
97
|
+
/** The `blog` blueprint, which is what `POST /api/content-types/blueprints/blog` creates. */
|
|
98
|
+
const BLOG_BLUEPRINT: Pick<PressConfig, "types" | "fields"> = {
|
|
99
|
+
types: { post: "post", author: "author", category: "category" },
|
|
100
|
+
fields: {
|
|
101
|
+
title: "Title",
|
|
102
|
+
slug: "Slug",
|
|
103
|
+
excerpt: "Excerpt",
|
|
104
|
+
body: "Body",
|
|
105
|
+
coverImage: "CoverImage",
|
|
106
|
+
coverImageAlt: "CoverImageAlt",
|
|
107
|
+
publishedAt: "PublishedAt",
|
|
108
|
+
featured: "Featured",
|
|
109
|
+
tags: "Tags",
|
|
110
|
+
author: "Author",
|
|
111
|
+
category: "Category",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function trimSlash(path: string): string {
|
|
116
|
+
return path.replace(/\/+$/, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Builds a complete config from a partial one.
|
|
121
|
+
*
|
|
122
|
+
* Site name and URL have no sensible default: a fallback of the engine's own name is how a client
|
|
123
|
+
* site ends up with the vendor's brand in its masthead, so they are required and the type says so.
|
|
124
|
+
*/
|
|
125
|
+
export function defineConfig(input: PressConfigInput & { site: SiteIdentity }): PressConfig {
|
|
126
|
+
const routes = { post: "/blog", author: "/authors", category: "/categories", ...input.routes };
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
types: { ...BLOG_BLUEPRINT.types, ...input.types },
|
|
130
|
+
fields: { ...BLOG_BLUEPRINT.fields, ...input.fields },
|
|
131
|
+
routes: {
|
|
132
|
+
post: trimSlash(routes.post),
|
|
133
|
+
author: routes.author ? trimSlash(routes.author) : undefined,
|
|
134
|
+
category: routes.category ? trimSlash(routes.category) : undefined,
|
|
135
|
+
},
|
|
136
|
+
site: { ...input.site, url: trimSlash(input.site.url) },
|
|
137
|
+
pageSizes: { index: 20, feed: 50, sitemap: 1000, archive: 50, ...input.pageSizes },
|
|
138
|
+
cacheTag: input.cacheTag ?? "cms",
|
|
139
|
+
backstopSeconds: input.backstopSeconds ?? 300,
|
|
140
|
+
locale: input.locale ?? "en-GB",
|
|
141
|
+
cmsUrl: trimSlash(input.cmsUrl ?? process.env.CMS_URL ?? "http://localhost:5005"),
|
|
142
|
+
tenant: input.tenant ?? process.env.CMS_TENANT ?? undefined,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The reference fields worth resolving in one request, as the API's `include` expects them.
|
|
148
|
+
*
|
|
149
|
+
* Only names the site actually has. Sending `include=Author,Category` unconditionally is a 400
|
|
150
|
+
* from the API for any post type without both fields, which is every model that is not the blog
|
|
151
|
+
* blueprint.
|
|
152
|
+
*/
|
|
153
|
+
export function includesFor(config: PressConfig): string[] {
|
|
154
|
+
const wanted: (string | undefined)[] = [
|
|
155
|
+
config.types.author ? config.fields.author : undefined,
|
|
156
|
+
config.types.category ? config.fields.category : undefined,
|
|
157
|
+
];
|
|
158
|
+
return wanted.filter((f): f is string => Boolean(f));
|
|
159
|
+
}
|
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { PressConfig } from "./config.js";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The delivery layer: typed calls against barakoCMS's public API.
|
|
5
|
+
*
|
|
6
|
+
* It should use @baryodev/barako-client and it is written to switch back, but 0.3.0 cannot express
|
|
7
|
+
* what a blog needs. Its PublicListQuery is `{ page?, pageSize? }` and nothing else, and `bySlug`
|
|
8
|
+
* takes no options, so `include`, `filter`, `sort` and a preview token are all out of reach even
|
|
9
|
+
* though the API supports every one of them. Tracked on BaryoDev/barakoCMS#182.
|
|
10
|
+
*
|
|
11
|
+
* Caching is the reason to be careful here. Every read is tagged, so a signed webhook can drop it
|
|
12
|
+
* the moment the CMS says something changed, and carries a backstop so a deployment whose webhook
|
|
13
|
+
* was never wired up still refreshes on its own.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here reads process.env or a module-level constant: every call takes the config, because
|
|
16
|
+
* two sites served by one build must be able to differ, and because a value read at module scope
|
|
17
|
+
* is baked into a prerender at build time.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface PublicContent {
|
|
21
|
+
id: string;
|
|
22
|
+
slug?: string;
|
|
23
|
+
contentType?: string;
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
updatedAt?: string;
|
|
26
|
+
data: Record<string, unknown>;
|
|
27
|
+
seo?: Seo;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Seo {
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
canonicalUrl?: string;
|
|
34
|
+
imageUrl?: string;
|
|
35
|
+
noIndex?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Paged<T> {
|
|
39
|
+
items: T[];
|
|
40
|
+
page: number;
|
|
41
|
+
pageSize: number;
|
|
42
|
+
totalItems: number;
|
|
43
|
+
totalPages: number;
|
|
44
|
+
hasNextPage: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function headers(config: PressConfig): HeadersInit {
|
|
48
|
+
return config.tenant ? { "X-Tenant": config.tenant } : {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A cached, tagged read. Everything a visitor sees comes through here. */
|
|
52
|
+
async function get<T>(config: PressConfig, path: string): Promise<T> {
|
|
53
|
+
const res = await fetch(`${config.cmsUrl}${path}`, {
|
|
54
|
+
headers: headers(config),
|
|
55
|
+
next: {
|
|
56
|
+
tags: [config.cacheTag],
|
|
57
|
+
// Zero means no backstop, which Next spells as false.
|
|
58
|
+
revalidate: config.backstopSeconds > 0 ? config.backstopSeconds : false,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
|
62
|
+
return (await res.json()) as T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An uncached read, for a draft. A cached draft would be served to the next visitor. */
|
|
66
|
+
async function getFresh<T>(config: PressConfig, path: string): Promise<T | null> {
|
|
67
|
+
const res = await fetch(`${config.cmsUrl}${path}`, {
|
|
68
|
+
headers: headers(config),
|
|
69
|
+
cache: "no-store",
|
|
70
|
+
});
|
|
71
|
+
if (res.status === 404) return null;
|
|
72
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
|
73
|
+
return (await res.json()) as T;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ListOptions {
|
|
77
|
+
page?: number;
|
|
78
|
+
pageSize?: number;
|
|
79
|
+
/** Reference fields to resolve in the same request. The API caps this at five. */
|
|
80
|
+
include?: string[];
|
|
81
|
+
/** `[field, op, value]`, for example ["Author", "eq", id]. The API caps this at five. */
|
|
82
|
+
filter?: [string, string, string][];
|
|
83
|
+
/** Sent to the API, so ordering covers every row rather than the page that came back. */
|
|
84
|
+
sort?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function list(
|
|
88
|
+
config: PressConfig,
|
|
89
|
+
type: string,
|
|
90
|
+
opts: ListOptions = {},
|
|
91
|
+
): Promise<Paged<PublicContent>> {
|
|
92
|
+
const q = new URLSearchParams();
|
|
93
|
+
q.set("page", String(opts.page ?? 1));
|
|
94
|
+
q.set("pageSize", String(opts.pageSize ?? config.pageSizes.index));
|
|
95
|
+
if (opts.include?.length) q.set("include", opts.include.join(","));
|
|
96
|
+
if (opts.sort) q.set("sort", opts.sort);
|
|
97
|
+
for (const [field, op, value] of opts.filter ?? []) q.set(`filter[${field}][${op}]`, value);
|
|
98
|
+
return get<Paged<PublicContent>>(config, `/api/public/${encodeURIComponent(type)}?${q}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function bySlug(
|
|
102
|
+
config: PressConfig,
|
|
103
|
+
type: string,
|
|
104
|
+
slug: string,
|
|
105
|
+
): Promise<PublicContent | null> {
|
|
106
|
+
try {
|
|
107
|
+
return await get<PublicContent>(
|
|
108
|
+
config,
|
|
109
|
+
`/api/public/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`,
|
|
110
|
+
);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// A missing entry is a not-found page, not a broken site.
|
|
113
|
+
if (e instanceof Error && e.message.includes("404")) return null;
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A draft, read with a preview token an editor minted from `POST /api/preview`.
|
|
120
|
+
*
|
|
121
|
+
* An invalid or expired token is not an error: the API falls back to published-only, so this
|
|
122
|
+
* returns whatever is public, or null. That is the safe direction to fail in.
|
|
123
|
+
*/
|
|
124
|
+
export async function bySlugPreview(
|
|
125
|
+
config: PressConfig,
|
|
126
|
+
type: string,
|
|
127
|
+
slug: string,
|
|
128
|
+
token: string,
|
|
129
|
+
): Promise<PublicContent | null> {
|
|
130
|
+
return getFresh<PublicContent>(
|
|
131
|
+
config,
|
|
132
|
+
`/api/public/${encodeURIComponent(type)}/${encodeURIComponent(slug)}?preview=${encodeURIComponent(token)}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The barakoPress engine.
|
|
3
|
+
*
|
|
4
|
+
* A site depends on this and re-exports the pieces it wants from its own route files, because
|
|
5
|
+
* Next decides routes by file path and a package cannot create files in someone else's app.
|
|
6
|
+
*
|
|
7
|
+
* Everything that differs between sites is in the config passed to these factories: the content
|
|
8
|
+
* type names, the field map, where the routes are mounted, page sizes, site identity, the cache
|
|
9
|
+
* tag and the backstop. barakoCMS content types are defined at runtime, so a client's posts are
|
|
10
|
+
* as likely to be `article` with a `Headline` as the blueprint's `post` and `Title`. An engine
|
|
11
|
+
* that compiled one shape in would be one blog wearing a package name.
|
|
12
|
+
*
|
|
13
|
+
* A consumer's app/page.tsx:
|
|
14
|
+
*
|
|
15
|
+
* import { createBlogIndex } from "barakopress";
|
|
16
|
+
* import { config } from "@/press.config";
|
|
17
|
+
* export default createBlogIndex(config);
|
|
18
|
+
* export const revalidate = 300;
|
|
19
|
+
*
|
|
20
|
+
* Route segment config stays in the consumer's file: Next reads it from the file that owns the
|
|
21
|
+
* route and does not reliably follow a re-export, and the caching window is their decision.
|
|
22
|
+
*
|
|
23
|
+
* The screens live in src/screens rather than src/pages because Next claims both `pages` and
|
|
24
|
+
* `app` as router directories, and a `src/pages` inside a transpiled package is read as a second
|
|
25
|
+
* router.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export { defineConfig, includesFor } from "./config.js";
|
|
29
|
+
export type {
|
|
30
|
+
PressConfig,
|
|
31
|
+
PressConfigInput,
|
|
32
|
+
TypeNames,
|
|
33
|
+
FieldMap,
|
|
34
|
+
RouteMap,
|
|
35
|
+
PageSizes,
|
|
36
|
+
SiteIdentity,
|
|
37
|
+
} from "./config.js";
|
|
38
|
+
|
|
39
|
+
export { list, bySlug, bySlugPreview } from "./delivery.js";
|
|
40
|
+
export type { PublicContent, Seo, ListOptions, Paged } from "./delivery.js";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
listPosts,
|
|
44
|
+
getPost,
|
|
45
|
+
getPostPreview,
|
|
46
|
+
listPostsBy,
|
|
47
|
+
getTerm,
|
|
48
|
+
toPost,
|
|
49
|
+
formatDate,
|
|
50
|
+
} from "./cms.js";
|
|
51
|
+
export type { Post, Ref, Term } from "./cms.js";
|
|
52
|
+
|
|
53
|
+
export { renderMarkdown, isSafeHref, anchor } from "./markdown.js";
|
|
54
|
+
|
|
55
|
+
export { createBlogIndex, Card } from "./screens/blog-index.js";
|
|
56
|
+
export {
|
|
57
|
+
createBlogPost,
|
|
58
|
+
createBlogPostPreview,
|
|
59
|
+
createPostMetadata,
|
|
60
|
+
createPostStaticParams,
|
|
61
|
+
} from "./screens/blog-post.js";
|
|
62
|
+
export { createArchive, createArchiveStaticParams } from "./screens/archive.js";
|
|
63
|
+
export { PostView } from "./screens/post-view.js";
|
|
64
|
+
|
|
65
|
+
export { createRevalidateRoute } from "./routes/revalidate.js";
|
|
66
|
+
export type { RevalidateOptions } from "./routes/revalidate.js";
|
|
67
|
+
export { createFeed } from "./routes/feed.js";
|
|
68
|
+
export { createSitemap } from "./routes/sitemap.js";
|
|
69
|
+
export { createRobots } from "./routes/robots.js";
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Marked, type Tokens } from "marked";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Markdown to HTML, treating the markdown as untrusted.
|
|
5
|
+
*
|
|
6
|
+
* An editor is authenticated, so this is not the first line of defence. It is the second, and it
|
|
7
|
+
* matters because the body of a post is the one field on this site that becomes markup. The
|
|
8
|
+
* threats it closes: an editor account that gets taken over, a contributor who is trusted to write
|
|
9
|
+
* but not to run script on the domain, and content imported in bulk from somewhere else.
|
|
10
|
+
*
|
|
11
|
+
* Three rules:
|
|
12
|
+
* 1. Raw HTML in the source is escaped, never passed through. That removes script tags, event
|
|
13
|
+
* handler attributes and iframes in one move, instead of trying to enumerate them.
|
|
14
|
+
* 2. A link or image destination must be http, https or mailto. That kills javascript: and
|
|
15
|
+
* data: URLs, which are the two that execute.
|
|
16
|
+
* 3. Text is escaped on the way into every attribute, so an alt or a title cannot close its own
|
|
17
|
+
* quote and add another attribute.
|
|
18
|
+
*
|
|
19
|
+
* The trade is real and deliberate: an author cannot embed a YouTube iframe or any raw HTML. When
|
|
20
|
+
* that is wanted, the answer is a content field the frontend renders deliberately, not a hole here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SAFE_SCHEMES = ["http:", "https:", "mailto:"];
|
|
24
|
+
|
|
25
|
+
export function isSafeHref(href: string): boolean {
|
|
26
|
+
const trimmed = href.trim();
|
|
27
|
+
// A relative or anchor link has no scheme and cannot execute.
|
|
28
|
+
if (trimmed.startsWith("/") || trimmed.startsWith("#")) return true;
|
|
29
|
+
try {
|
|
30
|
+
return SAFE_SCHEMES.includes(new URL(trimmed).protocol);
|
|
31
|
+
} catch {
|
|
32
|
+
// Unparseable means it is not a URL this should emit.
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeHtml(value: string): string {
|
|
38
|
+
return value
|
|
39
|
+
.replace(/&/g, "&")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">")
|
|
42
|
+
.replace(/"/g, """)
|
|
43
|
+
.replace(/'/g, "'");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Turns a heading into a stable id, so "On this page" links and deep links work. */
|
|
47
|
+
export function anchor(text: string): string {
|
|
48
|
+
return text
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^\w\s-]/g, "")
|
|
51
|
+
.trim()
|
|
52
|
+
.replace(/\s+/g, "-");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildRenderer() {
|
|
56
|
+
const marked = new Marked({ gfm: true, breaks: false });
|
|
57
|
+
|
|
58
|
+
marked.use({
|
|
59
|
+
renderer: {
|
|
60
|
+
// Raw HTML blocks and inline HTML are emitted as visible text, not as markup.
|
|
61
|
+
html({ text }: Tokens.HTML | Tokens.Tag) {
|
|
62
|
+
return escapeHtml(text);
|
|
63
|
+
},
|
|
64
|
+
link({ href, title, tokens }) {
|
|
65
|
+
const label = this.parser.parseInline(tokens);
|
|
66
|
+
if (!isSafeHref(href)) {
|
|
67
|
+
// Keep the words, drop the destination. A reader still sees what was written.
|
|
68
|
+
return label;
|
|
69
|
+
}
|
|
70
|
+
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
|
71
|
+
const external = /^https?:/.test(href.trim());
|
|
72
|
+
const rel = external ? ' rel="noopener noreferrer"' : "";
|
|
73
|
+
return `<a href="${escapeHtml(href.trim())}"${t}${rel}>${label}</a>`;
|
|
74
|
+
},
|
|
75
|
+
image({ href, title, text }) {
|
|
76
|
+
if (!isSafeHref(href)) return escapeHtml(text ?? "");
|
|
77
|
+
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
|
78
|
+
return `<img src="${escapeHtml(href.trim())}" alt="${escapeHtml(text ?? "")}"${t} loading="lazy">`;
|
|
79
|
+
},
|
|
80
|
+
heading({ tokens, depth }) {
|
|
81
|
+
const label = this.parser.parseInline(tokens);
|
|
82
|
+
const plain = tokens.map((t) => ("raw" in t ? t.raw : "")).join("");
|
|
83
|
+
return `<h${depth} id="${escapeHtml(anchor(plain))}">${label}</h${depth}>`;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return marked;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const renderer = buildRenderer();
|
|
92
|
+
|
|
93
|
+
export function renderMarkdown(source: string): string {
|
|
94
|
+
if (!source) return "";
|
|
95
|
+
return renderer.parse(source, { async: false }) as string;
|
|
96
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { PressConfig } from "../config.js";
|
|
2
|
+
import { listPosts } from "../cms.js";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* RSS, built here rather than proxied from the CMS.
|
|
6
|
+
*
|
|
7
|
+
* barakoCMS serves /api/public/{type}/feed.xml and it is correct, but its item links come from a
|
|
8
|
+
* server-side config template that has to be kept in step with the site's routes by hand. Built
|
|
9
|
+
* here, the routes have one owner: config.routes. It also costs nothing, because it is the same
|
|
10
|
+
* cached, tagged read the index uses.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** XML text escaping. Every value below is content someone typed, so none of it is trusted. */
|
|
14
|
+
function xml(value: string): string {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">")
|
|
19
|
+
.replace(/"/g, """)
|
|
20
|
+
.replace(/'/g, "'");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFeed(config: PressConfig) {
|
|
24
|
+
return async function GET() {
|
|
25
|
+
let posts: Awaited<ReturnType<typeof listPosts>>["posts"] = [];
|
|
26
|
+
try {
|
|
27
|
+
({ posts } = await listPosts(config, { pageSize: config.pageSizes.feed }));
|
|
28
|
+
} catch {
|
|
29
|
+
// An unreachable CMS yields an empty channel, not a failure. This route is prerendered
|
|
30
|
+
// when the consumer gives it a revalidate window, so a throw fails their build, and at
|
|
31
|
+
// runtime it would hand a feed reader a 500.
|
|
32
|
+
posts = [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const items = posts
|
|
36
|
+
.map((p) => {
|
|
37
|
+
const url = `${config.site.url}${config.routes.post}/${p.slug}`;
|
|
38
|
+
const date = p.publishedAt ? new Date(p.publishedAt) : null;
|
|
39
|
+
const pubDate =
|
|
40
|
+
date && !Number.isNaN(date.getTime())
|
|
41
|
+
? ` <pubDate>${date.toUTCString()}</pubDate>`
|
|
42
|
+
: "";
|
|
43
|
+
return [
|
|
44
|
+
" <item>",
|
|
45
|
+
` <title>${xml(p.title)}</title>`,
|
|
46
|
+
` <link>${xml(url)}</link>`,
|
|
47
|
+
` <guid isPermaLink="true">${xml(url)}</guid>`,
|
|
48
|
+
// Plain text, not rendered HTML: a feed reader that trusts markup is not this
|
|
49
|
+
// site's problem to create.
|
|
50
|
+
p.excerpt ? ` <description>${xml(p.excerpt)}</description>` : "",
|
|
51
|
+
pubDate,
|
|
52
|
+
p.category ? ` <category>${xml(p.category.name)}</category>` : "",
|
|
53
|
+
" </item>",
|
|
54
|
+
]
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join("\n");
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
|
|
60
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
61
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
62
|
+
<channel>
|
|
63
|
+
<title>${xml(config.site.name)}</title>
|
|
64
|
+
<link>${xml(config.site.url)}</link>
|
|
65
|
+
<description>${xml(config.site.tagline ?? config.site.name)}</description>
|
|
66
|
+
<atom:link href="${xml(`${config.site.url}/feed.xml`)}" rel="self" type="application/rss+xml"/>
|
|
67
|
+
${items}
|
|
68
|
+
</channel>
|
|
69
|
+
</rss>
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
return new Response(body, {
|
|
73
|
+
headers: {
|
|
74
|
+
"content-type": "application/rss+xml; charset=utf-8",
|
|
75
|
+
"cache-control": "public, max-age=300",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
}
|