@softure-ai/blog 0.1.7 → 0.1.8
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/CHANGELOG.md +18 -0
- package/README.md +77 -17
- package/dist/cli/run.js +1 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/skill.d.ts.map +1 -1
- package/dist/cli/skill.js +2 -1
- package/dist/cli/skill.js.map +1 -1
- package/dist/db/articles.d.ts.map +1 -1
- package/dist/db/articles.js +7 -3
- package/dist/db/articles.js.map +1 -1
- package/dist/db/history.d.ts +7 -4
- package/dist/db/history.d.ts.map +1 -1
- package/dist/db/history.js +8 -4
- package/dist/db/history.js.map +1 -1
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +2 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/json-ld.d.ts +13 -0
- package/dist/next/json-ld.d.ts.map +1 -0
- package/dist/next/json-ld.js +42 -0
- package/dist/next/json-ld.js.map +1 -0
- package/dist/next/metadata.d.ts +18 -0
- package/dist/next/metadata.d.ts.map +1 -0
- package/dist/next/metadata.js +84 -0
- package/dist/next/metadata.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +16 -77
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +23 -7
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +23 -0
- package/dist/options.js.map +1 -1
- package/dist/pages/body.d.ts +1 -1
- package/dist/pages/body.d.ts.map +1 -1
- package/dist/pages/body.js +1 -0
- package/dist/pages/body.js.map +1 -1
- package/dist/pages/index.d.ts +1 -1
- package/dist/pages/index.d.ts.map +1 -1
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/redirects.d.ts +22 -6
- package/dist/pages/redirects.d.ts.map +1 -1
- package/dist/pages/redirects.js +5 -2
- package/dist/pages/redirects.js.map +1 -1
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +8 -1
- package/dist/proxy/index.js.map +1 -1
- package/dist/quality/index.d.ts +1 -1
- package/dist/quality/index.d.ts.map +1 -1
- package/dist/quality/index.js.map +1 -1
- package/dist/quality/link-targets.d.ts.map +1 -1
- package/dist/quality/link-targets.js +6 -5
- package/dist/quality/link-targets.js.map +1 -1
- package/dist/quality/options.d.ts +3 -3
- package/dist/quality/options.d.ts.map +1 -1
- package/dist/quality/options.js +6 -3
- package/dist/quality/options.js.map +1 -1
- package/dist/quality/settings.d.ts +11 -0
- package/dist/quality/settings.d.ts.map +1 -1
- package/dist/quality/settings.js +7 -1
- package/dist/quality/settings.js.map +1 -1
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +1 -1
- package/dist/render/index.js.map +1 -1
- package/dist/render/render-article.d.ts +8 -0
- package/dist/render/render-article.d.ts.map +1 -1
- package/dist/render/render-article.js +14 -7
- package/dist/render/render-article.js.map +1 -1
- package/dist/server/options.js +1 -1
- package/dist/server/options.js.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/skill.ts +2 -1
- package/src/db/articles.ts +11 -6
- package/src/db/history.ts +15 -8
- package/src/index.ts +1 -1
- package/src/next/index.ts +9 -0
- package/src/next/json-ld.ts +47 -0
- package/src/next/metadata.ts +103 -0
- package/src/next/pages.tsx +16 -82
- package/src/options.ts +26 -1
- package/src/pages/body.ts +2 -1
- package/src/pages/index.ts +3 -0
- package/src/pages/redirects.ts +27 -3
- package/src/proxy/index.ts +9 -1
- package/src/quality/index.ts +1 -1
- package/src/quality/link-targets.ts +6 -5
- package/src/quality/options.ts +6 -3
- package/src/quality/settings.ts +17 -1
- package/src/render/index.ts +2 -0
- package/src/render/render-article.ts +22 -7
- package/src/server/options.ts +1 -1
package/src/next/pages.tsx
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
// articles and terms render on their first request and are kept for `revalidate` seconds (ISR).
|
|
15
15
|
// 301 and 410 are answered before the page by `@softure-ai/blog/proxy`; anything else that is not a
|
|
16
16
|
// published text of the page's kind is a 404 here.
|
|
17
|
-
|
|
17
|
+
//
|
|
18
|
+
// An app with its own page components keeps the rest: `generate*Metadata` and `BlogArticleOgImage`
|
|
19
|
+
// mount next to its own page, and `build*Metadata` (metadata.ts) and `build*JsonLd` (json-ld.ts) take
|
|
20
|
+
// a text it already read.
|
|
21
|
+
import { getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
18
22
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
19
23
|
// `next/types.js`, not `next`: the root entry adds Next's globals (a read-only NODE_ENV) to every
|
|
20
24
|
// program that includes this file.
|
|
@@ -25,9 +29,7 @@ import type { BlogArticle } from "../contract.js";
|
|
|
25
29
|
import { getRelatedArticles } from "../discovery/related.js";
|
|
26
30
|
import { findArticlesLinkingTerm, renderPageBody, type RenderPageBodyOptions } from "../pages/body.js";
|
|
27
31
|
import { getArticleDates } from "../pages/dates.js";
|
|
28
|
-
import {
|
|
29
|
-
import { getArticleCrumbs, getClusterLabel, getTermCrumbs, groupByCluster, sortTerms, type CrumbLabels } from "../pages/listing.js";
|
|
30
|
-
import { getArticlePath, getTermPath } from "../pages/paths.js";
|
|
32
|
+
import { getArticleCrumbs, groupByCluster, getTermCrumbs, sortTerms } from "../pages/listing.js";
|
|
31
33
|
import { toGlossary } from "../render/glossary.js";
|
|
32
34
|
import { getBlogOptions } from "../server/options.js";
|
|
33
35
|
import { BlogArticleView } from "../ui/blog-article.js";
|
|
@@ -37,6 +39,8 @@ import { BlogMethodView } from "../ui/blog-method.js";
|
|
|
37
39
|
import type { BlogPageContext } from "../ui/page-context.js";
|
|
38
40
|
import { getPageContext } from "./context.js";
|
|
39
41
|
import { getPublishedArticles, getPublishedTerms, getTextBySlug } from "./data.js";
|
|
42
|
+
import { buildArticleJsonLd, buildGlossaryJsonLd, buildTermJsonLd, getCrumbLabels } from "./json-ld.js";
|
|
43
|
+
import { buildArticleMetadata, buildBlogIndexMetadata, buildGlossaryIndexMetadata, buildMethodMetadata, buildTermMetadata } from "./metadata.js";
|
|
40
44
|
|
|
41
45
|
type SlugParams = Promise<{ readonly slug: string }>;
|
|
42
46
|
|
|
@@ -67,76 +71,15 @@ function isPublished(text: BlogArticle | null, kind: BlogArticle["kind"]): text
|
|
|
67
71
|
return text !== null && text.status === "published" && text.kind === kind;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
function getCrumbLabels(config: SoftureConfig, context: BlogPageContext): CrumbLabels {
|
|
71
|
-
const clusters = getBlogOptions(config).clusters;
|
|
72
|
-
return {
|
|
73
|
-
blog: context.messages.pages.blogTitle,
|
|
74
|
-
glossary: context.messages.glossary.title,
|
|
75
|
-
cluster: (cluster) => getClusterLabel(cluster, clusters, config.locale),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function getJsonLdContext(config: SoftureConfig, context: BlogPageContext): JsonLdContext {
|
|
80
|
-
return { urls: getSiteUrls(config), routes: context.routes, locale: config.locale, timezone: config.timezone, brand: context.brand };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
74
|
function getBodyOptions(config: SoftureConfig, context: BlogPageContext, terms: readonly BlogArticle[]): RenderPageBodyOptions {
|
|
84
75
|
const origins = [config.appOrigin, getSiteUrls(config).origin];
|
|
85
76
|
return { glossary: toGlossary(terms), routes: context.routes, options: getBlogOptions(config), origins, messages: context.messages };
|
|
86
77
|
}
|
|
87
78
|
|
|
88
|
-
function withBrand(title: string, context: BlogPageContext): string {
|
|
89
|
-
return context.brand === null ? title : formatMessage(context.messages.pages.titleWithBrand, { title, brand: context.brand });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A page's canonical URL: seo's host and trailing-slash rule when the app lists seo, else on `appOrigin`. */
|
|
93
|
-
function getCanonicalUrl(config: SoftureConfig, path: string): string {
|
|
94
|
-
return getSiteUrls(config).getCanonicalUrl(path);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The feed link a reader finds in `<head>` (`<link rel="alternate" type="application/rss+xml">`); a file, so no trailing-slash rule. */
|
|
98
|
-
function getFeedAlternates(config: SoftureConfig, context: BlogPageContext): NonNullable<Metadata["alternates"]>["types"] {
|
|
99
|
-
return { "application/rss+xml": [{ url: `${getSiteUrls(config).origin}${context.routes.rss}`, title: withBrand(context.messages.pages.blogTitle, context) }] };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Metadata of a page with a fixed path; an empty listing stays out of the index (thin content). */
|
|
103
|
-
function getStaticMetadata(config: SoftureConfig, context: BlogPageContext, page: { title: string; description: string; path: string; isEmpty?: boolean; hasFeed?: boolean }): Metadata {
|
|
104
|
-
return {
|
|
105
|
-
title: withBrand(page.title, context),
|
|
106
|
-
description: page.description,
|
|
107
|
-
robots: { index: page.isEmpty !== true, follow: true },
|
|
108
|
-
alternates: { canonical: getCanonicalUrl(config, page.path), ...(page.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
109
|
-
openGraph: { type: "website", title: page.title, description: page.description, url: getCanonicalUrl(config, page.path), ...(context.brand === null ? {} : { siteName: context.brand }) },
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function getTextMetadata(config: SoftureConfig, context: BlogPageContext, text: BlogArticle, path: string, options: { hasFeed?: boolean } = {}): Metadata {
|
|
114
|
-
const dates = getArticleDates(text, config.timezone);
|
|
115
|
-
const url = getCanonicalUrl(config, path);
|
|
116
|
-
return {
|
|
117
|
-
title: withBrand(text.title, context),
|
|
118
|
-
description: text.description,
|
|
119
|
-
robots: { index: true, follow: true },
|
|
120
|
-
alternates: { canonical: url, ...(options.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
121
|
-
openGraph: {
|
|
122
|
-
type: "article",
|
|
123
|
-
title: text.title,
|
|
124
|
-
description: text.description,
|
|
125
|
-
url,
|
|
126
|
-
locale: config.locale,
|
|
127
|
-
publishedTime: dates.published,
|
|
128
|
-
modifiedTime: dates.updated ?? dates.published,
|
|
129
|
-
...(context.brand === null ? {} : { siteName: context.brand }),
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
79
|
export async function generateBlogIndexMetadata(): Promise<Metadata> {
|
|
135
80
|
const config = getSoftureConfig();
|
|
136
|
-
const context = getPageContext(config);
|
|
137
81
|
const articles = await getPublishedArticles(config);
|
|
138
|
-
|
|
139
|
-
return getStaticMetadata(config, context, { title: copy.blogTitle, description: copy.blogDescription, path: context.routes.index, isEmpty: articles.length === 0, hasFeed: true });
|
|
82
|
+
return buildBlogIndexMetadata(config, { isEmpty: articles.length === 0 });
|
|
140
83
|
}
|
|
141
84
|
|
|
142
85
|
/** The listing: cards grouped by cluster, the pillar first. Mount with `dynamic = "force-dynamic"`. */
|
|
@@ -153,9 +96,7 @@ export async function generateArticleMetadata({ params }: { readonly params: Slu
|
|
|
153
96
|
const config = getSoftureConfig();
|
|
154
97
|
const { slug } = await params;
|
|
155
98
|
const article = await getTextBySlug(config, slug);
|
|
156
|
-
|
|
157
|
-
const context = getPageContext(config);
|
|
158
|
-
return getTextMetadata(config, context, article, getArticlePath(context.routes, article.slug), { hasFeed: true });
|
|
99
|
+
return isPublished(article, "article") ? buildArticleMetadata(config, article) : {};
|
|
159
100
|
}
|
|
160
101
|
|
|
161
102
|
/** An article with "read next" under it. Mount with `revalidate` and `generateBlogStaticParams`. */
|
|
@@ -168,7 +109,7 @@ export async function BlogArticlePage({ params, cta, afterArticle }: BlogArticle
|
|
|
168
109
|
const [terms, published] = await Promise.all([getPublishedTerms(config), getPublishedArticles(config)]);
|
|
169
110
|
const body = renderPageBody<ReactNode>(article, getBodyOptions(config, context, terms));
|
|
170
111
|
const crumbs = getArticleCrumbs(article, context.routes, getCrumbLabels(config, context));
|
|
171
|
-
const jsonLd =
|
|
112
|
+
const jsonLd = buildArticleJsonLd(config, article);
|
|
172
113
|
return (
|
|
173
114
|
<BlogArticleView
|
|
174
115
|
context={context}
|
|
@@ -186,10 +127,8 @@ export async function BlogArticlePage({ params, cta, afterArticle }: BlogArticle
|
|
|
186
127
|
|
|
187
128
|
export async function generateGlossaryIndexMetadata(): Promise<Metadata> {
|
|
188
129
|
const config = getSoftureConfig();
|
|
189
|
-
const context = getPageContext(config);
|
|
190
130
|
const terms = await getPublishedTerms(config);
|
|
191
|
-
|
|
192
|
-
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.glossary, isEmpty: terms.length === 0 });
|
|
131
|
+
return buildGlossaryIndexMetadata(config, { isEmpty: terms.length === 0 });
|
|
193
132
|
}
|
|
194
133
|
|
|
195
134
|
/** The glossary index. Mount with `dynamic = "force-dynamic"`. */
|
|
@@ -197,7 +136,7 @@ export async function GlossaryIndexPage() {
|
|
|
197
136
|
const config = getSoftureConfig();
|
|
198
137
|
const context = getPageContext(config);
|
|
199
138
|
const terms = sortTerms(await getPublishedTerms(config), config.locale);
|
|
200
|
-
const jsonLd =
|
|
139
|
+
const jsonLd = buildGlossaryJsonLd(config, terms);
|
|
201
140
|
return <GlossaryIndexView context={context} terms={terms} jsonLd={jsonLd} />;
|
|
202
141
|
}
|
|
203
142
|
|
|
@@ -205,9 +144,7 @@ export async function generateTermMetadata({ params }: { readonly params: SlugPa
|
|
|
205
144
|
const config = getSoftureConfig();
|
|
206
145
|
const { slug } = await params;
|
|
207
146
|
const term = await getTextBySlug(config, slug);
|
|
208
|
-
|
|
209
|
-
const context = getPageContext(config);
|
|
210
|
-
return getTextMetadata(config, context, term, getTermPath(context.routes, term.slug));
|
|
147
|
+
return isPublished(term, "term") ? buildTermMetadata(config, term) : {};
|
|
211
148
|
}
|
|
212
149
|
|
|
213
150
|
/** A glossary term with the articles that expand on it. Mount with `revalidate` and `generateBlogStaticParams`. */
|
|
@@ -220,7 +157,7 @@ export async function GlossaryTermPage({ params, cta }: GlossaryTermPageProps) {
|
|
|
220
157
|
const [terms, articles] = await Promise.all([getPublishedTerms(config), getPublishedArticles(config)]);
|
|
221
158
|
const bodyOptions = getBodyOptions(config, context, terms);
|
|
222
159
|
const crumbs = getTermCrumbs(term, context.routes, getCrumbLabels(config, context));
|
|
223
|
-
const jsonLd =
|
|
160
|
+
const jsonLd = buildTermJsonLd(config, term);
|
|
224
161
|
return (
|
|
225
162
|
<GlossaryTermView
|
|
226
163
|
context={context}
|
|
@@ -236,10 +173,7 @@ export async function GlossaryTermPage({ params, cta }: GlossaryTermPageProps) {
|
|
|
236
173
|
}
|
|
237
174
|
|
|
238
175
|
export function generateMethodMetadata(): Metadata {
|
|
239
|
-
|
|
240
|
-
const context = getPageContext(config);
|
|
241
|
-
const copy = context.messages.method;
|
|
242
|
-
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.method });
|
|
176
|
+
return buildMethodMetadata(getSoftureConfig());
|
|
243
177
|
}
|
|
244
178
|
|
|
245
179
|
/** "How our texts are made". Mount it with `blog({ methodPage: true })`; without that it is a 404. */
|
package/src/options.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The options an app passes to `blog({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { qualitySettingSchema } from "./quality/options.js";
|
|
4
|
+
import type { GonePageRenderInput } from "./pages/redirects.js";
|
|
4
5
|
import type { ArticleImagePolicy } from "./render/images.js";
|
|
5
|
-
import type
|
|
6
|
+
import { EXTERNAL_LINK_MARKERS, type BlockPlugin } from "./render/render-article.js";
|
|
6
7
|
|
|
7
8
|
/** Where the app keeps its article files unless a command names a path. */
|
|
8
9
|
export const DEFAULT_CONTENT_DIR = "content/blog";
|
|
@@ -162,6 +163,23 @@ const skillSectionSchema = z.strictObject({
|
|
|
162
163
|
.refine((body) => !hasTopHeading(body), "must not hold a # or ## heading; use ### and deeper (the title is the section's ## heading)"),
|
|
163
164
|
});
|
|
164
165
|
|
|
166
|
+
/** A 410 link: a path from the site root or an https URL, never another scheme. */
|
|
167
|
+
const goneHrefSchema = z
|
|
168
|
+
.string()
|
|
169
|
+
.trim()
|
|
170
|
+
.refine((href) => (href.startsWith("/") && !href.startsWith("//")) || (href.startsWith("https://") && URL.canParse(href)), "must be a path from the site root or an https URL, e.g. /calculator");
|
|
171
|
+
|
|
172
|
+
const gonePageSchema = z.strictObject({
|
|
173
|
+
/** Further ways on, listed under the link to the listing. */
|
|
174
|
+
links: z.array(z.strictObject({ href: goneHrefSchema, label: localizedTextSchema })).max(5).default([]),
|
|
175
|
+
/**
|
|
176
|
+
* `(input) => html`: the whole body of the 410, written by the app (its own HTML, trusted like a
|
|
177
|
+
* block plugin's; the proxy still answers 410 with `text/html`). `input.links` holds the labels in
|
|
178
|
+
* the app's locale.
|
|
179
|
+
*/
|
|
180
|
+
render: z.custom<(input: GonePageRenderInput) => string>((value) => typeof value === "function", "must be a function: (input) => html").optional(),
|
|
181
|
+
});
|
|
182
|
+
|
|
165
183
|
const skillSchema = z.strictObject({
|
|
166
184
|
/** The app's own sections (its numbers, block plugins, fields), written to `references/app.md` of the skill. */
|
|
167
185
|
sections: z.array(skillSectionSchema).superRefine((sections, ctx) => {
|
|
@@ -204,11 +222,18 @@ export const blogOptionsSchema = z
|
|
|
204
222
|
blocks: z.array(z.custom<BlockPlugin>(isBlockPlugin, "must be a block plugin: { type: \"chart\", render(block) }")).default([]),
|
|
205
223
|
/** Hosts besides those of `appOrigin` and the canonical site origin (`getSiteUrls`) whose links are not marked external (subdomains included). */
|
|
206
224
|
siteHosts: z.array(z.string().regex(HOSTNAME, "must be a host name, e.g. example.com")).default([]),
|
|
225
|
+
/**
|
|
226
|
+
* What the pages append to an external link (`renderArticle({ externalMarker })`): the arrow and a
|
|
227
|
+
* visually hidden "opens in a new tab", the hidden words only, or nothing.
|
|
228
|
+
*/
|
|
229
|
+
externalLinkMarker: z.enum(EXTERNAL_LINK_MARKERS).default("icon-and-text"),
|
|
207
230
|
/**
|
|
208
231
|
* Which images article bodies may show (`renderArticle({ images })`), used by the pages and the
|
|
209
232
|
* quality gate; without it every image renders as its alt text and the gate refuses it.
|
|
210
233
|
*/
|
|
211
234
|
images: imagePolicySchema.optional(),
|
|
235
|
+
/** The page a withdrawn text answers with (410): extra links, or the app's own body. */
|
|
236
|
+
gonePage: gonePageSchema.default({ links: [] }),
|
|
212
237
|
/** How long the listing and glossary cache their reads; keep it equal to the pages' `revalidate`. */
|
|
213
238
|
revalidateSeconds: z.number().int().min(1).default(DEFAULT_REVALIDATE_SECONDS),
|
|
214
239
|
/** The text quality gate (`softure-blog check`, and every publish); `false` turns it off. */
|
package/src/pages/body.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { getTermPath, type BlogRoutes } from "./paths.js";
|
|
|
11
11
|
export interface RenderPageBodyOptions {
|
|
12
12
|
readonly glossary: readonly GlossaryTerm[];
|
|
13
13
|
readonly routes: BlogRoutes;
|
|
14
|
-
readonly options: Pick<BlogOptions, "blocks" | "images" | "siteHosts"
|
|
14
|
+
readonly options: Pick<BlogOptions, "blocks" | "images" | "siteHosts"> & Partial<Pick<BlogOptions, "externalLinkMarker">>;
|
|
15
15
|
/** The app's own origins (`appOrigin` and the canonical site origin); their hosts are the site's (links to them are not external). */
|
|
16
16
|
readonly origins: readonly string[];
|
|
17
17
|
readonly messages: BlogMessages;
|
|
@@ -28,6 +28,7 @@ export function renderPageBody<TNode = unknown>(text: BlogArticle, input: Render
|
|
|
28
28
|
// app's plugins return.
|
|
29
29
|
blocks: input.options.blocks as readonly BlockPlugin<TNode>[],
|
|
30
30
|
article: { currentAsOf: text.currentAsOf, fields: text.fields },
|
|
31
|
+
...(input.options.externalLinkMarker === undefined ? {} : { externalMarker: input.options.externalLinkMarker }),
|
|
31
32
|
messages: input.messages.render,
|
|
32
33
|
});
|
|
33
34
|
}
|
package/src/pages/index.ts
CHANGED
package/src/pages/redirects.ts
CHANGED
|
@@ -92,15 +92,38 @@ export interface GonePageCopy {
|
|
|
92
92
|
readonly link: string;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/** A further way on from the 410 page, after the link to the listing; the label in the app's locale. */
|
|
96
|
+
export interface GonePageLink {
|
|
97
|
+
readonly href: string;
|
|
98
|
+
readonly label: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What `blog({ gonePage: { render } })` receives to write the whole 410 body. */
|
|
102
|
+
export interface GonePageRenderInput {
|
|
103
|
+
readonly copy: GonePageCopy;
|
|
104
|
+
readonly lang: string;
|
|
105
|
+
readonly indexPath: string;
|
|
106
|
+
readonly links: readonly GonePageLink[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface GonePageOptions {
|
|
110
|
+
readonly lang: string;
|
|
111
|
+
readonly indexPath: string;
|
|
112
|
+
/** Links listed under the way to the listing (a calculator, a sign-up). */
|
|
113
|
+
readonly links?: readonly GonePageLink[];
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
function escapeHtml(text: string): string {
|
|
96
117
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
/**
|
|
100
|
-
* The body of a 410: a short page with the way on
|
|
101
|
-
* script, no external resource; `noindex`. Every value is
|
|
121
|
+
* The body of a 410: a short page with the way on to the listing and the app's further `links`. No
|
|
122
|
+
* React (the proxy renders no components), no script, no external resource; `noindex`. Every value is
|
|
123
|
+
* escaped: copy and links come from the app's options.
|
|
102
124
|
*/
|
|
103
|
-
export function buildGonePage(copy: GonePageCopy, options:
|
|
125
|
+
export function buildGonePage(copy: GonePageCopy, options: GonePageOptions): string {
|
|
126
|
+
const links = options.links ?? [];
|
|
104
127
|
return [
|
|
105
128
|
"<!doctype html>",
|
|
106
129
|
`<html lang="${escapeHtml(options.lang)}">`,
|
|
@@ -113,6 +136,7 @@ export function buildGonePage(copy: GonePageCopy, options: { readonly lang: stri
|
|
|
113
136
|
'<body style="font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;line-height:1.5">',
|
|
114
137
|
`<h1>${escapeHtml(copy.heading)}</h1>`,
|
|
115
138
|
`<p>${escapeHtml(copy.body)} <a href="${escapeHtml(options.indexPath)}">${escapeHtml(copy.link)}</a></p>`,
|
|
139
|
+
...(links.length === 0 ? [] : ["<ul>", ...links.map((link) => `<li><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></li>`), "</ul>"]),
|
|
116
140
|
"</body>",
|
|
117
141
|
"</html>",
|
|
118
142
|
"",
|
package/src/proxy/index.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
|
|
|
53
53
|
findRedirect: async (oldSlug) => findSlugRedirect(await getContext(), oldSlug),
|
|
54
54
|
};
|
|
55
55
|
const decide = createCachedBlogPathDecider(routes, lookup, options);
|
|
56
|
-
const gonePage =
|
|
56
|
+
const gonePage = renderGonePage(config, routes.index);
|
|
57
57
|
|
|
58
58
|
return async (request) => {
|
|
59
59
|
if (request.method !== "GET" && request.method !== "HEAD") return null;
|
|
@@ -74,6 +74,14 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** The 410 body: the app's `gonePage.render` when given, else the module's page with the app's links. */
|
|
78
|
+
function renderGonePage(config: SoftureConfig, indexPath: string): string {
|
|
79
|
+
const { gonePage } = getBlogOptions(config);
|
|
80
|
+
const links = gonePage.links.map((link) => ({ href: link.href, label: link.label[config.locale] ?? link.label.en }));
|
|
81
|
+
const input = { copy: getBlogMessages(config).gone, lang: config.locale, indexPath, links };
|
|
82
|
+
return gonePage.render === undefined ? buildGonePage(input.copy, input) : gonePage.render(input);
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
export type BlogMarkdown = (request: Request) => Promise<Response | null>;
|
|
78
86
|
|
|
79
87
|
export interface BlogMarkdownOptions {
|
package/src/quality/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { qualityOptionsSchema, type QualityLimits, type QualityOptions, type Qua
|
|
|
11
11
|
export { isQualityPlugin, type QualityPlugin, type QualityPluginContext, type QualityRuleInfo } from "./plugin.js";
|
|
12
12
|
export { collectLinks, type InternalLinkResolver, type LinkSummary } from "./rules/links.js";
|
|
13
13
|
export { enRuleset, plRuleset, QUALITY_LANGUAGES, QUALITY_RULESETS, type LanguageRuleset, type QualityLanguage, type StylePattern } from "./rulesets/index.js";
|
|
14
|
-
export { getLocalDate, resolveQualitySettings, type QualitySettings } from "./settings.js";
|
|
14
|
+
export { getLocalDate, resolveQualitySettings, type QualityPaths, type QualitySettings } from "./settings.js";
|
|
15
15
|
export {
|
|
16
16
|
countWords,
|
|
17
17
|
findBareUrls,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Internal link targets from the file system
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `quality.paths
|
|
1
|
+
// Internal link targets from the file system, for `softure-blog check`. A route exists when the
|
|
2
|
+
// Next.js app folder has a `page.*` or `route.*` for it (route groups vanish from the path; private
|
|
3
|
+
// segments and `_folders` are skipped). An article or a glossary term exists when the content folder
|
|
4
|
+
// has its published file of that kind, under `QualitySettings.paths` (the blog's routes unless
|
|
5
|
+
// `quality.paths` overrides them); a static page under the same path wins over the dynamic article
|
|
6
|
+
// route.
|
|
6
7
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { parseArticleFile, type ParseArticleFileOptions } from "../content/article-file.js";
|
package/src/quality/options.ts
CHANGED
|
@@ -26,7 +26,7 @@ const range = (min: number, max: number) =>
|
|
|
26
26
|
const byKind = (article: number, term: number) =>
|
|
27
27
|
z.strictObject({ article: z.number().int().min(0).default(article), term: z.number().int().min(0).default(term) }).prefault({});
|
|
28
28
|
|
|
29
|
-
/** Thresholds; the defaults
|
|
29
|
+
/** Thresholds; the defaults suit answer-first texts that AI assistants quote. */
|
|
30
30
|
export const qualityLimitsSchema = z
|
|
31
31
|
.strictObject({
|
|
32
32
|
words: z.strictObject({ article: range(600, 4000), term: range(60, 700) }).prefault({}),
|
|
@@ -75,8 +75,11 @@ export const qualityOptionsSchema = z.strictObject({
|
|
|
75
75
|
limits: qualityLimitsSchema,
|
|
76
76
|
/** Per rule: another severity, or "off". */
|
|
77
77
|
severity: z.record(z.string().regex(KEBAB), z.enum(["error", "warning", "off"])).default({}),
|
|
78
|
-
/**
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Where articles and terms live, only to override the blog's `routes` (`articles` defaults to
|
|
80
|
+
* `routes.index`, `terms` to `routes.glossary`). The resolved pair is `QualitySettings.paths`.
|
|
81
|
+
*/
|
|
82
|
+
paths: z.strictObject({ articles: sitePath.optional(), terms: sitePath.optional() }).prefault({}),
|
|
80
83
|
/** Absolute origins whose links count as internal, besides the config's `appOrigin` and the canonical site origin (`getSiteUrls`). */
|
|
81
84
|
ownOrigins: z.array(z.url({ protocol: /^https?$/ })).default([]),
|
|
82
85
|
/** The Next.js app folder `softure-blog check` reads routes from. Default: `src/app`, else `app`. */
|
package/src/quality/settings.ts
CHANGED
|
@@ -18,13 +18,25 @@ export interface QualitySettings {
|
|
|
18
18
|
readonly timeZone: string;
|
|
19
19
|
/** The app's image policy (`blog({ images })`); `null` when bodies may show no image. */
|
|
20
20
|
readonly images: ArticleImagePolicy | null;
|
|
21
|
+
/** Where articles and terms live: `options.paths` where set, else the blog's routes. */
|
|
22
|
+
readonly paths: QualityPaths;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export interface QualityPaths {
|
|
26
|
+
readonly articles: string;
|
|
27
|
+
readonly terms: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The paths a caller without the blog's routes gets: the module's default routes. */
|
|
31
|
+
const DEFAULT_PATHS: QualityPaths = { articles: "/blog", terms: "/blog/glossary" };
|
|
32
|
+
|
|
23
33
|
export function resolveQualitySettings(
|
|
24
34
|
options: QualityOptions,
|
|
25
35
|
config: Pick<SoftureConfig, "appOrigin" | "timezone"> & {
|
|
26
36
|
/** The canonical site origin (core's `getSiteUrls(config).origin`) when it is not `appOrigin`. */
|
|
27
37
|
readonly siteOrigin?: string;
|
|
38
|
+
/** The blog's routes (`getBlogRoutes`), the source of `paths` unless `options.paths` overrides them. */
|
|
39
|
+
readonly routes?: { readonly index: string; readonly glossary: string };
|
|
28
40
|
},
|
|
29
41
|
images: ArticleImagePolicy | null = null,
|
|
30
42
|
): QualitySettings {
|
|
@@ -44,7 +56,11 @@ export function resolveQualitySettings(
|
|
|
44
56
|
});
|
|
45
57
|
}
|
|
46
58
|
const origins = [config.appOrigin, ...(config.siteOrigin === undefined ? [] : [config.siteOrigin]), ...options.ownOrigins].map((origin) => new URL(origin).origin);
|
|
47
|
-
|
|
59
|
+
const paths: QualityPaths = {
|
|
60
|
+
articles: options.paths.articles ?? config.routes?.index ?? DEFAULT_PATHS.articles,
|
|
61
|
+
terms: options.paths.terms ?? config.routes?.glossary ?? DEFAULT_PATHS.terms,
|
|
62
|
+
};
|
|
63
|
+
return { options, ruleset, voicePatterns, ownOrigins: [...new Set(origins)], timeZone: config.timezone, images, paths };
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
/** `YYYY-MM-DD` of a moment in a time zone. */
|
package/src/render/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
findArticleBlocks,
|
|
18
18
|
parseDirectiveAttributes,
|
|
19
19
|
parseDirectiveLine,
|
|
20
|
+
EXTERNAL_LINK_MARKERS,
|
|
20
21
|
renderArticle,
|
|
21
22
|
replaceArticleBlocks,
|
|
22
23
|
type ArticleBlock,
|
|
@@ -28,6 +29,7 @@ export {
|
|
|
28
29
|
type BlockOutput,
|
|
29
30
|
type BlockPlugin,
|
|
30
31
|
type BlogRenderMessages,
|
|
32
|
+
type ExternalLinkMarker,
|
|
31
33
|
type FoundBlock,
|
|
32
34
|
type RenderArticleOptions,
|
|
33
35
|
type RenderedArticle,
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
// ## Links
|
|
18
18
|
//
|
|
19
19
|
// A link that leaves the site (`siteHosts`, subdomains included) gets `rel="noopener noreferrer"`,
|
|
20
|
-
// opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab"
|
|
20
|
+
// opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab"
|
|
21
|
+
// (`externalMarker`: both, the hidden words only, or neither; the `blog-external` class stays either way).
|
|
21
22
|
//
|
|
22
23
|
// ## Headings and the table of contents
|
|
23
24
|
//
|
|
@@ -114,6 +115,11 @@ export type ArticleSegment<TNode = unknown> =
|
|
|
114
115
|
| { readonly kind: "html"; readonly html: string }
|
|
115
116
|
| { readonly kind: "node"; readonly type: string; readonly node: TNode };
|
|
116
117
|
|
|
118
|
+
/** What the renderer appends to an external link (`RenderArticleOptions.externalMarker`). */
|
|
119
|
+
export const EXTERNAL_LINK_MARKERS = ["icon-and-text", "text", "none"] as const;
|
|
120
|
+
|
|
121
|
+
export type ExternalLinkMarker = (typeof EXTERNAL_LINK_MARKERS)[number];
|
|
122
|
+
|
|
117
123
|
export interface RenderArticleOptions<TNode = unknown> {
|
|
118
124
|
/** Terms for automatic links; without it no text is linked. */
|
|
119
125
|
readonly glossary?: readonly GlossaryTerm[];
|
|
@@ -130,6 +136,11 @@ export interface RenderArticleOptions<TNode = unknown> {
|
|
|
130
136
|
readonly article?: BlockArticle;
|
|
131
137
|
/** Render a table of contents of `h2` down to `maxLevel` (3 by default). */
|
|
132
138
|
readonly toc?: boolean | { readonly maxLevel: number };
|
|
139
|
+
/**
|
|
140
|
+
* What follows an external link: the visible arrow and the visually hidden "opens in a new tab"
|
|
141
|
+
* (`"icon-and-text"`, the default), the hidden words only (`"text"`), or nothing (`"none"`).
|
|
142
|
+
*/
|
|
143
|
+
readonly externalMarker?: ExternalLinkMarker;
|
|
133
144
|
/** Copy for footnotes, external links and the table of contents; English by default. */
|
|
134
145
|
readonly messages?: BlogRenderMessages;
|
|
135
146
|
readonly wordsPerMinute?: number;
|
|
@@ -297,7 +308,14 @@ function addFootnoteMarkup(md: Markdown, messages: BlogRenderMessages): void {
|
|
|
297
308
|
};
|
|
298
309
|
}
|
|
299
310
|
|
|
300
|
-
function
|
|
311
|
+
function getExternalMarkerHtml(md: Markdown, marker: ExternalLinkMarker, messages: BlogRenderMessages): string {
|
|
312
|
+
if (marker === "none") return "";
|
|
313
|
+
const hidden = `<span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`;
|
|
314
|
+
return marker === "text" ? hidden : `<span class="blog-external-marker" aria-hidden="true">↗</span>${hidden}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages: BlogRenderMessages, marker: ExternalLinkMarker): void {
|
|
318
|
+
const markerHtml = getExternalMarkerHtml(md, marker, messages);
|
|
301
319
|
// Markdown links do not nest, but a stack keeps open and close paired whatever the token stream.
|
|
302
320
|
const externalStack: boolean[] = [];
|
|
303
321
|
md.renderer.rules.link_open = (tokens, index, options, _env, self) => {
|
|
@@ -312,10 +330,7 @@ function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages:
|
|
|
312
330
|
return self.renderToken(tokens, index, options);
|
|
313
331
|
};
|
|
314
332
|
md.renderer.rules.link_close = (tokens, index, options, _env, self) => {
|
|
315
|
-
|
|
316
|
-
? `<span class="blog-external-marker" aria-hidden="true">↗</span><span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`
|
|
317
|
-
: "";
|
|
318
|
-
return marker + self.renderToken(tokens, index, options);
|
|
333
|
+
return (externalStack.pop() === true ? markerHtml : "") + self.renderToken(tokens, index, options);
|
|
319
334
|
};
|
|
320
335
|
}
|
|
321
336
|
|
|
@@ -513,7 +528,7 @@ function createMarkdown<TNode>(
|
|
|
513
528
|
md.validateLink = isSafeLink;
|
|
514
529
|
addImages(md, options.images);
|
|
515
530
|
addFootnoteMarkup(md, messages);
|
|
516
|
-
addExternalLinks(md, options.siteHosts ?? [], messages);
|
|
531
|
+
addExternalLinks(md, options.siteHosts ?? [], messages, options.externalMarker ?? "icon-and-text");
|
|
517
532
|
addBlockTokens(md, getTypes(plugins, "fence"));
|
|
518
533
|
addDirectiveRule(md, getTypes(plugins, "directive"));
|
|
519
534
|
addHeadingIds(md, state);
|
package/src/server/options.ts
CHANGED
|
@@ -57,6 +57,6 @@ export function getBlogReservedSlugs(config: SoftureConfig): string[] {
|
|
|
57
57
|
export function getQualitySettings(config: SoftureConfig): QualitySettings | null {
|
|
58
58
|
const { quality, images } = getBlogOptions(config);
|
|
59
59
|
if (quality === false) return null;
|
|
60
|
-
const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone };
|
|
60
|
+
const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone, routes: getBlogRoutes(config) };
|
|
61
61
|
return resolveQualitySettings(quality, site, images ?? null);
|
|
62
62
|
}
|