@sevenfold/setto-client 0.34.0 → 0.36.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/blog/SettoBlog.d.ts +20 -0
- package/dist/blog/data.d.ts +36 -0
- package/dist/blog/index.d.ts +2 -0
- package/dist/blog/render.d.ts +16 -0
- package/dist/blog/strings.d.ts +25 -0
- package/dist/blog.js +13 -0
- package/dist/blog.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/lib/media.d.ts +16 -0
- package/dist/lib/optimized-image.d.ts +14 -2
- package/dist/pages/manifest.d.ts +18 -1
- package/dist/setto-client.js +762 -200
- package/dist/setto-client.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/vite/index.d.ts +6 -0
- package/dist/vite/plugin.d.ts +32 -0
- package/dist/vite/static.d.ts +78 -0
- package/dist/vite.js +550 -0
- package/dist/vite.js.map +1 -0
- package/package.json +10 -2
- package/virtual.d.ts +11 -0
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SettoBlogData } from './blog/data';
|
|
1
2
|
import type { SectionSchema } from './section-schema';
|
|
2
3
|
export interface BrandColor {
|
|
3
4
|
/** Display name in the swatch picker, e.g. 'Beige'. */
|
|
@@ -66,6 +67,13 @@ export interface SettoConfig {
|
|
|
66
67
|
images?: {
|
|
67
68
|
optimize?: boolean;
|
|
68
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* The site's blog posts, as its build bundles them: `import blog from
|
|
72
|
+
* 'virtual:setto-blog'` with the `settoBlog()` plugin from
|
|
73
|
+
* `@setto/client/vite`. Without it the `blog` and `blog-latest` sections
|
|
74
|
+
* draw nothing.
|
|
75
|
+
*/
|
|
76
|
+
blog?: SettoBlogData;
|
|
69
77
|
}
|
|
70
78
|
export interface DeploymentRow {
|
|
71
79
|
id: string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { type BlogFrontmatter } from '../blog/contract';
|
|
3
|
+
export interface SettoBlogPluginOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The site's public address, for canonical links, `og:url`, RSS and the
|
|
6
|
+
* sitemap. Otherwise `SETTO_SITE_URL`, then Vercel's
|
|
7
|
+
* `VERCEL_PROJECT_PRODUCTION_URL`, then the first address in
|
|
8
|
+
* `public/sitemap.xml`. Without any, pages still get their own head, only
|
|
9
|
+
* with relative addresses, and no feed.
|
|
10
|
+
*/
|
|
11
|
+
siteUrl?: string;
|
|
12
|
+
/** Hosts a post's images may come from. Setto's media store by default. */
|
|
13
|
+
imageHosts?: readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* Where the blog lives, for a site without a blog page in `pages.json`
|
|
16
|
+
* (one that routes by hand and mounts `<SettoBlog basePath>`). A blog page
|
|
17
|
+
* in the manifest wins.
|
|
18
|
+
*/
|
|
19
|
+
blogPath?: string;
|
|
20
|
+
}
|
|
21
|
+
interface LoadedPost {
|
|
22
|
+
key: string;
|
|
23
|
+
frontmatter: BlogFrontmatter;
|
|
24
|
+
body: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function loadPosts(root: string, imageHosts: readonly string[]): LoadedPost[];
|
|
27
|
+
export declare function settoBlog(options?: SettoBlogPluginOptions): Plugin;
|
|
28
|
+
/** Exported for tests: everything `writeBundle` does, given a built `outDir`. */
|
|
29
|
+
export declare function writeStaticFiles(root: string, outDir: string, loaded: readonly LoadedPost[], options?: SettoBlogPluginOptions): {
|
|
30
|
+
written: string[];
|
|
31
|
+
};
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static files a blog adds to a site's build: one HTML page per post with
|
|
3
|
+
* its own head, the blog's index page, an RSS feed, and the posts in
|
|
4
|
+
* `sitemap.xml`.
|
|
5
|
+
*
|
|
6
|
+
* Why: the sites are single-page apps — every path is the same `index.html`.
|
|
7
|
+
* Google runs the JavaScript; Facebook, LinkedIn, X, Slack, Messenger and
|
|
8
|
+
* iMessage do not. They fetch the address and read the `og:` tags in the raw
|
|
9
|
+
* HTML, so without a file of its own a shared post shows the front page's
|
|
10
|
+
* title and picture. Vercel serves a real file before it applies the
|
|
11
|
+
* catch-all rewrite, so `/blogg/<slug>` gets this one and the app takes over
|
|
12
|
+
* in the browser as before.
|
|
13
|
+
*
|
|
14
|
+
* Pure functions over strings, so they can be tested without a build.
|
|
15
|
+
*/
|
|
16
|
+
import type { BlogFrontmatter } from '../blog/contract';
|
|
17
|
+
export interface HeadMeta {
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/** Absolute, when the site's address is known. */
|
|
21
|
+
url?: string;
|
|
22
|
+
/** Absolute. */
|
|
23
|
+
image?: string;
|
|
24
|
+
type: 'article' | 'website';
|
|
25
|
+
locale: string;
|
|
26
|
+
siteName?: string;
|
|
27
|
+
publishedTime?: string;
|
|
28
|
+
jsonLd?: Record<string, unknown>;
|
|
29
|
+
rssUrl?: string;
|
|
30
|
+
/** The same page in other languages, absolute; `x-default` for the primary. */
|
|
31
|
+
alternates?: {
|
|
32
|
+
hreflang: string;
|
|
33
|
+
href: string;
|
|
34
|
+
}[];
|
|
35
|
+
}
|
|
36
|
+
export declare const escapeXml: (value: string) => string;
|
|
37
|
+
/**
|
|
38
|
+
* The site's `index.html` with a page's own head: its title, description,
|
|
39
|
+
* canonical address, Open Graph and Twitter tags and structured data. Any of
|
|
40
|
+
* those the front page already had are taken out first, so a crawler never
|
|
41
|
+
* reads two answers.
|
|
42
|
+
*/
|
|
43
|
+
export declare function withHead(indexHtml: string, meta: HeadMeta): string;
|
|
44
|
+
/** `https://x.no` + `/blogg/a` — or the path alone when the address is unknown. */
|
|
45
|
+
export declare function absolute(siteUrl: string | null, pathOrUrl: string): string;
|
|
46
|
+
export interface PostHeadInput {
|
|
47
|
+
post: BlogFrontmatter;
|
|
48
|
+
blogPath: string;
|
|
49
|
+
siteUrl: string | null;
|
|
50
|
+
siteName?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function postHead({ post, blogPath, siteUrl, siteName }: PostHeadInput): HeadMeta;
|
|
53
|
+
export declare function indexHead({ title, description, blogPath, siteUrl, siteName, locale, }: {
|
|
54
|
+
title: string;
|
|
55
|
+
description: string;
|
|
56
|
+
blogPath: string;
|
|
57
|
+
siteUrl: string | null;
|
|
58
|
+
siteName?: string;
|
|
59
|
+
locale: string;
|
|
60
|
+
}): HeadMeta;
|
|
61
|
+
export declare function rssFeed({ posts, blogPath, siteUrl, title, description, locale, }: {
|
|
62
|
+
posts: readonly BlogFrontmatter[];
|
|
63
|
+
blogPath: string;
|
|
64
|
+
siteUrl: string;
|
|
65
|
+
title: string;
|
|
66
|
+
description: string;
|
|
67
|
+
locale: string;
|
|
68
|
+
}): string;
|
|
69
|
+
/**
|
|
70
|
+
* The site's `sitemap.xml` with the blog and its posts added — before the
|
|
71
|
+
* closing tag, and only those not already listed.
|
|
72
|
+
*/
|
|
73
|
+
export declare function withSitemapEntries(sitemap: string, entries: readonly {
|
|
74
|
+
loc: string;
|
|
75
|
+
lastmod?: string;
|
|
76
|
+
}[]): string;
|
|
77
|
+
/** The site's own address, from the first `<loc>` of its sitemap. */
|
|
78
|
+
export declare function originFromSitemap(sitemap: string): string | null;
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SETTO_MEDIA_HOST, BLOG_DIR, parseBlogPostPath, validateBlogPost } from "./blog.js";
|
|
4
|
+
function byDateDesc(a, b) {
|
|
5
|
+
return a.date === b.date ? a.slug.localeCompare(b.slug) : a.date < b.date ? 1 : -1;
|
|
6
|
+
}
|
|
7
|
+
function postsForLanguage(posts, language, fallbackLanguage) {
|
|
8
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
9
|
+
for (const post of posts) {
|
|
10
|
+
const current = bySlug.get(post.slug);
|
|
11
|
+
if (!current || rank(post, language, fallbackLanguage) < rank(current, language, fallbackLanguage)) {
|
|
12
|
+
bySlug.set(post.slug, post);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return [...bySlug.values()].sort(byDateDesc);
|
|
16
|
+
}
|
|
17
|
+
function rank(post, language, fallbackLanguage) {
|
|
18
|
+
if (post.locale === language) return 0;
|
|
19
|
+
if (post.locale === fallbackLanguage) return 1;
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
const NO = {
|
|
23
|
+
blog: "Blogg",
|
|
24
|
+
latest: "Fra bloggen",
|
|
25
|
+
allPosts: "Alle innlegg",
|
|
26
|
+
readMore: "Les mer",
|
|
27
|
+
back: "Alle innlegg",
|
|
28
|
+
empty: "Ingen innlegg ennå.",
|
|
29
|
+
notFound: "Fant ikke innlegget.",
|
|
30
|
+
loading: "Henter innlegget …",
|
|
31
|
+
share: "Del",
|
|
32
|
+
moreShare: "Flere valg …",
|
|
33
|
+
copyLink: "Kopier lenke",
|
|
34
|
+
copied: "Lenken er kopiert",
|
|
35
|
+
email: "E-post",
|
|
36
|
+
video: "Video",
|
|
37
|
+
play: "Spill av"
|
|
38
|
+
};
|
|
39
|
+
const EN = {
|
|
40
|
+
blog: "Blog",
|
|
41
|
+
latest: "From the blog",
|
|
42
|
+
allPosts: "All posts",
|
|
43
|
+
readMore: "Read more",
|
|
44
|
+
back: "All posts",
|
|
45
|
+
empty: "No posts yet.",
|
|
46
|
+
notFound: "That post could not be found.",
|
|
47
|
+
loading: "Loading the post …",
|
|
48
|
+
share: "Share",
|
|
49
|
+
moreShare: "More …",
|
|
50
|
+
copyLink: "Copy link",
|
|
51
|
+
copied: "Link copied",
|
|
52
|
+
email: "Email",
|
|
53
|
+
video: "Video",
|
|
54
|
+
play: "Play"
|
|
55
|
+
};
|
|
56
|
+
function blogStrings(language) {
|
|
57
|
+
return language === "no" || language === "nb" || language === "nn" ? NO : EN;
|
|
58
|
+
}
|
|
59
|
+
const PAGES_MANIFEST_VERSION = 1;
|
|
60
|
+
const PAGES_PATH = "src/content/pages.json";
|
|
61
|
+
const BUILTIN_SECTION_TYPES = ["text", "blog", "blog-latest"];
|
|
62
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
63
|
+
const PATH = /^\/(?:[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*)?$/;
|
|
64
|
+
function isRecord(value) {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
function parseManifest(input) {
|
|
68
|
+
const issues = [];
|
|
69
|
+
if (!isRecord(input)) return { ok: false, issues: [{ at: "$", message: "not an object" }] };
|
|
70
|
+
if (input.version !== PAGES_MANIFEST_VERSION) {
|
|
71
|
+
issues.push({ at: "version", message: `expected ${PAGES_MANIFEST_VERSION}` });
|
|
72
|
+
}
|
|
73
|
+
const pages = [];
|
|
74
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
75
|
+
const paths = /* @__PURE__ */ new Set();
|
|
76
|
+
const sectionIds = /* @__PURE__ */ new Set();
|
|
77
|
+
const rawPages = Array.isArray(input.pages) ? input.pages : null;
|
|
78
|
+
if (!rawPages) issues.push({ at: "pages", message: "must be a list" });
|
|
79
|
+
(rawPages ?? []).forEach((raw, i) => {
|
|
80
|
+
const at = `pages[${i}]`;
|
|
81
|
+
if (!isRecord(raw)) {
|
|
82
|
+
issues.push({ at, message: "not an object" });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const id = String(raw.id ?? "");
|
|
86
|
+
const path2 = String(raw.path ?? "");
|
|
87
|
+
const titleKey = String(raw.titleKey ?? "");
|
|
88
|
+
if (!ID.test(id)) issues.push({ at: `${at}.id`, message: `invalid id "${id}"` });
|
|
89
|
+
else if (pageIds.has(id)) issues.push({ at: `${at}.id`, message: `duplicate page "${id}"` });
|
|
90
|
+
pageIds.add(id);
|
|
91
|
+
if (!PATH.test(path2)) issues.push({ at: `${at}.path`, message: `invalid path "${path2}"` });
|
|
92
|
+
else if (path2 === "/setto" || path2.startsWith("/setto/")) {
|
|
93
|
+
issues.push({ at: `${at}.path`, message: "/setto belongs to the editor" });
|
|
94
|
+
} else if (paths.has(path2)) issues.push({ at: `${at}.path`, message: `duplicate path "${path2}"` });
|
|
95
|
+
paths.add(path2);
|
|
96
|
+
if (!titleKey) issues.push({ at: `${at}.titleKey`, message: "required" });
|
|
97
|
+
const sections = [];
|
|
98
|
+
const rawSections = Array.isArray(raw.sections) ? raw.sections : null;
|
|
99
|
+
if (!rawSections) issues.push({ at: `${at}.sections`, message: "must be a list" });
|
|
100
|
+
(rawSections ?? []).forEach((s, j) => {
|
|
101
|
+
const sat = `${at}.sections[${j}]`;
|
|
102
|
+
if (!isRecord(s)) {
|
|
103
|
+
issues.push({ at: sat, message: "not an object" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const sid = String(s.id ?? "");
|
|
107
|
+
if (!ID.test(sid)) issues.push({ at: `${sat}.id`, message: `invalid id "${sid}"` });
|
|
108
|
+
else if (sectionIds.has(sid)) issues.push({ at: `${sat}.id`, message: `duplicate section "${sid}"` });
|
|
109
|
+
sectionIds.add(sid);
|
|
110
|
+
const type = s.type;
|
|
111
|
+
if (type !== void 0 && !BUILTIN_SECTION_TYPES.includes(type)) {
|
|
112
|
+
issues.push({ at: `${sat}.type`, message: `unknown section type "${String(type)}"` });
|
|
113
|
+
}
|
|
114
|
+
sections.push({
|
|
115
|
+
id: sid,
|
|
116
|
+
...type !== void 0 ? { type } : {},
|
|
117
|
+
...s.hidden === true ? { hidden: true } : {},
|
|
118
|
+
...typeof s.label === "string" && s.label.trim() ? { label: s.label.trim() } : {}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
pages.push({
|
|
122
|
+
id,
|
|
123
|
+
path: path2,
|
|
124
|
+
titleKey,
|
|
125
|
+
...raw.hidden === true ? { hidden: true } : {},
|
|
126
|
+
sections
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
const blogs = pages.flatMap(
|
|
130
|
+
(p, i) => p.sections.flatMap((s, j) => s.type === "blog" ? [{ page: p, at: `pages[${i}].sections[${j}]` }] : [])
|
|
131
|
+
);
|
|
132
|
+
if (blogs.length > 1) {
|
|
133
|
+
issues.push({ at: blogs[1].at, message: "a site has one blog; another blog section is already on a page" });
|
|
134
|
+
}
|
|
135
|
+
for (const blog of blogs) {
|
|
136
|
+
if (blog.page.path === "/") issues.push({ at: blog.at, message: "the blog cannot be the front page" });
|
|
137
|
+
}
|
|
138
|
+
const menu = parseMenu(input.menu, "menu", issues);
|
|
139
|
+
const footer = parseMenu(input.footer, "footer", issues);
|
|
140
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
141
|
+
return { ok: true, manifest: { version: PAGES_MANIFEST_VERSION, pages, menu, footer } };
|
|
142
|
+
}
|
|
143
|
+
function parseMenu(raw, at, issues) {
|
|
144
|
+
if (raw === void 0) return [];
|
|
145
|
+
if (!Array.isArray(raw)) {
|
|
146
|
+
issues.push({ at, message: "must be a list" });
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const ids = /* @__PURE__ */ new Set();
|
|
150
|
+
const items = [];
|
|
151
|
+
raw.forEach((item, i) => {
|
|
152
|
+
const iat = `${at}[${i}]`;
|
|
153
|
+
if (!isRecord(item)) {
|
|
154
|
+
issues.push({ at: iat, message: "not an object" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const id = String(item.id ?? "");
|
|
158
|
+
const href = String(item.href ?? "");
|
|
159
|
+
const labelKey = String(item.labelKey ?? "");
|
|
160
|
+
if (!ID.test(id)) issues.push({ at: `${iat}.id`, message: `invalid id "${id}"` });
|
|
161
|
+
else if (ids.has(id)) issues.push({ at: `${iat}.id`, message: `duplicate item "${id}"` });
|
|
162
|
+
ids.add(id);
|
|
163
|
+
if (!isSafeHref(href)) issues.push({ at: `${iat}.href`, message: `invalid link "${href}"` });
|
|
164
|
+
if (!labelKey) issues.push({ at: `${iat}.labelKey`, message: "required" });
|
|
165
|
+
items.push({ id, href, labelKey, ...item.hidden === true ? { hidden: true } : {} });
|
|
166
|
+
});
|
|
167
|
+
return items;
|
|
168
|
+
}
|
|
169
|
+
function isSafeHref(href) {
|
|
170
|
+
if (!href) return false;
|
|
171
|
+
if (href.startsWith("/") && !href.startsWith("//")) return true;
|
|
172
|
+
if (href.startsWith("#")) return true;
|
|
173
|
+
return /^(https?:\/\/|mailto:|tel:)/i.test(href);
|
|
174
|
+
}
|
|
175
|
+
function normalisePath(path2) {
|
|
176
|
+
const bare = (path2.split(/[?#]/)[0] ?? "").replace(/\/+$/, "");
|
|
177
|
+
return bare === "" ? "/" : bare.toLowerCase();
|
|
178
|
+
}
|
|
179
|
+
function blogPage(manifest) {
|
|
180
|
+
for (const page of manifest.pages) {
|
|
181
|
+
const section = page.sections.find((s) => s.type === "blog");
|
|
182
|
+
if (section) return { page, section };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const escapeAttr = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
187
|
+
const escapeXml = escapeAttr;
|
|
188
|
+
const safeJson = (value) => JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
189
|
+
function htmlLang(locale) {
|
|
190
|
+
return locale === "no" || locale === "nb" ? "nb-NO" : locale === "nn" ? "nn-NO" : locale;
|
|
191
|
+
}
|
|
192
|
+
const OG_LOCALE = { no: "nb_NO", nb: "nb_NO", nn: "nn_NO", en: "en_GB", sv: "sv_SE", da: "da_DK", de: "de_DE" };
|
|
193
|
+
function withHead(indexHtml, meta) {
|
|
194
|
+
let html = indexHtml.replace(/<title>[\s\S]*?<\/title>\s*/gi, "").replace(/<meta\s+name=["']description["'][^>]*>\s*/gi, "").replace(/<meta\s+(?:property|name)=["'](?:og|twitter|article):[^"']*["'][^>]*>\s*/gi, "").replace(/<link\s+rel=["']canonical["'][^>]*>\s*/gi, "").replace(/<link\s+rel=["']alternate["']\s+type=["']application\/rss\+xml["'][^>]*>\s*/gi, "").replace(/<link\b(?=[^>]*\bhreflang=)[^>]*>\s*/gi, "").replace(/<script\s+type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>\s*/gi, "");
|
|
195
|
+
const lang = escapeAttr(htmlLang(meta.locale));
|
|
196
|
+
html = /<html\b[^>]*\blang=/i.test(html) ? html.replace(/(<html\b[^>]*\blang=)(["'])[^"']*\2/i, `$1"${lang}"`) : html.replace(/<html\b/i, `<html lang="${lang}"`);
|
|
197
|
+
const tags = [
|
|
198
|
+
`<title>${escapeAttr(meta.title)}</title>`,
|
|
199
|
+
`<meta name="description" content="${escapeAttr(meta.description)}" />`,
|
|
200
|
+
meta.url ? `<link rel="canonical" href="${escapeAttr(meta.url)}" />` : "",
|
|
201
|
+
...(meta.alternates ?? []).map(
|
|
202
|
+
(a) => `<link rel="alternate" hreflang="${escapeAttr(a.hreflang === "x-default" ? a.hreflang : htmlLang(a.hreflang))}" href="${escapeAttr(a.href)}" />`
|
|
203
|
+
),
|
|
204
|
+
meta.rssUrl ? `<link rel="alternate" type="application/rss+xml" title="${escapeAttr(meta.siteName ?? meta.title)}" href="${escapeAttr(meta.rssUrl)}" />` : "",
|
|
205
|
+
`<meta property="og:type" content="${meta.type}" />`,
|
|
206
|
+
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,
|
|
207
|
+
`<meta property="og:description" content="${escapeAttr(meta.description)}" />`,
|
|
208
|
+
meta.url ? `<meta property="og:url" content="${escapeAttr(meta.url)}" />` : "",
|
|
209
|
+
meta.image ? `<meta property="og:image" content="${escapeAttr(meta.image)}" />` : "",
|
|
210
|
+
meta.siteName ? `<meta property="og:site_name" content="${escapeAttr(meta.siteName)}" />` : "",
|
|
211
|
+
`<meta property="og:locale" content="${OG_LOCALE[meta.locale] ?? meta.locale}" />`,
|
|
212
|
+
meta.publishedTime ? `<meta property="article:published_time" content="${escapeAttr(meta.publishedTime)}" />` : "",
|
|
213
|
+
`<meta name="twitter:card" content="${meta.image ? "summary_large_image" : "summary"}" />`,
|
|
214
|
+
`<meta name="twitter:title" content="${escapeAttr(meta.title)}" />`,
|
|
215
|
+
`<meta name="twitter:description" content="${escapeAttr(meta.description)}" />`,
|
|
216
|
+
meta.image ? `<meta name="twitter:image" content="${escapeAttr(meta.image)}" />` : "",
|
|
217
|
+
meta.jsonLd ? `<script type="application/ld+json">${safeJson(meta.jsonLd)}<\/script>` : ""
|
|
218
|
+
].filter(Boolean);
|
|
219
|
+
return html.replace(/<\/head>/i, ` ${tags.join("\n ")}
|
|
220
|
+
</head>`);
|
|
221
|
+
}
|
|
222
|
+
function absolute(siteUrl, pathOrUrl) {
|
|
223
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
224
|
+
return siteUrl ? `${siteUrl.replace(/\/+$/, "")}${pathOrUrl}` : pathOrUrl;
|
|
225
|
+
}
|
|
226
|
+
function postHead({ post, blogPath, siteUrl, siteName }) {
|
|
227
|
+
const path2 = `${blogPath}/${post.slug}`;
|
|
228
|
+
const url = siteUrl ? absolute(siteUrl, path2) : void 0;
|
|
229
|
+
const image = post.cover ? absolute(siteUrl, post.cover) : void 0;
|
|
230
|
+
return {
|
|
231
|
+
title: siteName ? `${post.title} · ${siteName}` : post.title,
|
|
232
|
+
description: post.description,
|
|
233
|
+
url,
|
|
234
|
+
image,
|
|
235
|
+
type: "article",
|
|
236
|
+
locale: post.locale,
|
|
237
|
+
siteName,
|
|
238
|
+
publishedTime: post.date,
|
|
239
|
+
rssUrl: siteUrl ? absolute(siteUrl, `${blogPath}/rss.xml`) : void 0,
|
|
240
|
+
jsonLd: {
|
|
241
|
+
"@context": "https://schema.org",
|
|
242
|
+
"@type": "BlogPosting",
|
|
243
|
+
headline: post.title,
|
|
244
|
+
description: post.description,
|
|
245
|
+
datePublished: post.date,
|
|
246
|
+
inLanguage: post.locale,
|
|
247
|
+
...image ? { image } : {},
|
|
248
|
+
...url ? { url, mainEntityOfPage: url } : {},
|
|
249
|
+
...siteName ? { publisher: { "@type": "Organization", name: siteName } } : {},
|
|
250
|
+
...post.tags?.length ? { keywords: post.tags.join(", ") } : {}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function indexHead({
|
|
255
|
+
title,
|
|
256
|
+
description,
|
|
257
|
+
blogPath,
|
|
258
|
+
siteUrl,
|
|
259
|
+
siteName,
|
|
260
|
+
locale
|
|
261
|
+
}) {
|
|
262
|
+
const url = siteUrl ? absolute(siteUrl, blogPath) : void 0;
|
|
263
|
+
return {
|
|
264
|
+
title: siteName ? `${title} · ${siteName}` : title,
|
|
265
|
+
description,
|
|
266
|
+
url,
|
|
267
|
+
type: "website",
|
|
268
|
+
locale,
|
|
269
|
+
siteName,
|
|
270
|
+
rssUrl: siteUrl ? absolute(siteUrl, `${blogPath}/rss.xml`) : void 0,
|
|
271
|
+
jsonLd: {
|
|
272
|
+
"@context": "https://schema.org",
|
|
273
|
+
"@type": "Blog",
|
|
274
|
+
name: title,
|
|
275
|
+
...url ? { url } : {},
|
|
276
|
+
...siteName ? { publisher: { "@type": "Organization", name: siteName } } : {}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function rssDate(date) {
|
|
281
|
+
return (/* @__PURE__ */ new Date(`${date}T12:00:00Z`)).toUTCString();
|
|
282
|
+
}
|
|
283
|
+
function rssFeed({
|
|
284
|
+
posts,
|
|
285
|
+
blogPath,
|
|
286
|
+
siteUrl,
|
|
287
|
+
title,
|
|
288
|
+
description,
|
|
289
|
+
locale
|
|
290
|
+
}) {
|
|
291
|
+
const link = absolute(siteUrl, blogPath);
|
|
292
|
+
const self = absolute(siteUrl, `${blogPath}/rss.xml`);
|
|
293
|
+
const items = posts.map((post) => {
|
|
294
|
+
const url = absolute(siteUrl, `${blogPath}/${post.slug}`);
|
|
295
|
+
return [
|
|
296
|
+
" <item>",
|
|
297
|
+
` <title>${escapeXml(post.title)}</title>`,
|
|
298
|
+
` <link>${escapeXml(url)}</link>`,
|
|
299
|
+
` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
|
|
300
|
+
` <pubDate>${rssDate(post.date)}</pubDate>`,
|
|
301
|
+
` <description>${escapeXml(post.description)}</description>`,
|
|
302
|
+
" </item>"
|
|
303
|
+
].join("\n");
|
|
304
|
+
}).join("\n");
|
|
305
|
+
return [
|
|
306
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
307
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
|
308
|
+
" <channel>",
|
|
309
|
+
` <title>${escapeXml(title)}</title>`,
|
|
310
|
+
` <link>${escapeXml(link)}</link>`,
|
|
311
|
+
` <description>${escapeXml(description)}</description>`,
|
|
312
|
+
` <language>${escapeXml(locale === "no" ? "nb" : locale)}</language>`,
|
|
313
|
+
` <atom:link href="${escapeXml(self)}" rel="self" type="application/rss+xml" />`,
|
|
314
|
+
items,
|
|
315
|
+
" </channel>",
|
|
316
|
+
"</rss>",
|
|
317
|
+
""
|
|
318
|
+
].filter((line) => line !== "").join("\n") + "\n";
|
|
319
|
+
}
|
|
320
|
+
function withSitemapEntries(sitemap, entries) {
|
|
321
|
+
const missing = entries.filter((entry) => !sitemap.includes(`<loc>${escapeXml(entry.loc)}</loc>`));
|
|
322
|
+
if (missing.length === 0 || !/<\/urlset>/i.test(sitemap)) return sitemap;
|
|
323
|
+
const block = missing.map(
|
|
324
|
+
(entry) => [
|
|
325
|
+
" <url>",
|
|
326
|
+
` <loc>${escapeXml(entry.loc)}</loc>`,
|
|
327
|
+
entry.lastmod ? ` <lastmod>${escapeXml(entry.lastmod)}</lastmod>` : "",
|
|
328
|
+
" </url>"
|
|
329
|
+
].filter(Boolean).join("\n")
|
|
330
|
+
).join("\n");
|
|
331
|
+
return sitemap.replace(/<\/urlset>/i, `${block}
|
|
332
|
+
</urlset>`);
|
|
333
|
+
}
|
|
334
|
+
function originFromSitemap(sitemap) {
|
|
335
|
+
const loc = /<loc>\s*([^<\s]+)\s*<\/loc>/i.exec(sitemap)?.[1];
|
|
336
|
+
if (!loc) return null;
|
|
337
|
+
try {
|
|
338
|
+
return new URL(loc).origin;
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const INDEX_ID = "virtual:setto-blog";
|
|
344
|
+
const POST_PREFIX = "virtual:setto-blog/post/";
|
|
345
|
+
const RESOLVED = "\0";
|
|
346
|
+
function stripForBrowser(node) {
|
|
347
|
+
if (Array.isArray(node)) return node.map(stripForBrowser);
|
|
348
|
+
if (!node || typeof node !== "object") return node;
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const [key, value] of Object.entries(node)) {
|
|
351
|
+
if (key === "position" || key === "data") continue;
|
|
352
|
+
out[key] = stripForBrowser(value);
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
function formatIssues(file, issues) {
|
|
357
|
+
return issues.map((i) => ` ${file}${i.line ? `:${i.line}` : ""} (${i.path}): ${i.message}`).join("\n");
|
|
358
|
+
}
|
|
359
|
+
function loadPosts(root, imageHosts) {
|
|
360
|
+
const dir = path.join(root, BLOG_DIR);
|
|
361
|
+
if (!existsSync(dir)) return [];
|
|
362
|
+
const posts = [];
|
|
363
|
+
const problems = [];
|
|
364
|
+
for (const name of readdirSync(dir).sort()) {
|
|
365
|
+
if (!name.endsWith(".mdx")) continue;
|
|
366
|
+
const repoPath = `${BLOG_DIR}/${name}`;
|
|
367
|
+
if (!parseBlogPostPath(repoPath)) {
|
|
368
|
+
problems.push(` ${repoPath}: not <slug>.<locale>.mdx`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const result = validateBlogPost(readFileSync(path.join(dir, name), "utf8"), { path: repoPath, imageHosts });
|
|
372
|
+
if (!result.ok) {
|
|
373
|
+
problems.push(formatIssues(repoPath, result.issues));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const { slug, locale } = result.post.frontmatter;
|
|
377
|
+
posts.push({ key: `${slug}.${locale}`, frontmatter: result.post.frontmatter, body: stripForBrowser(result.post.body) });
|
|
378
|
+
}
|
|
379
|
+
if (problems.length) {
|
|
380
|
+
throw new Error(`[setto-blog] ${problems.length === 1 ? "a post breaks" : "posts break"} the blog contract:
|
|
381
|
+
${problems.join("\n")}`);
|
|
382
|
+
}
|
|
383
|
+
return posts;
|
|
384
|
+
}
|
|
385
|
+
function readJson(file) {
|
|
386
|
+
try {
|
|
387
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function settoBlog(options = {}) {
|
|
393
|
+
const imageHosts = options.imageHosts ?? [SETTO_MEDIA_HOST];
|
|
394
|
+
let config;
|
|
395
|
+
let cache = null;
|
|
396
|
+
const posts = () => cache ??= loadPosts(config.root, imageHosts);
|
|
397
|
+
return {
|
|
398
|
+
name: "setto-blog",
|
|
399
|
+
configResolved(resolved) {
|
|
400
|
+
config = resolved;
|
|
401
|
+
},
|
|
402
|
+
resolveId(id) {
|
|
403
|
+
if (id === INDEX_ID || id.startsWith(POST_PREFIX)) return RESOLVED + id;
|
|
404
|
+
return null;
|
|
405
|
+
},
|
|
406
|
+
load(id) {
|
|
407
|
+
if (!id.startsWith(RESOLVED)) return null;
|
|
408
|
+
const bare = id.slice(RESOLVED.length);
|
|
409
|
+
if (bare === INDEX_ID) {
|
|
410
|
+
const list = posts();
|
|
411
|
+
const loaders = list.map((p) => ` ${JSON.stringify(p.key)}: () => import(${JSON.stringify(POST_PREFIX + p.key)}),`).join("\n");
|
|
412
|
+
return [
|
|
413
|
+
`const posts = ${JSON.stringify(list.map((p) => p.frontmatter))};`,
|
|
414
|
+
`const loaders = {
|
|
415
|
+
${loaders}
|
|
416
|
+
};`,
|
|
417
|
+
"export default {",
|
|
418
|
+
" posts,",
|
|
419
|
+
" load(slug, locale) {",
|
|
420
|
+
" const load = loaders[slug + '.' + locale];",
|
|
421
|
+
" return load ? load().then((m) => m.default) : Promise.resolve(null);",
|
|
422
|
+
" },",
|
|
423
|
+
"};"
|
|
424
|
+
].join("\n");
|
|
425
|
+
}
|
|
426
|
+
if (bare.startsWith(POST_PREFIX)) {
|
|
427
|
+
const post = posts().find((p) => p.key === bare.slice(POST_PREFIX.length));
|
|
428
|
+
if (!post) return "export default null;";
|
|
429
|
+
return `export default ${JSON.stringify({ frontmatter: post.frontmatter, body: post.body })};`;
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
},
|
|
433
|
+
configureServer(server) {
|
|
434
|
+
const dir = path.join(config.root, BLOG_DIR);
|
|
435
|
+
server.watcher.add(dir);
|
|
436
|
+
const changed = (file) => {
|
|
437
|
+
if (!path.resolve(file).startsWith(path.resolve(dir))) return;
|
|
438
|
+
cache = null;
|
|
439
|
+
for (const [id, mod] of server.moduleGraph.idToModuleMap) {
|
|
440
|
+
if (id.startsWith(RESOLVED + INDEX_ID)) server.moduleGraph.invalidateModule(mod);
|
|
441
|
+
}
|
|
442
|
+
server.ws.send({ type: "full-reload" });
|
|
443
|
+
};
|
|
444
|
+
server.watcher.on("add", changed).on("change", changed).on("unlink", changed);
|
|
445
|
+
},
|
|
446
|
+
writeBundle: {
|
|
447
|
+
sequential: true,
|
|
448
|
+
handler() {
|
|
449
|
+
if (config.command !== "build" || config.build.ssr) return;
|
|
450
|
+
writeStaticFiles(config.root, path.resolve(config.root, config.build.outDir), posts(), options);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function writeStaticFiles(root, outDir, loaded, options = {}) {
|
|
456
|
+
const written = [];
|
|
457
|
+
const manifestRaw = readJson(path.join(root, PAGES_PATH));
|
|
458
|
+
const parsed = manifestRaw ? parseManifest(manifestRaw) : null;
|
|
459
|
+
const blog = parsed?.ok ? blogPage(parsed.manifest) : null;
|
|
460
|
+
if (blog && (blog.page.hidden || blog.section.hidden)) return { written };
|
|
461
|
+
const blogPath = blog?.page.path ?? (options.blogPath ? normalisePath(options.blogPath) : null);
|
|
462
|
+
if (!blogPath || blogPath === "/") return { written };
|
|
463
|
+
const indexFile = path.join(outDir, "index.html");
|
|
464
|
+
if (!existsSync(indexFile)) return { written };
|
|
465
|
+
const indexHtml = readFileSync(indexFile, "utf8");
|
|
466
|
+
const settoConfig = readJson(path.join(root, "setto.config.json")) ?? {};
|
|
467
|
+
const languages = settoConfig.languages?.length ? settoConfig.languages : ["no"];
|
|
468
|
+
const language = languages[0];
|
|
469
|
+
const siteName = settoConfig.displayName;
|
|
470
|
+
const sitemapFile = path.join(outDir, "sitemap.xml");
|
|
471
|
+
const sitemap = existsSync(sitemapFile) ? readFileSync(sitemapFile, "utf8") : null;
|
|
472
|
+
const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL;
|
|
473
|
+
const siteUrl = options.siteUrl ?? process.env.SETTO_SITE_URL ?? (vercel ? `https://${vercel}` : null) ?? (sitemap ? originFromSitemap(sitemap) : null);
|
|
474
|
+
const posts = postsForLanguage(
|
|
475
|
+
loaded.map((p) => p.frontmatter),
|
|
476
|
+
language,
|
|
477
|
+
language
|
|
478
|
+
);
|
|
479
|
+
const write = (relative, content) => {
|
|
480
|
+
const file = path.join(outDir, relative);
|
|
481
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
482
|
+
writeFileSync(file, content);
|
|
483
|
+
written.push(relative.split(path.sep).join("/"));
|
|
484
|
+
};
|
|
485
|
+
const all = loaded.map((p) => p.frontmatter);
|
|
486
|
+
const prefixOf = (lang) => lang === language ? "" : `/${lang}`;
|
|
487
|
+
const versionsOf = (slug) => all.filter((p) => p.slug === slug);
|
|
488
|
+
for (const lang of languages) {
|
|
489
|
+
const langBlogPath = `${prefixOf(lang)}${blogPath}`;
|
|
490
|
+
const dir = langBlogPath.replace(/^\//, "");
|
|
491
|
+
const langStrings = blogStrings(lang);
|
|
492
|
+
write(
|
|
493
|
+
path.join(dir, "index.html"),
|
|
494
|
+
withHead(
|
|
495
|
+
indexHtml,
|
|
496
|
+
indexHead({
|
|
497
|
+
title: langStrings.blog,
|
|
498
|
+
description: siteName ? `${langStrings.latest} · ${siteName}` : langStrings.latest,
|
|
499
|
+
blogPath: langBlogPath,
|
|
500
|
+
siteUrl,
|
|
501
|
+
siteName,
|
|
502
|
+
locale: lang
|
|
503
|
+
})
|
|
504
|
+
)
|
|
505
|
+
);
|
|
506
|
+
for (const post of postsForLanguage(all, lang, language)) {
|
|
507
|
+
const meta = postHead({ post, blogPath: langBlogPath, siteUrl, siteName });
|
|
508
|
+
const versions = versionsOf(post.slug);
|
|
509
|
+
if (post.locale !== lang && siteUrl) meta.url = absolute(siteUrl, `${prefixOf(post.locale)}${blogPath}/${post.slug}`);
|
|
510
|
+
if (siteUrl && versions.length > 1) {
|
|
511
|
+
meta.alternates = [
|
|
512
|
+
...versions.map((v) => ({ hreflang: v.locale, href: absolute(siteUrl, `${prefixOf(v.locale)}${blogPath}/${v.slug}`) })),
|
|
513
|
+
{ hreflang: "x-default", href: absolute(siteUrl, `${blogPath}/${post.slug}`) }
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
write(path.join(dir, post.slug, "index.html"), withHead(indexHtml, meta));
|
|
517
|
+
}
|
|
518
|
+
if (siteUrl) {
|
|
519
|
+
const feedTitle = siteName ? `${langStrings.blog} · ${siteName}` : langStrings.blog;
|
|
520
|
+
write(
|
|
521
|
+
path.join(dir, "rss.xml"),
|
|
522
|
+
rssFeed({
|
|
523
|
+
posts: postsForLanguage(all, lang, language).slice(0, 50),
|
|
524
|
+
blogPath: langBlogPath,
|
|
525
|
+
siteUrl,
|
|
526
|
+
title: feedTitle,
|
|
527
|
+
description: siteName ? `${langStrings.latest} · ${siteName}` : langStrings.latest,
|
|
528
|
+
locale: lang
|
|
529
|
+
})
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (siteUrl) {
|
|
534
|
+
if (sitemap) {
|
|
535
|
+
const entries = [
|
|
536
|
+
{ loc: absolute(siteUrl, blogPath), lastmod: posts[0]?.date },
|
|
537
|
+
...posts.map((post) => ({ loc: absolute(siteUrl, `${blogPath}/${post.slug}`), lastmod: post.date })),
|
|
538
|
+
// A version in another language is a page of its own.
|
|
539
|
+
...all.filter((post) => post.locale !== language && languages.includes(post.locale)).map((post) => ({ loc: absolute(siteUrl, `${prefixOf(post.locale)}${blogPath}/${post.slug}`), lastmod: post.date }))
|
|
540
|
+
];
|
|
541
|
+
const next = withSitemapEntries(sitemap, entries);
|
|
542
|
+
if (next !== sitemap) write("sitemap.xml", next);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { written };
|
|
546
|
+
}
|
|
547
|
+
export {
|
|
548
|
+
settoBlog
|
|
549
|
+
};
|
|
550
|
+
//# sourceMappingURL=vite.js.map
|