@softure-ai/blog 0.1.6 → 0.1.7
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 +12 -0
- package/README.md +135 -10
- 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 +121 -87
- package/dist/cli/run.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 +36 -4
- package/dist/db/articles.js.map +1 -1
- package/dist/db/history.d.ts +33 -0
- package/dist/db/history.d.ts.map +1 -0
- package/dist/db/history.js +66 -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.js +1 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +3 -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/proxy/index.d.ts +15 -0
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +52 -2
- 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/options.d.ts.map +1 -1
- package/dist/quality/options.js +3 -1
- 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/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 +40 -4
- package/dist/render/render-article.d.ts.map +1 -1
- package/dist/render/render-article.js +132 -10
- 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/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 +121 -91
- package/src/contract.ts +9 -1
- package/src/db/articles.ts +39 -4
- package/src/db/history.ts +83 -0
- package/src/db/publish-run.ts +24 -4
- package/src/index.ts +1 -1
- package/src/options.ts +6 -2
- package/src/pages/accept.ts +39 -0
- package/src/proxy/index.ts +60 -2
- package/src/quality/catalog.ts +4 -1
- package/src/quality/check-article.ts +2 -1
- package/src/quality/options.ts +6 -2
- package/src/quality/rules/blocks.ts +26 -1
- package/src/render/article-markdown.ts +34 -0
- package/src/render/index.ts +6 -0
- package/src/render/render-article.ts +170 -16
- package/src/server/index.ts +2 -0
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
11
|
+
|
|
12
|
+
const timestamp = z.iso.datetime({
|
|
13
|
+
offset: true,
|
|
14
|
+
error: (issue) =>
|
|
15
|
+
issue.input === undefined
|
|
16
|
+
? "is required: an ISO 8601 timestamp, or null for a text that was never published"
|
|
17
|
+
: "must be an ISO 8601 timestamp with a time zone, e.g. 2026-09-01T08:00:00Z",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const oldSlugSchema = z.strictObject({
|
|
21
|
+
slug: z.string().max(100).regex(SLUG, "must be a kebab-case slug"),
|
|
22
|
+
changed_at: timestamp.optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const entrySchema = z
|
|
26
|
+
.strictObject({
|
|
27
|
+
id: z.string().max(100).regex(SLUG, "must be a kebab-case article id"),
|
|
28
|
+
published_at: timestamp.nullable(),
|
|
29
|
+
updated_at: timestamp.nullable().optional(),
|
|
30
|
+
old_slugs: z.array(oldSlugSchema).default([]),
|
|
31
|
+
})
|
|
32
|
+
.refine((entry) => entry.updated_at === undefined || entry.updated_at === null || entry.published_at !== null, {
|
|
33
|
+
message: "updated_at needs published_at: a text is updated only after it was published",
|
|
34
|
+
path: ["updated_at"],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const articleHistorySchema = z
|
|
38
|
+
.strictObject({ articles: z.array(entrySchema) })
|
|
39
|
+
.superRefine((history, context) => {
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
const slugs = new Set<string>();
|
|
42
|
+
history.articles.forEach((entry, index) => {
|
|
43
|
+
if (ids.has(entry.id)) context.addIssue({ code: "custom", path: ["articles", index, "id"], message: `"${entry.id}" is listed twice` });
|
|
44
|
+
ids.add(entry.id);
|
|
45
|
+
entry.old_slugs.forEach((old, slugIndex) => {
|
|
46
|
+
if (slugs.has(old.slug)) context.addIssue({ code: "custom", path: ["articles", index, "old_slugs", slugIndex, "slug"], message: `"${old.slug}" is an old slug twice` });
|
|
47
|
+
slugs.add(old.slug);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** What the module keeps from an article's earlier life. */
|
|
53
|
+
export interface ArticleHistory {
|
|
54
|
+
readonly publishedAt: Date | null;
|
|
55
|
+
/** Kept only with `publishedAt`. */
|
|
56
|
+
readonly updatedAt: Date | null;
|
|
57
|
+
readonly oldSlugs: readonly { readonly slug: string; readonly changedAt: Date | null }[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** History by article id. */
|
|
61
|
+
export type ArticleHistoryMap = ReadonlyMap<string, ArticleHistory>;
|
|
62
|
+
|
|
63
|
+
/** The parsed history, or the problems that name the field (`articles.3.published_at: …`). */
|
|
64
|
+
export function parseArticleHistory(input: unknown): { readonly ok: true; readonly history: ArticleHistoryMap } | { readonly ok: false; readonly errors: readonly string[] } {
|
|
65
|
+
const parsed = articleHistorySchema.safeParse(input);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
return { ok: false, errors: parsed.error.issues.map((issue) => `${issue.path.join(".") || "history"}: ${issue.message}`) };
|
|
68
|
+
}
|
|
69
|
+
const toDate = (value: string | null | undefined) => (value === null || value === undefined ? null : new Date(value));
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
history: new Map(
|
|
73
|
+
parsed.data.articles.map((entry) => [
|
|
74
|
+
entry.id,
|
|
75
|
+
{
|
|
76
|
+
publishedAt: toDate(entry.published_at),
|
|
77
|
+
updatedAt: toDate(entry.updated_at),
|
|
78
|
+
oldSlugs: entry.old_slugs.map((old) => ({ slug: old.slug, changedAt: toDate(old.changed_at) })),
|
|
79
|
+
},
|
|
80
|
+
]),
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
}
|
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.7",
|
|
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/options.ts
CHANGED
|
@@ -50,8 +50,12 @@ function isFieldsSchema(value: unknown): value is BlogFieldsSchema {
|
|
|
50
50
|
|
|
51
51
|
function isBlockPlugin(value: unknown): value is BlockPlugin {
|
|
52
52
|
if (typeof value !== "object" || value === null) return false;
|
|
53
|
-
const candidate = value as { type?: unknown; render?: unknown };
|
|
54
|
-
return
|
|
53
|
+
const candidate = value as { type?: unknown; syntax?: unknown; render?: unknown; markdown?: unknown };
|
|
54
|
+
return (
|
|
55
|
+
typeof candidate.type === "string" && KEBAB.test(candidate.type) && typeof candidate.render === "function" &&
|
|
56
|
+
(candidate.syntax === undefined || candidate.syntax === "fence" || candidate.syntax === "directive") &&
|
|
57
|
+
(candidate.markdown === undefined || typeof candidate.markdown === "function")
|
|
58
|
+
);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/** How long the pages cache their reads by default, in seconds; the app's `revalidate` should match. */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Content negotiation for the Markdown representation: which requests ask for Markdown.
|
|
2
|
+
|
|
3
|
+
interface MediaRange {
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly subtype: string;
|
|
6
|
+
readonly q: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseAccept(accept: string): MediaRange[] {
|
|
10
|
+
return accept.split(",").flatMap((part) => {
|
|
11
|
+
const [range = "", ...params] = part.trim().toLowerCase().split(";");
|
|
12
|
+
const [type, subtype] = range.trim().split("/");
|
|
13
|
+
if (type === undefined || type === "" || subtype === undefined || subtype === "") return [];
|
|
14
|
+
const qParam = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
|
|
15
|
+
const q = qParam === undefined ? 1 : Number(qParam.slice(2));
|
|
16
|
+
return Number.isFinite(q) ? [{ type, subtype, q }] : [];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The weight of a type by the most specific matching range (RFC 9110 §12.5.1); 0 without a match. */
|
|
21
|
+
function getQuality(ranges: readonly MediaRange[], type: string, subtype: string): number {
|
|
22
|
+
const exact = ranges.find((range) => range.type === type && range.subtype === subtype);
|
|
23
|
+
const group = ranges.find((range) => range.type === type && range.subtype === "*");
|
|
24
|
+
const any = ranges.find((range) => range.type === "*" && range.subtype === "*");
|
|
25
|
+
return (exact ?? group ?? any)?.q ?? 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether an `Accept` header asks for Markdown rather than HTML. `text/markdown` must be named: `*\/*`
|
|
30
|
+
* (curl, most crawlers, browser prefetches) matches Markdown as well as HTML, and they want the page.
|
|
31
|
+
* A tie goes to Markdown: a client that names it next to HTML with the same weight is an agent.
|
|
32
|
+
*/
|
|
33
|
+
export function prefersMarkdown(accept: string | null): boolean {
|
|
34
|
+
if (accept === null) return false;
|
|
35
|
+
const ranges = parseAccept(accept);
|
|
36
|
+
const markdown = ranges.find((range) => range.type === "text" && range.subtype === "markdown");
|
|
37
|
+
if (markdown === undefined || markdown.q <= 0) return false;
|
|
38
|
+
return markdown.q >= getQuality(ranges, "text", "html");
|
|
39
|
+
}
|
package/src/proxy/index.ts
CHANGED
|
@@ -8,12 +8,21 @@
|
|
|
8
8
|
// }
|
|
9
9
|
//
|
|
10
10
|
// Next 16 runs `proxy.ts` on Node.js, so the database handle is the process-wide one.
|
|
11
|
+
//
|
|
12
|
+
// `createBlogMarkdown` answers an article or term page asked for with `Accept: text/markdown` with the
|
|
13
|
+
// text as Markdown; put it before the redirects so a moved or withdrawn text still answers as before:
|
|
14
|
+
//
|
|
15
|
+
// return (await blogMarkdown(request)) ?? (await blogRedirects(request)) ?? …
|
|
11
16
|
import { systemClock, type SoftureConfig } from "@softure-ai/core";
|
|
12
17
|
import { getConfiguredDatabase } from "@softure-ai/db";
|
|
13
|
-
import { findArticleBySlug, findSlugRedirect, type BlogContext } from "../db/articles.js";
|
|
18
|
+
import { findArticleBySlug, findSlugRedirect, getPublishedArticle, type BlogContext } from "../db/articles.js";
|
|
19
|
+
import { prefersMarkdown } from "../pages/accept.js";
|
|
20
|
+
import { toArticleMarkdown } from "../render/article-markdown.js";
|
|
14
21
|
import { matchBlogPath } from "../pages/paths.js";
|
|
15
22
|
import { buildGonePage, createCachedBlogPathDecider, type BlogPathLookup, type CachedDeciderOptions } from "../pages/redirects.js";
|
|
16
|
-
import { getBlogMessages, getBlogReservedSlugs, getBlogRoutes } from "../server/options.js";
|
|
23
|
+
import { getBlogMessages, getBlogOptions, getBlogReservedSlugs, getBlogRoutes } from "../server/options.js";
|
|
24
|
+
|
|
25
|
+
export { prefersMarkdown };
|
|
17
26
|
|
|
18
27
|
export type BlogRedirects = (request: Request) => Promise<Response | null>;
|
|
19
28
|
|
|
@@ -64,3 +73,52 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
|
|
|
64
73
|
return null;
|
|
65
74
|
};
|
|
66
75
|
}
|
|
76
|
+
|
|
77
|
+
export type BlogMarkdown = (request: Request) => Promise<Response | null>;
|
|
78
|
+
|
|
79
|
+
export interface BlogMarkdownOptions {
|
|
80
|
+
/** The store's context; the shared database handle of `config.database` by default (tests pass PGlite). */
|
|
81
|
+
readonly getContext?: () => Promise<BlogContext>;
|
|
82
|
+
/** Where a failed read is reported; `console.error` by default. The request then goes on to the page. */
|
|
83
|
+
readonly onError?: (message: string) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const MARKDOWN_HEADERS = {
|
|
87
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
88
|
+
// One address, two representations. `private`: a shared cache must not hand Markdown to a browser.
|
|
89
|
+
vary: "Accept",
|
|
90
|
+
"cache-control": "private, max-age=0, must-revalidate",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Answers a GET or HEAD of a published article or term whose `Accept` asks for Markdown
|
|
95
|
+
* (`prefersMarkdown`) with the text as Markdown (`toArticleMarkdown`, the app's block plugins giving
|
|
96
|
+
* their Markdown form). `null` for everything else, and when the read fails: the page answers then.
|
|
97
|
+
*/
|
|
98
|
+
export function createBlogMarkdown(config: SoftureConfig, options: BlogMarkdownOptions = {}): BlogMarkdown {
|
|
99
|
+
const routes = getBlogRoutes(config);
|
|
100
|
+
const reservedSlugs = getBlogReservedSlugs(config);
|
|
101
|
+
const getContext = options.getContext ?? createDefaultContext(config);
|
|
102
|
+
const onError = options.onError ?? ((message: string) => console.error(message));
|
|
103
|
+
const { blocks } = getBlogOptions(config);
|
|
104
|
+
const messages = getBlogMessages(config).pages;
|
|
105
|
+
|
|
106
|
+
return async (request) => {
|
|
107
|
+
if (request.method !== "GET" && request.method !== "HEAD") return null;
|
|
108
|
+
if (!prefersMarkdown(request.headers.get("accept"))) return null;
|
|
109
|
+
const url = new URL(request.url);
|
|
110
|
+
const match = matchBlogPath(url.pathname, routes, reservedSlugs);
|
|
111
|
+
if (match === null) return null;
|
|
112
|
+
let article;
|
|
113
|
+
try {
|
|
114
|
+
article = await getPublishedArticle(await getContext(), match.slug);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
onError(`@softure-ai/blog: reading ${url.pathname} as Markdown failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (article?.kind !== match.kind) return null;
|
|
120
|
+
const body = toArticleMarkdown(article, { blocks, messages });
|
|
121
|
+
const headers = { ...MARKDOWN_HEADERS, "x-markdown-tokens": String(Math.ceil(body.length / 4)) };
|
|
122
|
+
return new Response(request.method === "HEAD" ? null : body, { status: 200, headers });
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/quality/catalog.ts
CHANGED
|
@@ -75,7 +75,10 @@ export function listQualityRules(settings: QualitySettings): QualityCatalogRule[
|
|
|
75
75
|
...ruleset.patterns.map((pattern) => rule("style", pattern.id, pattern.severity, pattern.message)),
|
|
76
76
|
...settings.voicePatterns.map((pattern) => rule("voice", pattern.id, pattern.severity, pattern.message)),
|
|
77
77
|
...(options.ymyl === null ? [] : YMYL_RULES),
|
|
78
|
-
...(options.blocks.length === 0 ? [] : [rule("structure", "block-requires", "error", "a block plugin's
|
|
78
|
+
...(options.blocks.length === 0 ? [] : [rule("structure", "block-requires", "error", "a block plugin's block has the frontmatter keys it requires")]),
|
|
79
|
+
...(options.blocks.some((plugin) => plugin.syntax === "directive")
|
|
80
|
+
? [rule("structure", "block-directive", "error", "a ::directive line names a directive the blog renders, with readable key=\"value\" attributes")]
|
|
81
|
+
: []),
|
|
79
82
|
...(options.plugins.length === 0 ? [] : PLUGIN_RULES),
|
|
80
83
|
...options.plugins.flatMap((plugin) => plugin.rules.map((info) => rule("plugin", info.id, info.severity, info.description))),
|
|
81
84
|
];
|
|
@@ -8,7 +8,7 @@ import { parseArticleFile, type ParseArticleFileOptions } from "../content/artic
|
|
|
8
8
|
import { splitArticleBody, splitBlocks } from "./blocks.js";
|
|
9
9
|
import { sortFindings, type QualityFinding } from "./finding.js";
|
|
10
10
|
import type { QualityPlugin } from "./plugin.js";
|
|
11
|
-
import { checkBlockRequires } from "./rules/blocks.js";
|
|
11
|
+
import { checkBlockRequires, checkDirectives } from "./rules/blocks.js";
|
|
12
12
|
import { checkImages } from "./rules/images.js";
|
|
13
13
|
import type { RuleInput } from "./rules/input.js";
|
|
14
14
|
import { checkLinks, collectLinks, type InternalLinkResolver } from "./rules/links.js";
|
|
@@ -58,6 +58,7 @@ export function checkArticle(input: CheckArticleInput): QualityCheckResult {
|
|
|
58
58
|
...checkStyle(ruleInput),
|
|
59
59
|
...checkRhythm(ruleInput),
|
|
60
60
|
...checkBlockRequires(article, pluginBlocks),
|
|
61
|
+
...checkDirectives(blocks, pluginBlocks, settings.options.blocks),
|
|
61
62
|
...checkImages(images, settings.images),
|
|
62
63
|
];
|
|
63
64
|
const fromPlugins = settings.options.plugins.flatMap((plugin) => runPlugin(plugin, { article, blocks, pluginBlocks, today, ruleset: settings.ruleset }));
|
package/src/quality/options.ts
CHANGED
|
@@ -9,8 +9,12 @@ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
9
9
|
|
|
10
10
|
function isBlockPlugin(value: unknown): value is BlockPlugin {
|
|
11
11
|
if (typeof value !== "object" || value === null) return false;
|
|
12
|
-
const candidate = value as { type?: unknown; render?: unknown };
|
|
13
|
-
return
|
|
12
|
+
const candidate = value as { type?: unknown; syntax?: unknown; render?: unknown; markdown?: unknown };
|
|
13
|
+
return (
|
|
14
|
+
typeof candidate.type === "string" && typeof candidate.render === "function" &&
|
|
15
|
+
(candidate.syntax === undefined || candidate.syntax === "fence" || candidate.syntax === "directive") &&
|
|
16
|
+
(candidate.markdown === undefined || typeof candidate.markdown === "function")
|
|
17
|
+
);
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
const range = (min: number, max: number) =>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Block plugin needs (BL-3): a fenced block of a block plugin reads frontmatter keys (`requires`), and
|
|
2
2
|
// an article that lacks one would render the block without its data.
|
|
3
3
|
import type { BlogArticleInput } from "../../contract.js";
|
|
4
|
-
import type
|
|
4
|
+
import { parseDirectiveLine, type BlockPlugin, type FoundBlock } from "../../render/render-article.js";
|
|
5
|
+
import type { Block } from "../blocks.js";
|
|
5
6
|
import type { QualityFinding } from "../finding.js";
|
|
6
7
|
|
|
7
8
|
export function checkBlockRequires(article: BlogArticleInput, pluginBlocks: readonly FoundBlock[]): QualityFinding[] {
|
|
@@ -41,3 +42,27 @@ function hasFrontmatterKey(article: BlogArticleInput, key: string): boolean {
|
|
|
41
42
|
return article.fields[key] !== undefined;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Directive lines the renderer would not turn into a block (BL-6 for directives): a name no directive
|
|
48
|
+
* plugin renders (it would show as a paragraph `::chrat{…}`), or attributes it cannot read. Checked
|
|
49
|
+
* only when the app registers a directive plugin; otherwise `::` lines are the app's own business.
|
|
50
|
+
*/
|
|
51
|
+
export function checkDirectives(blocks: readonly Block[], pluginBlocks: readonly FoundBlock[], plugins: readonly BlockPlugin[]): QualityFinding[] {
|
|
52
|
+
const names = plugins.filter((plugin) => plugin.syntax === "directive").map((plugin) => plugin.type);
|
|
53
|
+
if (names.length === 0) return [];
|
|
54
|
+
const unknown = blocks.flatMap((block) => {
|
|
55
|
+
if (block.kind !== "directive") return [];
|
|
56
|
+
const directive = parseDirectiveLine(block.text);
|
|
57
|
+
if (directive === null || names.includes(directive.name)) return [];
|
|
58
|
+
return [finding(`::${directive.name} is not a directive this blog renders; use one of: ${names.join(", ")}`, block.line)];
|
|
59
|
+
});
|
|
60
|
+
const unreadable = pluginBlocks
|
|
61
|
+
.filter((block) => block.syntax === "directive" && block.attributes === null)
|
|
62
|
+
.map((block) => finding(`the attributes of ::${block.type} cannot be read; write them as key="value" pairs, each key once`, block.line));
|
|
63
|
+
return [...unknown, ...unreadable].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function finding(message: string, line: number): QualityFinding {
|
|
67
|
+
return { rule: "block-directive", severity: "error", message, line };
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// An article or term as Markdown for agents (`Accept: text/markdown`): the stored Markdown in a frame
|
|
2
|
+
// that carries what the HTML page shows around it (title, description, the day the facts were checked,
|
|
3
|
+
// the summary, sources and FAQ). Exact by construction: no HTML round trip.
|
|
4
|
+
import type { BlogArticle } from "../contract.js";
|
|
5
|
+
import { en } from "../messages/en.js";
|
|
6
|
+
import { replaceArticleBlocks, type BlockPlugin } from "./render-article.js";
|
|
7
|
+
|
|
8
|
+
export type ArticleMarkdownInput = Pick<BlogArticle, "title" | "description" | "summary" | "bodyMarkdown" | "sources" | "faq" | "currentAsOf" | "fields">;
|
|
9
|
+
|
|
10
|
+
export interface ArticleMarkdownOptions {
|
|
11
|
+
/** The app's block plugins; a block whose plugin has `markdown` is replaced by its output. */
|
|
12
|
+
readonly blocks?: readonly BlockPlugin[];
|
|
13
|
+
/** The pages' copy for the frame's labels; English by default. */
|
|
14
|
+
readonly messages?: Pick<(typeof en)["pages"], "summary" | "sources" | "faq" | "currentAsOf">;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toArticleMarkdown(article: ArticleMarkdownInput, options: ArticleMarkdownOptions = {}): string {
|
|
18
|
+
const messages = options.messages ?? en.pages;
|
|
19
|
+
const body = replaceArticleBlocks(article.bodyMarkdown.trim(), options.blocks ?? [], { currentAsOf: article.currentAsOf, fields: article.fields });
|
|
20
|
+
const parts = [`# ${article.title}`, article.description, `${messages.currentAsOf}: ${article.currentAsOf}`];
|
|
21
|
+
if (article.summary !== null) parts.push(`> **${messages.summary}:** ${article.summary}`);
|
|
22
|
+
parts.push(body);
|
|
23
|
+
if (article.sources.length > 0) {
|
|
24
|
+
parts.push(`## ${messages.sources}`, article.sources.map((source) => `- [${escapeLinkText(source.name)}](${source.url})`).join("\n"));
|
|
25
|
+
}
|
|
26
|
+
if (article.faq.length > 0) {
|
|
27
|
+
parts.push(`## ${messages.faq}`, ...article.faq.flatMap((entry) => [`### ${entry.question}`, entry.answer]));
|
|
28
|
+
}
|
|
29
|
+
return `${parts.join("\n\n")}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeLinkText(text: string): string {
|
|
33
|
+
return text.replace(/[[\]\\]/g, (char) => `\\${char}`);
|
|
34
|
+
}
|
package/src/render/index.ts
CHANGED
|
@@ -11,11 +11,17 @@ export {
|
|
|
11
11
|
type ImageProblem,
|
|
12
12
|
type ImageVerdict,
|
|
13
13
|
} from "./images.js";
|
|
14
|
+
export { toArticleMarkdown, type ArticleMarkdownInput, type ArticleMarkdownOptions } from "./article-markdown.js";
|
|
14
15
|
export { DEFAULT_WORDS_PER_MINUTE, getReadingMinutes } from "./reading-time.js";
|
|
15
16
|
export {
|
|
16
17
|
findArticleBlocks,
|
|
18
|
+
parseDirectiveAttributes,
|
|
19
|
+
parseDirectiveLine,
|
|
17
20
|
renderArticle,
|
|
21
|
+
replaceArticleBlocks,
|
|
18
22
|
type ArticleBlock,
|
|
23
|
+
type BlockAttributes,
|
|
24
|
+
type BlockSyntax,
|
|
19
25
|
type ArticleHeading,
|
|
20
26
|
type ArticleSegment,
|
|
21
27
|
type BlockArticle,
|