@softure-ai/blog 0.1.5 → 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 +22 -0
- package/README.md +142 -15
- 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 +13 -6
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +131 -94
- 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/next/context.js +2 -2
- package/dist/next/context.js.map +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 +54 -4
- 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/dist/sitemap.js +2 -2
- package/dist/sitemap.js.map +1 -1
- package/module.json +1 -1
- package/package.json +7 -3
- package/skill/references/rules.md +2 -1
- package/src/cli/report.ts +182 -0
- package/src/cli/run.ts +132 -100
- 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/next/context.ts +2 -2
- package/src/options.ts +6 -2
- package/src/pages/accept.ts +39 -0
- package/src/proxy/index.ts +62 -4
- 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
- package/src/sitemap.ts +2 -2
package/src/cli/run.ts
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
10
10
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
|
-
import { systemClock, type Clock, type SoftureConfig } from "@softure-ai/core";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
12
|
+
import { systemClock, type Clock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
|
|
13
|
+
import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { BLOG_REFRESH_SECRET_ENV, requestBlogRefresh } from "../discovery/refresh.js";
|
|
16
|
+
import { submitBlogChanges } from "../discovery/submit.js";
|
|
17
|
+
import { parseArticleHistory, type ArticleHistoryMap } from "../db/history.js";
|
|
18
|
+
import { runBlogPublish, type ArticleFile, type PublishGate } from "../db/publish-run.js";
|
|
17
19
|
import { checkArticleFiles, type FileCheckResult } from "../quality/check-files.js";
|
|
18
20
|
import type { FetchLike } from "../quality/external-links.js";
|
|
19
21
|
import { createQualityGate } from "../quality/gate.js";
|
|
@@ -22,12 +24,10 @@ import { getLocalDate } from "../quality/settings.js";
|
|
|
22
24
|
import type { OgFontSource } from "../options.js";
|
|
23
25
|
import { createOgFontLoader } from "../server/og-fonts.js";
|
|
24
26
|
import { getBlogOptions, getBlogReservedSlugs, getQualitySettings } from "../server/options.js";
|
|
27
|
+
import { createPublishReporter, type CliOutput, type PublishFormat } from "./report.js";
|
|
25
28
|
import { DEFAULT_SKILL_COMMAND, DEFAULT_SKILL_DIR, renderBlogSkill, SKILL_MARKER, type SkillFile } from "./skill.js";
|
|
26
29
|
|
|
27
|
-
export
|
|
28
|
-
readonly log: (line: string) => void;
|
|
29
|
-
readonly error: (line: string) => void;
|
|
30
|
-
}
|
|
30
|
+
export type { CliOutput };
|
|
31
31
|
|
|
32
32
|
export interface RunBlogCliOptions {
|
|
33
33
|
/** The app's config with `blog()` among its modules. */
|
|
@@ -37,7 +37,7 @@ export interface RunBlogCliOptions {
|
|
|
37
37
|
/** Relative paths resolve against it. Default: `process.cwd()`. */
|
|
38
38
|
readonly cwd?: string;
|
|
39
39
|
readonly output?: CliOutput;
|
|
40
|
-
/** Opens the database connection. Default: `createDatabase(url, { max: 1 })`. */
|
|
40
|
+
/** Opens the database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
|
|
41
41
|
readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
|
|
42
42
|
/** The gate for files going public. Default: the quality gate of `blog({ quality })`, none with `quality: false`. */
|
|
43
43
|
readonly gate?: PublishGate;
|
|
@@ -52,6 +52,8 @@ export interface RunBlogCliOptions {
|
|
|
52
52
|
readonly refreshFetch?: typeof fetch;
|
|
53
53
|
/** Where `publish` reads BLOG_REFRESH_SECRET. Default: `process.env`. */
|
|
54
54
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
55
|
+
/** What `publish --stdin` reads. Default: the whole of `process.stdin`, as UTF-8. */
|
|
56
|
+
readonly readStdin?: () => Promise<string>;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export const EXIT_OK = 0;
|
|
@@ -60,6 +62,7 @@ export const EXIT_USAGE = 2;
|
|
|
60
62
|
|
|
61
63
|
export const BLOG_USAGE = `Usage:
|
|
62
64
|
softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>]
|
|
65
|
+
[--stdin [--name <slug>.md]] [--history <file.json>] [--format text|lines]
|
|
63
66
|
softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>]
|
|
64
67
|
softure-blog skill install [--dir <path>] [--command <cmd>] [--check]
|
|
65
68
|
|
|
@@ -70,6 +73,12 @@ publish Brings the blog's tables to the state of the article files. A <path> i
|
|
|
70
73
|
--withdraw publish the one given file as withdrawn, whatever its status
|
|
71
74
|
--no-indexnow do not submit the changed addresses to IndexNow
|
|
72
75
|
--app-url the running app's origin for the cache refresh; default: appOrigin
|
|
76
|
+
--stdin read the files from standard input instead of paths: one file named by
|
|
77
|
+
--name, or without --name a JSON bundle {"files":[{"name","text"}]}
|
|
78
|
+
(optionally with "history", the content of a --history file)
|
|
79
|
+
--history a JSON file of the articles' earlier dates and old slugs, applied on the
|
|
80
|
+
first publish of each article (moving an existing blog in)
|
|
81
|
+
--format text (default) or lines: a stable blog|<key>|... contract for scripts
|
|
73
82
|
Files going public pass the quality gate first; an error writes nothing.
|
|
74
83
|
With ${BLOG_REFRESH_SECRET_ENV} set, a commit that changed a text asks the running
|
|
75
84
|
app (refreshBlogCache) to refresh its blog cache, before the IndexNow submit.
|
|
@@ -107,6 +116,11 @@ export type BlogCommand =
|
|
|
107
116
|
readonly indexNow: boolean;
|
|
108
117
|
/** The origin `--app-url` gives; `null`: `appOrigin`. */
|
|
109
118
|
readonly appUrl: string | null;
|
|
119
|
+
/** Read the files from standard input; `name`: one file, `null`: a JSON bundle. */
|
|
120
|
+
readonly stdin: { readonly name: string | null } | null;
|
|
121
|
+
/** The `--history` file, as given. */
|
|
122
|
+
readonly history: string | null;
|
|
123
|
+
readonly format: PublishFormat;
|
|
110
124
|
}
|
|
111
125
|
| { readonly kind: "check"; readonly paths: readonly string[]; readonly external: boolean; readonly today: string | null }
|
|
112
126
|
| { readonly kind: "skill-install"; readonly dir: string; readonly command: string; readonly check: boolean };
|
|
@@ -146,10 +160,28 @@ export function parseBlogCommand(argv: readonly string[]): BlogCommand | string
|
|
|
146
160
|
const { values, positionals } = parsed;
|
|
147
161
|
if (values.help === true) return { kind: "help" };
|
|
148
162
|
const withdraw = values.withdraw === true;
|
|
149
|
-
|
|
163
|
+
const isStdin = values.stdin === true;
|
|
164
|
+
const stdinName = values.name ?? null;
|
|
165
|
+
if (isStdin && positionals.length > 0) return "--stdin reads the files from standard input; give no paths with it";
|
|
166
|
+
if (!isStdin && stdinName !== null) return "--name names the file --stdin reads; use it with --stdin";
|
|
167
|
+
if (stdinName !== null && !/^[^/\\]+\.md$/.test(stdinName)) return `--name needs a file name <slug>.md, not "${stdinName}"`;
|
|
168
|
+
if (withdraw && !(isStdin ? stdinName !== null : positionals.length === 1)) return "--withdraw takes exactly one article file";
|
|
150
169
|
const appUrl = values["app-url"] === undefined ? null : parseOrigin(values["app-url"]);
|
|
151
170
|
if (appUrl === undefined) return `--app-url needs an http or https origin, e.g. http://web:3000, not "${values["app-url"] ?? ""}"`;
|
|
152
|
-
|
|
171
|
+
const format = values.format ?? "text";
|
|
172
|
+
if (format !== "text" && format !== "lines") return `--format is text or lines, not "${format}"`;
|
|
173
|
+
if (values.history !== undefined && values.history.trim() === "") return "--history needs a JSON file";
|
|
174
|
+
return {
|
|
175
|
+
kind: "publish",
|
|
176
|
+
paths: positionals,
|
|
177
|
+
commit: values.commit === true,
|
|
178
|
+
withdraw,
|
|
179
|
+
indexNow: values["no-indexnow"] !== true,
|
|
180
|
+
appUrl,
|
|
181
|
+
stdin: isStdin ? { name: stdinName } : null,
|
|
182
|
+
history: values.history ?? null,
|
|
183
|
+
format,
|
|
184
|
+
};
|
|
153
185
|
}
|
|
154
186
|
|
|
155
187
|
/** The origin of an http(s) URL, or `undefined` for anything else. */
|
|
@@ -232,6 +264,10 @@ function parsePublishArgs(args: readonly string[]) {
|
|
|
232
264
|
withdraw: { type: "boolean" },
|
|
233
265
|
"no-indexnow": { type: "boolean" },
|
|
234
266
|
"app-url": { type: "string" },
|
|
267
|
+
stdin: { type: "boolean" },
|
|
268
|
+
name: { type: "string" },
|
|
269
|
+
history: { type: "string" },
|
|
270
|
+
format: { type: "string" },
|
|
235
271
|
help: { type: "boolean" },
|
|
236
272
|
},
|
|
237
273
|
strict: true,
|
|
@@ -242,27 +278,28 @@ function parsePublishArgs(args: readonly string[]) {
|
|
|
242
278
|
async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, options: RunBlogCliOptions, output: CliOutput): Promise<number> {
|
|
243
279
|
const cwd = options.cwd ?? process.cwd();
|
|
244
280
|
const { config } = options;
|
|
281
|
+
const report = createPublishReporter(command.format, output);
|
|
282
|
+
const fail = (message: string): number => {
|
|
283
|
+
report.failed(message);
|
|
284
|
+
return EXIT_FAILED;
|
|
285
|
+
};
|
|
245
286
|
let blogOptions: ReturnType<typeof getBlogOptions>;
|
|
246
287
|
try {
|
|
247
288
|
blogOptions = getBlogOptions(config);
|
|
248
289
|
} catch (error) {
|
|
249
|
-
|
|
250
|
-
return EXIT_FAILED;
|
|
251
|
-
}
|
|
252
|
-
const paths = command.paths.length > 0 ? command.paths : [blogOptions.contentDir];
|
|
253
|
-
const files = await readArticleFiles(paths.map((path) => resolve(cwd, path)));
|
|
254
|
-
if (typeof files === "string") {
|
|
255
|
-
output.error(`softure-blog publish: ${files}`);
|
|
256
|
-
return EXIT_FAILED;
|
|
257
|
-
}
|
|
258
|
-
if (command.withdraw && files.length !== 1) {
|
|
259
|
-
output.error("softure-blog publish: --withdraw takes exactly one article file, not a folder");
|
|
260
|
-
return EXIT_FAILED;
|
|
261
|
-
}
|
|
262
|
-
if (config.database === null) {
|
|
263
|
-
output.error("softure-blog publish: the config has no database; set database.url in softure.config");
|
|
264
|
-
return EXIT_FAILED;
|
|
290
|
+
return fail(describeError(error));
|
|
265
291
|
}
|
|
292
|
+
const input = command.stdin === null
|
|
293
|
+
? { files: await readArticleFiles((command.paths.length > 0 ? command.paths : [blogOptions.contentDir]).map((path) => resolve(cwd, path))), history: undefined }
|
|
294
|
+
: await readStdinBundle(command.stdin.name, options.readStdin ?? readProcessStdin);
|
|
295
|
+
if (typeof input === "string") return fail(input);
|
|
296
|
+
const { files } = input;
|
|
297
|
+
if (typeof files === "string") return fail(files);
|
|
298
|
+
if (command.withdraw && files.length !== 1) return fail("--withdraw takes exactly one article file, not a folder");
|
|
299
|
+
if (command.history !== null && input.history !== undefined) return fail("the bundle on standard input carries a history; drop --history");
|
|
300
|
+
const history = command.history === null ? input.history : await readHistoryFile(resolve(cwd, command.history));
|
|
301
|
+
if (typeof history === "string") return fail(history);
|
|
302
|
+
if (config.database === null) return fail("the config has no database; set database.url in softure.config");
|
|
266
303
|
|
|
267
304
|
const clock = options.clock ?? systemClock;
|
|
268
305
|
let gate = options.gate;
|
|
@@ -271,16 +308,15 @@ async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, op
|
|
|
271
308
|
if (settings !== null) gate = createQualityGate(settings, clock);
|
|
272
309
|
}
|
|
273
310
|
|
|
274
|
-
let
|
|
311
|
+
let opened: CommandDatabase;
|
|
275
312
|
try {
|
|
276
|
-
|
|
313
|
+
opened = await openDatabase(config.database, options.openDatabase);
|
|
277
314
|
} catch (error) {
|
|
278
|
-
|
|
279
|
-
return EXIT_FAILED;
|
|
315
|
+
return fail(describeError(error));
|
|
280
316
|
}
|
|
281
317
|
try {
|
|
282
318
|
const run = await runBlogPublish(
|
|
283
|
-
{ db: handle.db, clock, config },
|
|
319
|
+
{ db: opened.handle.db, clock, config },
|
|
284
320
|
files,
|
|
285
321
|
{
|
|
286
322
|
commit: command.commit,
|
|
@@ -288,9 +324,10 @@ async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, op
|
|
|
288
324
|
reservedSlugs: getBlogReservedSlugs(config),
|
|
289
325
|
...(blogOptions.fields === undefined ? {} : { fields: blogOptions.fields }),
|
|
290
326
|
...(gate === undefined ? {} : { gate }),
|
|
327
|
+
...(history === undefined ? {} : { history }),
|
|
291
328
|
},
|
|
292
329
|
);
|
|
293
|
-
|
|
330
|
+
report.run(run);
|
|
294
331
|
if (run.status === "done") {
|
|
295
332
|
// Before the IndexNow submit: a crawler that answers the ping must find the new text.
|
|
296
333
|
const refresh = await requestBlogRefresh(config, run.changes, {
|
|
@@ -299,18 +336,17 @@ async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, op
|
|
|
299
336
|
...(options.env === undefined ? {} : { env: options.env }),
|
|
300
337
|
...(options.refreshFetch === undefined ? {} : { fetchImpl: options.refreshFetch }),
|
|
301
338
|
});
|
|
302
|
-
|
|
339
|
+
report.refresh(refresh);
|
|
303
340
|
}
|
|
304
341
|
if (run.status === "done" && command.indexNow) {
|
|
305
|
-
|
|
342
|
+
report.indexNow(await submitBlogChanges(config, run.changes, { commit: run.committed, ...(options.indexNowFetch === undefined ? {} : { fetchImpl: options.indexNowFetch }) }));
|
|
306
343
|
}
|
|
307
|
-
return
|
|
344
|
+
return run.status === "done" ? EXIT_OK : EXIT_FAILED;
|
|
308
345
|
} catch (error) {
|
|
309
346
|
// Driver errors: the message only, never a stack or the database URL.
|
|
310
|
-
|
|
311
|
-
return EXIT_FAILED;
|
|
347
|
+
return fail(`${describeError(error)} (did softure migrate run?)`);
|
|
312
348
|
} finally {
|
|
313
|
-
await
|
|
349
|
+
await opened.close();
|
|
314
350
|
}
|
|
315
351
|
}
|
|
316
352
|
|
|
@@ -450,72 +486,66 @@ function reportCheck(results: readonly FileCheckResult[], files: readonly ReadAr
|
|
|
450
486
|
return errors > 0 ? EXIT_FAILED : EXIT_OK;
|
|
451
487
|
}
|
|
452
488
|
|
|
453
|
-
function reportRun(run: BlogPublishRun, output: CliOutput): number {
|
|
454
|
-
for (const warning of run.warnings) output.error(`warning ${formatProblem(warning)}`);
|
|
455
|
-
if (run.status === "refused") {
|
|
456
|
-
for (const problem of run.problems) output.error(`error ${formatProblem(problem)}`);
|
|
457
|
-
output.error("refused: nothing written; fix the problems above");
|
|
458
|
-
return EXIT_FAILED;
|
|
459
|
-
}
|
|
460
|
-
for (const change of run.changes) {
|
|
461
|
-
output.log(formatChange(change));
|
|
462
|
-
if (change.previousSlug !== null) output.log(`moved ${change.id} ${change.previousSlug} -> ${change.slug}`);
|
|
463
|
-
}
|
|
464
|
-
const count = (action: PublishedChange["action"]) => String(run.changes.filter((change) => change.action === action).length);
|
|
465
|
-
output.log(`summary: added ${count("added")}, changed ${count("changed")}, unchanged ${count("unchanged")}`);
|
|
466
|
-
output.log(run.committed ? "written" : "dry run: nothing written; pass --commit to write");
|
|
467
|
-
return EXIT_OK;
|
|
468
|
-
}
|
|
469
489
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
case "failed":
|
|
486
|
-
output.error(`warning cache: ${outcome.reason} (${outcome.code}); the publish is written, the app shows it within ${String(outcome.revalidateSeconds)} s`);
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
const stdinBundleSchema = z.strictObject({
|
|
495
|
+
files: z
|
|
496
|
+
.array(z.strictObject({ name: z.string().regex(/^[^/\\]+\.md$/, "must be a file name <slug>.md"), text: z.string() }))
|
|
497
|
+
.min(1, "must list at least one file"),
|
|
498
|
+
/** The `--history` file's content, for a publish whose only input is standard input. */
|
|
499
|
+
history: z.unknown().optional(),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
interface StdinBundle {
|
|
503
|
+
readonly files: ArticleFile[];
|
|
504
|
+
readonly history: ArticleHistoryMap | undefined;
|
|
489
505
|
}
|
|
490
506
|
|
|
491
|
-
/**
|
|
492
|
-
function
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
case "failed":
|
|
507
|
-
output.error(`warning indexnow: ${outcome.reason} (${outcome.code}); the publish is written, submit the addresses later: ${paths.join(" ")}`);
|
|
508
|
-
return;
|
|
507
|
+
/** What `--stdin` gives: one file named by `--name`, or the JSON bundle (files, optional history); or why it cannot be read. */
|
|
508
|
+
async function readStdinBundle(name: string | null, readStdin: () => Promise<string>): Promise<StdinBundle | string> {
|
|
509
|
+
let text: string;
|
|
510
|
+
try {
|
|
511
|
+
text = await readStdin();
|
|
512
|
+
} catch (error) {
|
|
513
|
+
return `cannot read standard input: ${describeError(error)}`;
|
|
514
|
+
}
|
|
515
|
+
if (name !== null) return text.trim() === "" ? `standard input is empty; pipe the text of ${name}` : { files: [{ name, text }], history: undefined };
|
|
516
|
+
let json: unknown;
|
|
517
|
+
try {
|
|
518
|
+
json = JSON.parse(text);
|
|
519
|
+
} catch {
|
|
520
|
+
// The parser's message names a position in a text nobody sees; say what was expected instead.
|
|
521
|
+
return 'standard input is not a JSON bundle {"files":[{"name":"<slug>.md","text":"..."}]}; pass --name <slug>.md for one file';
|
|
509
522
|
}
|
|
523
|
+
const parsed = stdinBundleSchema.safeParse(json);
|
|
524
|
+
if (!parsed.success) return `the bundle on standard input: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "bundle"}: ${issue.message}`).join("; ")}`;
|
|
525
|
+
if (parsed.data.history === undefined) return { files: parsed.data.files, history: undefined };
|
|
526
|
+
const history = parseArticleHistory(parsed.data.history);
|
|
527
|
+
if (!history.ok) return `the bundle on standard input: history.${history.errors.join("; history.")}`;
|
|
528
|
+
return { files: parsed.data.files, history: history.history };
|
|
510
529
|
}
|
|
511
530
|
|
|
512
|
-
function
|
|
513
|
-
const
|
|
514
|
-
|
|
531
|
+
async function readProcessStdin(): Promise<string> {
|
|
532
|
+
const chunks: Buffer[] = [];
|
|
533
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer));
|
|
534
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
515
535
|
}
|
|
516
536
|
|
|
517
|
-
|
|
518
|
-
|
|
537
|
+
/** The `--history` file, parsed; or why it cannot be used. */
|
|
538
|
+
async function readHistoryFile(path: string): Promise<ArticleHistoryMap | string> {
|
|
539
|
+
const text = await readText(path);
|
|
540
|
+
if (text === null) return `cannot read ${path}`;
|
|
541
|
+
let json: unknown;
|
|
542
|
+
try {
|
|
543
|
+
json = JSON.parse(text);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
return `${path} is not JSON: ${describeError(error)}`;
|
|
546
|
+
}
|
|
547
|
+
const parsed = parseArticleHistory(json);
|
|
548
|
+
return parsed.ok ? parsed.history : `${path}: ${parsed.errors.join("; ")}`;
|
|
519
549
|
}
|
|
520
550
|
|
|
521
551
|
interface ReadArticleFile extends ArticleFile {
|
|
@@ -578,8 +608,10 @@ async function readText(path: string): Promise<string | null> {
|
|
|
578
608
|
}
|
|
579
609
|
}
|
|
580
610
|
|
|
581
|
-
function openDatabase(url: string)
|
|
582
|
-
return
|
|
611
|
+
async function openDatabase(database: SoftureDatabaseConfig, open: ((url: string) => Promise<DatabaseHandle>) | undefined): Promise<CommandDatabase> {
|
|
612
|
+
if (open === undefined) return openCommandDatabase(database, { max: 1 });
|
|
613
|
+
const handle = await open(database.url);
|
|
614
|
+
return { handle, close: handle.close };
|
|
583
615
|
}
|
|
584
616
|
|
|
585
617
|
/** The driver's own message; drizzle's "Failed query: … params: …" wrapper is dropped. */
|
package/src/contract.ts
CHANGED
|
@@ -84,5 +84,13 @@ export type BlogPublishResult =
|
|
|
84
84
|
readonly after: BlogArticleState;
|
|
85
85
|
/** The slug that has just entered the slug history. */
|
|
86
86
|
readonly previousSlug: string | null;
|
|
87
|
+
/** Set when the article's history (`publishArticle({ history })`) was applied. */
|
|
88
|
+
readonly imported?: true;
|
|
87
89
|
}
|
|
88
|
-
| {
|
|
90
|
+
| {
|
|
91
|
+
readonly ok: false;
|
|
92
|
+
readonly error: BlogSlugErrorCode;
|
|
93
|
+
readonly otherArticleId: string;
|
|
94
|
+
/** Set when the slug is an old slug from the article's history, not its current one. */
|
|
95
|
+
readonly slug?: string;
|
|
96
|
+
};
|
package/src/db/articles.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ModuleContext } from "@softure-ai/core";
|
|
|
5
5
|
import type { Queryable } from "@softure-ai/db";
|
|
6
6
|
import { and, asc, desc, eq, ne, 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, 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: 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,23 @@ export async function publishArticle(ctx: BlogContext, input: BlogArticleInput):
|
|
|
103
121
|
});
|
|
104
122
|
}
|
|
105
123
|
|
|
124
|
+
export interface PublishArticleOptions {
|
|
125
|
+
/** The article's earlier life, applied only when it has no row yet. */
|
|
126
|
+
readonly history?: ArticleHistory;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The refusal for the first old slug of `history` another article holds, current or old; `null` when all are free. */
|
|
130
|
+
async function findTakenOldSlug(db: Queryable, input: BlogArticleInput, history: ArticleHistory): Promise<Extract<BlogPublishResult, { ok: false }> | null> {
|
|
131
|
+
for (const { slug } of history.oldSlugs) {
|
|
132
|
+
if (slug === input.slug) continue;
|
|
133
|
+
const [owner] = await db.select({ id: articles.id }).from(articles).where(and(eq(articles.slug, slug), ne(articles.id, input.id)));
|
|
134
|
+
if (owner !== undefined) return { ok: false, error: "blog.slug_taken", otherArticleId: owner.id, slug };
|
|
135
|
+
const [historyOwner] = await db.select({ articleId: slugHistory.articleId }).from(slugHistory).where(eq(slugHistory.oldSlug, slug));
|
|
136
|
+
if (historyOwner !== undefined) return { ok: false, error: "blog.slug_in_history", otherArticleId: historyOwner.articleId, slug };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
106
141
|
/** The article under its current slug, in any status: a page tells 200 from 410 by it. */
|
|
107
142
|
export async function findArticleBySlug(ctx: BlogContext, slug: string): Promise<BlogArticle | null> {
|
|
108
143
|
const [row] = await ctx.db.select().from(articles).where(eq(articles.slug, slug));
|
|
@@ -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/next/context.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// wall clock; and what it hands the components: the copy, paths, brand and disclaimer.
|
|
3
3
|
import { systemClock, type SoftureConfig } from "@softure-ai/core";
|
|
4
4
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
5
|
-
import {
|
|
5
|
+
import { getConfiguredDatabase } from "@softure-ai/db";
|
|
6
6
|
import type { BlogContext } from "../db/articles.js";
|
|
7
7
|
import { getBlogMessages, getBlogOptions, getBlogRoutes } from "../server/options.js";
|
|
8
8
|
import type { BlogPageContext } from "../ui/page-context.js";
|
|
@@ -12,7 +12,7 @@ export async function getBlogContext(config: SoftureConfig = getSoftureConfig())
|
|
|
12
12
|
// Unreachable for a validated config: the module has a database schema.
|
|
13
13
|
throw new Error("@softure-ai/blog: softure.config.ts has no database; the blog needs one");
|
|
14
14
|
}
|
|
15
|
-
const { db } = await
|
|
15
|
+
const { db } = await getConfiguredDatabase(config.database);
|
|
16
16
|
return { db, clock: systemClock, config };
|
|
17
17
|
}
|
|
18
18
|
|