@softure-ai/blog 0.1.6 → 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 +30 -0
- package/README.md +211 -26
- package/dist/cli/report.d.ts +17 -0
- package/dist/cli/report.d.ts.map +1 -0
- package/dist/cli/report.js +149 -0
- package/dist/cli/report.js.map +1 -0
- package/dist/cli/run.d.ts +12 -5
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +122 -88
- 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/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/db/articles.d.ts +10 -1
- package/dist/db/articles.d.ts.map +1 -1
- package/dist/db/articles.js +41 -5
- package/dist/db/articles.js.map +1 -1
- package/dist/db/history.d.ts +36 -0
- package/dist/db/history.d.ts.map +1 -0
- package/dist/db/history.js +70 -0
- package/dist/db/history.js.map +1 -0
- package/dist/db/publish-run.d.ts +11 -0
- package/dist/db/publish-run.d.ts.map +1 -1
- package/dist/db/publish-run.js +14 -4
- package/dist/db/publish-run.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 +26 -1
- package/dist/options.js.map +1 -1
- package/dist/pages/accept.d.ts +7 -0
- package/dist/pages/accept.d.ts.map +1 -0
- package/dist/pages/accept.js +34 -0
- package/dist/pages/accept.js.map +1 -0
- 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 +15 -0
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +60 -3
- package/dist/proxy/index.js.map +1 -1
- package/dist/quality/catalog.d.ts.map +1 -1
- package/dist/quality/catalog.js +4 -1
- package/dist/quality/catalog.js.map +1 -1
- package/dist/quality/check-article.d.ts.map +1 -1
- package/dist/quality/check-article.js +2 -1
- package/dist/quality/check-article.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 +9 -4
- package/dist/quality/options.js.map +1 -1
- package/dist/quality/rules/blocks.d.ts +8 -1
- package/dist/quality/rules/blocks.d.ts.map +1 -1
- package/dist/quality/rules/blocks.js +26 -0
- package/dist/quality/rules/blocks.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/article-markdown.d.ts +12 -0
- package/dist/render/article-markdown.d.ts.map +1 -0
- package/dist/render/article-markdown.js +21 -0
- package/dist/render/article-markdown.js.map +1 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +2 -1
- package/dist/render/index.js.map +1 -1
- package/dist/render/render-article.d.ts +48 -4
- package/dist/render/render-article.d.ts.map +1 -1
- package/dist/render/render-article.js +146 -17
- package/dist/render/render-article.js.map +1 -1
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.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/skill/references/rules.md +2 -1
- package/src/cli/report.ts +182 -0
- package/src/cli/run.ts +122 -92
- package/src/cli/skill.ts +2 -1
- package/src/contract.ts +9 -1
- package/src/db/articles.ts +48 -8
- package/src/db/history.ts +90 -0
- package/src/db/publish-run.ts +24 -4
- 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 +32 -3
- package/src/pages/accept.ts +39 -0
- 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 +69 -3
- package/src/quality/catalog.ts +4 -1
- package/src/quality/check-article.ts +2 -1
- package/src/quality/index.ts +1 -1
- package/src/quality/link-targets.ts +6 -5
- package/src/quality/options.ts +12 -5
- package/src/quality/rules/blocks.ts +26 -1
- package/src/quality/settings.ts +17 -1
- package/src/render/article-markdown.ts +34 -0
- package/src/render/index.ts +8 -0
- package/src/render/render-article.ts +192 -23
- package/src/server/index.ts +2 -0
- package/src/server/options.ts +1 -1
package/src/db/articles.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// The article store
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The article store: the one write, `publishArticle`, called by the publish run, and the reads the
|
|
2
|
+
// blog pages use. Withdrawing is the same write with the status `withdrawn`; rows are never deleted,
|
|
3
|
+
// because a withdrawn address answers 410, not 404.
|
|
4
4
|
import type { ModuleContext } from "@softure-ai/core";
|
|
5
5
|
import type { Queryable } from "@softure-ai/db";
|
|
6
|
-
import { and, asc, desc, eq, ne, type SQL } from "drizzle-orm";
|
|
6
|
+
import { and, asc, desc, eq, ne, sql, type SQL } from "drizzle-orm";
|
|
7
7
|
import type { BlogArticle, BlogArticleInput, BlogArticleKind, BlogArticleState, BlogPublishResult } from "../contract.js";
|
|
8
|
+
import type { ArticleHistory } from "./history.js";
|
|
8
9
|
import { articles, slugHistory } from "./schema.js";
|
|
9
10
|
|
|
10
11
|
export type BlogContext = ModuleContext<Queryable>;
|
|
@@ -18,10 +19,14 @@ export type BlogContext = ModuleContext<Queryable>;
|
|
|
18
19
|
* text first becomes `published`. A draft gets none.
|
|
19
20
|
* - `updated_at` moves only when the content hash of a text that already has `published_at` changes.
|
|
20
21
|
*
|
|
22
|
+
* With `history` (an app moving its blog in, see history.ts), an article that has no row yet takes its
|
|
23
|
+
* `published_at` (unless the file sets one) and `updated_at` from it, and its old slugs enter the slug
|
|
24
|
+
* history; the result says `imported: true`. For an existing row the history is ignored.
|
|
25
|
+
*
|
|
21
26
|
* Runs in a transaction that locks the row (`FOR UPDATE`), so two publishes of one article never
|
|
22
27
|
* overwrite each other silently; inside an open transaction it becomes a savepoint.
|
|
23
28
|
*/
|
|
24
|
-
export async function publishArticle(ctx: BlogContext, input: BlogArticleInput): Promise<BlogPublishResult> {
|
|
29
|
+
export async function publishArticle(ctx: BlogContext, input: BlogArticleInput, options: PublishArticleOptions = {}): Promise<BlogPublishResult> {
|
|
25
30
|
return ctx.db.transaction(async (tx) => {
|
|
26
31
|
const [existing] = await tx.select().from(articles).where(eq(articles.id, input.id)).for("update");
|
|
27
32
|
|
|
@@ -61,12 +66,25 @@ export async function publishArticle(ctx: BlogContext, input: BlogArticleInput):
|
|
|
61
66
|
};
|
|
62
67
|
|
|
63
68
|
if (existing === undefined) {
|
|
64
|
-
const
|
|
69
|
+
const history = options.history;
|
|
70
|
+
const publishedAt = input.publishedAt ?? history?.publishedAt ?? (input.status === "published" ? now : null);
|
|
71
|
+
const updatedAt = publishedAt === null ? null : (history?.updatedAt ?? null);
|
|
72
|
+
if (history !== undefined) {
|
|
73
|
+
const taken = await findTakenOldSlug(tx, input, history);
|
|
74
|
+
if (taken !== null) return taken;
|
|
75
|
+
}
|
|
65
76
|
const [inserted] = await tx
|
|
66
77
|
.insert(articles)
|
|
67
|
-
.values({ id: input.id, ...content, publishedAt, updatedAt:
|
|
78
|
+
.values({ id: input.id, ...content, publishedAt: toTimestamp(publishedAt), updatedAt: toTimestamp(updatedAt), createdAt: now })
|
|
68
79
|
.returning();
|
|
69
|
-
|
|
80
|
+
const oldSlugs = history?.oldSlugs.filter((old) => old.slug !== input.slug) ?? [];
|
|
81
|
+
if (oldSlugs.length > 0) {
|
|
82
|
+
await tx.insert(slugHistory).values(oldSlugs.map((old) => ({ oldSlug: old.slug, articleId: input.id, changedAt: toTimestamp(old.changedAt) ?? now })));
|
|
83
|
+
}
|
|
84
|
+
const after = readState(requireRow(inserted, input.id));
|
|
85
|
+
return history === undefined
|
|
86
|
+
? { ok: true, action: "added", before: null, after, previousSlug: null }
|
|
87
|
+
: { ok: true, action: "added", before: null, after, previousSlug: null, imported: true };
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
const before = readState(existing);
|
|
@@ -103,6 +121,28 @@ export async function publishArticle(ctx: BlogContext, input: BlogArticleInput):
|
|
|
103
121
|
});
|
|
104
122
|
}
|
|
105
123
|
|
|
124
|
+
/** A history timestamp goes in as text, so Postgres keeps its microseconds; a `Date` goes in as is. */
|
|
125
|
+
function toTimestamp(value: Date | string | null): Date | SQL | null {
|
|
126
|
+
return typeof value === "string" ? sql`${value}::timestamptz` : value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface PublishArticleOptions {
|
|
130
|
+
/** The article's earlier life, applied only when it has no row yet. */
|
|
131
|
+
readonly history?: ArticleHistory;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The refusal for the first old slug of `history` another article holds, current or old; `null` when all are free. */
|
|
135
|
+
async function findTakenOldSlug(db: Queryable, input: BlogArticleInput, history: ArticleHistory): Promise<Extract<BlogPublishResult, { ok: false }> | null> {
|
|
136
|
+
for (const { slug } of history.oldSlugs) {
|
|
137
|
+
if (slug === input.slug) continue;
|
|
138
|
+
const [owner] = await db.select({ id: articles.id }).from(articles).where(and(eq(articles.slug, slug), ne(articles.id, input.id)));
|
|
139
|
+
if (owner !== undefined) return { ok: false, error: "blog.slug_taken", otherArticleId: owner.id, slug };
|
|
140
|
+
const [historyOwner] = await db.select({ articleId: slugHistory.articleId }).from(slugHistory).where(eq(slugHistory.oldSlug, slug));
|
|
141
|
+
if (historyOwner !== undefined) return { ok: false, error: "blog.slug_in_history", otherArticleId: historyOwner.articleId, slug };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
106
146
|
/** The article under its current slug, in any status: a page tells 200 from 410 by it. */
|
|
107
147
|
export async function findArticleBySlug(ctx: BlogContext, slug: string): Promise<BlogArticle | null> {
|
|
108
148
|
const [row] = await ctx.db.select().from(articles).where(eq(articles.slug, slug));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The history of articles an app published before it moved to this module: when each text first went
|
|
2
|
+
// public, when it last changed, and the slugs it had before. A first publish into `blog.*` would
|
|
3
|
+
// otherwise date every text today and lose the old addresses' 301s.
|
|
4
|
+
//
|
|
5
|
+
// The app exports it once from its own tables as JSON (README, "Moving an existing blog") and passes it
|
|
6
|
+
// to `softure-blog publish --history <file>`. It applies only to an article that has no row yet, so a
|
|
7
|
+
// re-run with the same file changes nothing, and the article file's own `published_at` still wins.
|
|
8
|
+
//
|
|
9
|
+
// Timestamps stay the text they were given and go into Postgres as text, so `…:12.421579Z` (what a
|
|
10
|
+
// `timestamptz` holds) is stored to the microsecond; a `Date` would keep only milliseconds. Digits past
|
|
11
|
+
// the sixth are rounded by Postgres.
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
15
|
+
|
|
16
|
+
const timestamp = z.iso.datetime({
|
|
17
|
+
offset: true,
|
|
18
|
+
error: (issue) =>
|
|
19
|
+
issue.input === undefined
|
|
20
|
+
? "is required: an ISO 8601 timestamp, or null for a text that was never published"
|
|
21
|
+
: "must be an ISO 8601 timestamp with a time zone, e.g. 2026-09-01T08:00:00Z",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const oldSlugSchema = z.strictObject({
|
|
25
|
+
slug: z.string().max(100).regex(SLUG, "must be a kebab-case slug"),
|
|
26
|
+
changed_at: timestamp.optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const entrySchema = z
|
|
30
|
+
.strictObject({
|
|
31
|
+
id: z.string().max(100).regex(SLUG, "must be a kebab-case article id"),
|
|
32
|
+
published_at: timestamp.nullable(),
|
|
33
|
+
updated_at: timestamp.nullable().optional(),
|
|
34
|
+
old_slugs: z.array(oldSlugSchema).default([]),
|
|
35
|
+
})
|
|
36
|
+
.refine((entry) => entry.updated_at === undefined || entry.updated_at === null || entry.published_at !== null, {
|
|
37
|
+
message: "updated_at needs published_at: a text is updated only after it was published",
|
|
38
|
+
path: ["updated_at"],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const articleHistorySchema = z
|
|
42
|
+
.strictObject({ articles: z.array(entrySchema) })
|
|
43
|
+
.superRefine((history, context) => {
|
|
44
|
+
const ids = new Set<string>();
|
|
45
|
+
const slugs = new Set<string>();
|
|
46
|
+
history.articles.forEach((entry, index) => {
|
|
47
|
+
if (ids.has(entry.id)) context.addIssue({ code: "custom", path: ["articles", index, "id"], message: `"${entry.id}" is listed twice` });
|
|
48
|
+
ids.add(entry.id);
|
|
49
|
+
entry.old_slugs.forEach((old, slugIndex) => {
|
|
50
|
+
if (slugs.has(old.slug)) context.addIssue({ code: "custom", path: ["articles", index, "old_slugs", slugIndex, "slug"], message: `"${old.slug}" is an old slug twice` });
|
|
51
|
+
slugs.add(old.slug);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the module keeps from an article's earlier life. Timestamps are ISO 8601 text with a time zone,
|
|
58
|
+
* as the history gives them, so their full precision reaches the database.
|
|
59
|
+
*/
|
|
60
|
+
export interface ArticleHistory {
|
|
61
|
+
readonly publishedAt: string | null;
|
|
62
|
+
/** Kept only with `publishedAt`. */
|
|
63
|
+
readonly updatedAt: string | null;
|
|
64
|
+
readonly oldSlugs: readonly { readonly slug: string; readonly changedAt: string | null }[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** History by article id. */
|
|
68
|
+
export type ArticleHistoryMap = ReadonlyMap<string, ArticleHistory>;
|
|
69
|
+
|
|
70
|
+
/** The parsed history, or the problems that name the field (`articles.3.published_at: …`). */
|
|
71
|
+
export function parseArticleHistory(input: unknown): { readonly ok: true; readonly history: ArticleHistoryMap } | { readonly ok: false; readonly errors: readonly string[] } {
|
|
72
|
+
const parsed = articleHistorySchema.safeParse(input);
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
return { ok: false, errors: parsed.error.issues.map((issue) => `${issue.path.join(".") || "history"}: ${issue.message}`) };
|
|
75
|
+
}
|
|
76
|
+
const orNull = (value: string | null | undefined) => value ?? null;
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
history: new Map(
|
|
80
|
+
parsed.data.articles.map((entry) => [
|
|
81
|
+
entry.id,
|
|
82
|
+
{
|
|
83
|
+
publishedAt: orNull(entry.published_at),
|
|
84
|
+
updatedAt: orNull(entry.updated_at),
|
|
85
|
+
oldSlugs: entry.old_slugs.map((old) => ({ slug: old.slug, changedAt: orNull(old.changed_at) })),
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/db/publish-run.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Queryable } from "@softure-ai/db";
|
|
|
12
12
|
import { eq } from "drizzle-orm";
|
|
13
13
|
import { findTermFormConflicts, toGlossary } from "../render/glossary.js";
|
|
14
14
|
import { listArticles, publishArticle, type BlogContext } from "./articles.js";
|
|
15
|
+
import type { ArticleHistory, ArticleHistoryMap } from "./history.js";
|
|
15
16
|
import { articles } from "./schema.js";
|
|
16
17
|
|
|
17
18
|
export interface ArticleFile {
|
|
@@ -33,6 +34,11 @@ export interface RunBlogPublishOptions extends ParseArticleFileOptions {
|
|
|
33
34
|
/** Publish the files as `withdrawn`, whatever their status (taking a text down at once). */
|
|
34
35
|
readonly withdraw?: boolean;
|
|
35
36
|
readonly gate?: PublishGate;
|
|
37
|
+
/**
|
|
38
|
+
* The earlier life of articles an app moves in (history.ts), by id: applied to an article of the run
|
|
39
|
+
* that has no row yet. An entry for an id outside the run is a warning.
|
|
40
|
+
*/
|
|
41
|
+
readonly history?: ArticleHistoryMap;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** A problem that stops the run; `subject` is a file name, an article id or a cluster. */
|
|
@@ -52,6 +58,11 @@ export interface PublishedChange {
|
|
|
52
58
|
readonly slug: string;
|
|
53
59
|
/** The slug that entered the slug history in this run. */
|
|
54
60
|
readonly previousSlug: string | null;
|
|
61
|
+
/** Set when the run applied the article's history (`history`): its dates and old slugs. */
|
|
62
|
+
readonly imported?: {
|
|
63
|
+
readonly publishedAt: Date | null;
|
|
64
|
+
readonly oldSlugs: number;
|
|
65
|
+
};
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export type BlogPublishRun =
|
|
@@ -100,15 +111,21 @@ export async function runBlogPublish(ctx: BlogContext, files: readonly ArticleFi
|
|
|
100
111
|
|
|
101
112
|
problems.push(...findDuplicateIds(inputs), ...findPillarProblems(inputs));
|
|
102
113
|
if (problems.length > 0) return { status: "refused", problems, warnings };
|
|
114
|
+
const runIds = new Set(inputs.map((input) => input.id));
|
|
115
|
+
for (const id of options.history?.keys() ?? []) {
|
|
116
|
+
if (!runIds.has(id)) warnings.push({ subject: id, message: "the history names an article that is not in this run; it is applied when its file is published" });
|
|
117
|
+
}
|
|
103
118
|
|
|
104
119
|
const changes: PublishedChange[] = [];
|
|
105
120
|
try {
|
|
106
121
|
await ctx.db.transaction(async (tx) => {
|
|
107
122
|
for (const input of inputs) {
|
|
108
|
-
const
|
|
123
|
+
const history = options.history?.get(input.id);
|
|
124
|
+
const result = await publishArticleOrRefuse({ ...ctx, db: tx }, input, history);
|
|
109
125
|
if (!result.ok) {
|
|
110
126
|
const reason = result.error === "blog.slug_taken" ? "is the slug of" : "redirects to";
|
|
111
|
-
|
|
127
|
+
const slug = result.slug === undefined ? `slug ${input.slug}` : `old slug ${result.slug} (history)`;
|
|
128
|
+
throw new PublishRefused([{ subject: input.id, message: `${slug} ${reason} article ${result.otherArticleId} (${result.error})` }]);
|
|
112
129
|
}
|
|
113
130
|
changes.push({
|
|
114
131
|
id: input.id,
|
|
@@ -119,6 +136,9 @@ export async function runBlogPublish(ctx: BlogContext, files: readonly ArticleFi
|
|
|
119
136
|
slugBefore: result.before?.slug ?? null,
|
|
120
137
|
slug: result.after.slug,
|
|
121
138
|
previousSlug: result.previousSlug,
|
|
139
|
+
...(result.imported === true && history !== undefined
|
|
140
|
+
? { imported: { publishedAt: result.after.publishedAt, oldSlugs: history.oldSlugs.filter((old) => old.slug !== input.slug).length } }
|
|
141
|
+
: {}),
|
|
122
142
|
});
|
|
123
143
|
}
|
|
124
144
|
const glossary = await checkGlossaryForms({ ...ctx, db: tx }, inputs);
|
|
@@ -192,9 +212,9 @@ async function checkGlossaryForms(ctx: BlogContext, inputs: readonly BlogArticle
|
|
|
192
212
|
* for that run and fail with 23505. The savepoint is rolled back, so the transaction still reads
|
|
193
213
|
* (at read committed, the winner's row is visible now) and names the article that took the slug.
|
|
194
214
|
*/
|
|
195
|
-
async function publishArticleOrRefuse(ctx: BlogContext, input: BlogArticleInput): ReturnType<typeof publishArticle> {
|
|
215
|
+
async function publishArticleOrRefuse(ctx: BlogContext, input: BlogArticleInput, history: ArticleHistory | undefined): ReturnType<typeof publishArticle> {
|
|
196
216
|
try {
|
|
197
|
-
return await publishArticle(ctx, input);
|
|
217
|
+
return await publishArticle(ctx, input, history === undefined ? {} : { history });
|
|
198
218
|
} catch (error) {
|
|
199
219
|
const driverError = findDriverError(error);
|
|
200
220
|
if (driverError?.code !== UNIQUE_VIOLATION || driverError.constraint !== SLUG_CONSTRAINT) throw error;
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const BLOG_RATE_LIMIT_BUCKETS = {
|
|
|
29
29
|
export const blog = defineModule({
|
|
30
30
|
manifest: {
|
|
31
31
|
id: MODULE_ID,
|
|
32
|
-
version: "0.1.
|
|
32
|
+
version: "0.1.8",
|
|
33
33
|
// seo is optional: with it the sitemap lists the texts and a publish pings IndexNow. security is
|
|
34
34
|
// optional too: only the cache refresh route needs it, for its rate limit.
|
|
35
35
|
dependsOn: { seo: "^0.1.0?", security: "^0.1.0?" },
|
package/src/next/index.ts
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
// feed, the cache refresh route and the cached reads (docs/02-module-standard.md §8).
|
|
3
3
|
export { getBlogContext, getPageContext } from "./context.js";
|
|
4
4
|
export { serveBlogRss } from "./discovery.js";
|
|
5
|
+
export { buildArticleJsonLd, buildGlossaryJsonLd, buildTermJsonLd, getCrumbLabels } from "./json-ld.js";
|
|
6
|
+
export {
|
|
7
|
+
buildArticleMetadata,
|
|
8
|
+
buildBlogIndexMetadata,
|
|
9
|
+
buildGlossaryIndexMetadata,
|
|
10
|
+
buildMethodMetadata,
|
|
11
|
+
buildTermMetadata,
|
|
12
|
+
type ListingMetadataInput,
|
|
13
|
+
} from "./metadata.js";
|
|
5
14
|
export { refreshBlogCache } from "./refresh.js";
|
|
6
15
|
export { BLOG_CACHE_TAG, getPublishedArticles, getPublishedTerms, getTextBySlug } from "./data.js";
|
|
7
16
|
export { createOgFontLoader, loadBrandOgFonts, type OgFontLoaderOptions, type OgFontsResult } from "./og-fonts.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The structured data of the blog's pages, ready for `<script type="application/ld+json">`: built from
|
|
2
|
+
// what the caller already holds (no database read, no request scope) and serialized so no text can
|
|
3
|
+
// close the tag. The ready-made pages use these; an app with its own page components mounts them as
|
|
4
|
+
//
|
|
5
|
+
// <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: buildArticleJsonLd(config, article) }} />
|
|
6
|
+
import { getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
7
|
+
import type { BlogArticle } from "../contract.js";
|
|
8
|
+
import { getArticleJsonLd, getGlossaryJsonLd, getTermJsonLd, serializeJsonLd, type JsonLdContext } from "../pages/json-ld.js";
|
|
9
|
+
import { getArticleCrumbs, getClusterLabel, getTermCrumbs, sortTerms, type CrumbLabels } from "../pages/listing.js";
|
|
10
|
+
import { getBlogOptions } from "../server/options.js";
|
|
11
|
+
import type { BlogPageContext } from "../ui/page-context.js";
|
|
12
|
+
import { getPageContext } from "./context.js";
|
|
13
|
+
|
|
14
|
+
/** The breadcrumb names the pages show: the blog, the glossary and the clusters' display names. */
|
|
15
|
+
export function getCrumbLabels(config: SoftureConfig, context: BlogPageContext = getPageContext(config)): CrumbLabels {
|
|
16
|
+
const clusters = getBlogOptions(config).clusters;
|
|
17
|
+
return {
|
|
18
|
+
blog: context.messages.pages.blogTitle,
|
|
19
|
+
glossary: context.messages.glossary.title,
|
|
20
|
+
cluster: (cluster) => getClusterLabel(cluster, clusters, config.locale),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getJsonLdContext(config: SoftureConfig, context: BlogPageContext): JsonLdContext {
|
|
25
|
+
return { urls: getSiteUrls(config), routes: context.routes, locale: config.locale, timezone: config.timezone, brand: context.brand };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An article's `BlogPosting`, breadcrumbs and FAQ, serialized. */
|
|
29
|
+
export function buildArticleJsonLd(config: SoftureConfig, article: BlogArticle): string {
|
|
30
|
+
const context = getPageContext(config);
|
|
31
|
+
const crumbs = getArticleCrumbs(article, context.routes, getCrumbLabels(config, context));
|
|
32
|
+
return serializeJsonLd(getArticleJsonLd(article, crumbs, getJsonLdContext(config, context)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A glossary term's `DefinedTerm` and breadcrumbs, serialized. */
|
|
36
|
+
export function buildTermJsonLd(config: SoftureConfig, term: BlogArticle): string {
|
|
37
|
+
const context = getPageContext(config);
|
|
38
|
+
const crumbs = getTermCrumbs(term, context.routes, getCrumbLabels(config, context));
|
|
39
|
+
return serializeJsonLd(getTermJsonLd(term, crumbs, getJsonLdContext(config, context), context.messages.glossary.title));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The glossary's `DefinedTermSet` over the published terms, in the index's order, serialized; `null` without terms. */
|
|
43
|
+
export function buildGlossaryJsonLd(config: SoftureConfig, terms: readonly BlogArticle[]): string | null {
|
|
44
|
+
if (terms.length === 0) return null;
|
|
45
|
+
const context = getPageContext(config);
|
|
46
|
+
return serializeJsonLd(getGlossaryJsonLd(sortTerms(terms, config.locale), getJsonLdContext(config, context), context.messages.glossary.title));
|
|
47
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The metadata of the blog's pages (`<title>`, description, robots, canonical, feed link, Open Graph),
|
|
2
|
+
// built from what the caller already holds: no database read, no request scope. The ready-made pages'
|
|
3
|
+
// `generate*Metadata` read the text and call these; an app with its own page components calls them
|
|
4
|
+
// with the text it read, and can extend the result:
|
|
5
|
+
//
|
|
6
|
+
// export async function generateMetadata({ params }) {
|
|
7
|
+
// const config = getSoftureConfig();
|
|
8
|
+
// const article = await getTextBySlug(config, (await params).slug);
|
|
9
|
+
// if (article?.status !== "published" || article.kind !== "article") return {};
|
|
10
|
+
// return { ...buildArticleMetadata(config, article), keywords: [...] };
|
|
11
|
+
// }
|
|
12
|
+
import { formatMessage, getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
13
|
+
// `next/types.js`, not `next`: the root entry adds Next's globals (a read-only NODE_ENV) to every
|
|
14
|
+
// program that includes this file.
|
|
15
|
+
import type { Metadata } from "next/types.js";
|
|
16
|
+
import type { BlogArticle } from "../contract.js";
|
|
17
|
+
import { getArticleDates } from "../pages/dates.js";
|
|
18
|
+
import { getArticlePath, getTermPath } from "../pages/paths.js";
|
|
19
|
+
import type { BlogPageContext } from "../ui/page-context.js";
|
|
20
|
+
import { getPageContext } from "./context.js";
|
|
21
|
+
|
|
22
|
+
/** Whether a listing has no entry: an empty listing stays out of the index (thin content). */
|
|
23
|
+
export interface ListingMetadataInput {
|
|
24
|
+
readonly isEmpty: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withBrand(title: string, context: BlogPageContext): string {
|
|
28
|
+
return context.brand === null ? title : formatMessage(context.messages.pages.titleWithBrand, { title, brand: context.brand });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A page's canonical URL: seo's host and trailing-slash rule when the app lists seo, else on `appOrigin`. */
|
|
32
|
+
function getCanonicalUrl(config: SoftureConfig, path: string): string {
|
|
33
|
+
return getSiteUrls(config).getCanonicalUrl(path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The feed link a reader finds in `<head>` (`<link rel="alternate" type="application/rss+xml">`); a file, so no trailing-slash rule. */
|
|
37
|
+
function getFeedAlternates(config: SoftureConfig, context: BlogPageContext): NonNullable<Metadata["alternates"]>["types"] {
|
|
38
|
+
return { "application/rss+xml": [{ url: `${getSiteUrls(config).origin}${context.routes.rss}`, title: withBrand(context.messages.pages.blogTitle, context) }] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getStaticMetadata(config: SoftureConfig, context: BlogPageContext, page: { title: string; description: string; path: string; isEmpty?: boolean; hasFeed?: boolean }): Metadata {
|
|
42
|
+
return {
|
|
43
|
+
title: withBrand(page.title, context),
|
|
44
|
+
description: page.description,
|
|
45
|
+
robots: { index: page.isEmpty !== true, follow: true },
|
|
46
|
+
alternates: { canonical: getCanonicalUrl(config, page.path), ...(page.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
47
|
+
openGraph: { type: "website", title: page.title, description: page.description, url: getCanonicalUrl(config, page.path), ...(context.brand === null ? {} : { siteName: context.brand }) },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getTextMetadata(config: SoftureConfig, context: BlogPageContext, text: BlogArticle, path: string, options: { hasFeed?: boolean } = {}): Metadata {
|
|
52
|
+
const dates = getArticleDates(text, config.timezone);
|
|
53
|
+
const url = getCanonicalUrl(config, path);
|
|
54
|
+
return {
|
|
55
|
+
title: withBrand(text.title, context),
|
|
56
|
+
description: text.description,
|
|
57
|
+
robots: { index: true, follow: true },
|
|
58
|
+
alternates: { canonical: url, ...(options.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
59
|
+
openGraph: {
|
|
60
|
+
type: "article",
|
|
61
|
+
title: text.title,
|
|
62
|
+
description: text.description,
|
|
63
|
+
url,
|
|
64
|
+
locale: config.locale,
|
|
65
|
+
publishedTime: dates.published,
|
|
66
|
+
modifiedTime: dates.updated ?? dates.published,
|
|
67
|
+
...(context.brand === null ? {} : { siteName: context.brand }),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The listing's metadata, with the feed link. */
|
|
73
|
+
export function buildBlogIndexMetadata(config: SoftureConfig, { isEmpty }: ListingMetadataInput): Metadata {
|
|
74
|
+
const context = getPageContext(config);
|
|
75
|
+
const copy = context.messages.pages;
|
|
76
|
+
return getStaticMetadata(config, context, { title: copy.blogTitle, description: copy.blogDescription, path: context.routes.index, isEmpty, hasFeed: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An article's metadata, with the feed link. The caller checks the text is a published article. */
|
|
80
|
+
export function buildArticleMetadata(config: SoftureConfig, article: BlogArticle): Metadata {
|
|
81
|
+
const context = getPageContext(config);
|
|
82
|
+
return getTextMetadata(config, context, article, getArticlePath(context.routes, article.slug), { hasFeed: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The glossary index's metadata. */
|
|
86
|
+
export function buildGlossaryIndexMetadata(config: SoftureConfig, { isEmpty }: ListingMetadataInput): Metadata {
|
|
87
|
+
const context = getPageContext(config);
|
|
88
|
+
const copy = context.messages.glossary;
|
|
89
|
+
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.glossary, isEmpty });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A glossary term's metadata. The caller checks the text is a published term. */
|
|
93
|
+
export function buildTermMetadata(config: SoftureConfig, term: BlogArticle): Metadata {
|
|
94
|
+
const context = getPageContext(config);
|
|
95
|
+
return getTextMetadata(config, context, term, getTermPath(context.routes, term.slug));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The method page's metadata. */
|
|
99
|
+
export function buildMethodMetadata(config: SoftureConfig): Metadata {
|
|
100
|
+
const context = getPageContext(config);
|
|
101
|
+
const copy = context.messages.method;
|
|
102
|
+
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.method });
|
|
103
|
+
}
|
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. */
|