@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +142 -15
  3. package/dist/cli/report.d.ts +17 -0
  4. package/dist/cli/report.d.ts.map +1 -0
  5. package/dist/cli/report.js +149 -0
  6. package/dist/cli/report.js.map +1 -0
  7. package/dist/cli/run.d.ts +13 -6
  8. package/dist/cli/run.d.ts.map +1 -1
  9. package/dist/cli/run.js +131 -94
  10. package/dist/cli/run.js.map +1 -1
  11. package/dist/contract.d.ts +4 -0
  12. package/dist/contract.d.ts.map +1 -1
  13. package/dist/db/articles.d.ts +10 -1
  14. package/dist/db/articles.d.ts.map +1 -1
  15. package/dist/db/articles.js +36 -4
  16. package/dist/db/articles.js.map +1 -1
  17. package/dist/db/history.d.ts +33 -0
  18. package/dist/db/history.d.ts.map +1 -0
  19. package/dist/db/history.js +66 -0
  20. package/dist/db/history.js.map +1 -0
  21. package/dist/db/publish-run.d.ts +11 -0
  22. package/dist/db/publish-run.d.ts.map +1 -1
  23. package/dist/db/publish-run.js +14 -4
  24. package/dist/db/publish-run.js.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/next/context.js +2 -2
  27. package/dist/next/context.js.map +1 -1
  28. package/dist/options.d.ts.map +1 -1
  29. package/dist/options.js +3 -1
  30. package/dist/options.js.map +1 -1
  31. package/dist/pages/accept.d.ts +7 -0
  32. package/dist/pages/accept.d.ts.map +1 -0
  33. package/dist/pages/accept.js +34 -0
  34. package/dist/pages/accept.js.map +1 -0
  35. package/dist/proxy/index.d.ts +15 -0
  36. package/dist/proxy/index.d.ts.map +1 -1
  37. package/dist/proxy/index.js +54 -4
  38. package/dist/proxy/index.js.map +1 -1
  39. package/dist/quality/catalog.d.ts.map +1 -1
  40. package/dist/quality/catalog.js +4 -1
  41. package/dist/quality/catalog.js.map +1 -1
  42. package/dist/quality/check-article.d.ts.map +1 -1
  43. package/dist/quality/check-article.js +2 -1
  44. package/dist/quality/check-article.js.map +1 -1
  45. package/dist/quality/options.d.ts.map +1 -1
  46. package/dist/quality/options.js +3 -1
  47. package/dist/quality/options.js.map +1 -1
  48. package/dist/quality/rules/blocks.d.ts +8 -1
  49. package/dist/quality/rules/blocks.d.ts.map +1 -1
  50. package/dist/quality/rules/blocks.js +26 -0
  51. package/dist/quality/rules/blocks.js.map +1 -1
  52. package/dist/render/article-markdown.d.ts +12 -0
  53. package/dist/render/article-markdown.d.ts.map +1 -0
  54. package/dist/render/article-markdown.js +21 -0
  55. package/dist/render/article-markdown.js.map +1 -0
  56. package/dist/render/index.d.ts +2 -1
  57. package/dist/render/index.d.ts.map +1 -1
  58. package/dist/render/index.js +2 -1
  59. package/dist/render/index.js.map +1 -1
  60. package/dist/render/render-article.d.ts +40 -4
  61. package/dist/render/render-article.d.ts.map +1 -1
  62. package/dist/render/render-article.js +132 -10
  63. package/dist/render/render-article.js.map +1 -1
  64. package/dist/server/index.d.ts +2 -1
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +1 -0
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/sitemap.js +2 -2
  69. package/dist/sitemap.js.map +1 -1
  70. package/module.json +1 -1
  71. package/package.json +7 -3
  72. package/skill/references/rules.md +2 -1
  73. package/src/cli/report.ts +182 -0
  74. package/src/cli/run.ts +132 -100
  75. package/src/contract.ts +9 -1
  76. package/src/db/articles.ts +39 -4
  77. package/src/db/history.ts +83 -0
  78. package/src/db/publish-run.ts +24 -4
  79. package/src/index.ts +1 -1
  80. package/src/next/context.ts +2 -2
  81. package/src/options.ts +6 -2
  82. package/src/pages/accept.ts +39 -0
  83. package/src/proxy/index.ts +62 -4
  84. package/src/quality/catalog.ts +4 -1
  85. package/src/quality/check-article.ts +2 -1
  86. package/src/quality/options.ts +6 -2
  87. package/src/quality/rules/blocks.ts +26 -1
  88. package/src/render/article-markdown.ts +34 -0
  89. package/src/render/index.ts +6 -0
  90. package/src/render/render-article.ts +170 -16
  91. package/src/server/index.ts +2 -0
  92. 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 { createDatabase, type DatabaseHandle } from "@softure-ai/db";
14
- import { BLOG_REFRESH_SECRET_ENV, requestBlogRefresh, type BlogRefreshOutcome } from "../discovery/refresh.js";
15
- import { submitBlogChanges, type BlogIndexNowSubmit } from "../discovery/submit.js";
16
- import { runBlogPublish, type ArticleFile, type BlogPublishRun, type PublishedChange, type PublishGate, type PublishProblem } from "../db/publish-run.js";
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 interface CliOutput {
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
- if (withdraw && positionals.length !== 1) return "--withdraw takes exactly one article file";
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
- return { kind: "publish", paths: positionals, commit: values.commit === true, withdraw, indexNow: values["no-indexnow"] !== true, appUrl };
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
- output.error(`softure-blog publish: ${describeError(error)}`);
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 handle: DatabaseHandle;
311
+ let opened: CommandDatabase;
275
312
  try {
276
- handle = await (options.openDatabase ?? openDatabase)(config.database.url);
313
+ opened = await openDatabase(config.database, options.openDatabase);
277
314
  } catch (error) {
278
- output.error(`softure-blog publish: ${describeError(error)}`);
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
- const code = reportRun(run, output);
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
- reportRefresh(refresh, output);
339
+ report.refresh(refresh);
303
340
  }
304
341
  if (run.status === "done" && command.indexNow) {
305
- reportIndexNow(await submitBlogChanges(config, run.changes, { commit: run.committed, ...(options.indexNowFetch === undefined ? {} : { fetchImpl: options.indexNowFetch }) }), output);
342
+ report.indexNow(await submitBlogChanges(config, run.changes, { commit: run.committed, ...(options.indexNowFetch === undefined ? {} : { fetchImpl: options.indexNowFetch }) }));
306
343
  }
307
- return code;
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
- output.error(`softure-blog publish: ${describeError(error)} (did softure migrate run?)`);
311
- return EXIT_FAILED;
347
+ return fail(`${describeError(error)} (did softure migrate run?)`);
312
348
  } finally {
313
- await handle.close();
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
- /** One line about the cache refresh; a failure is a warning and never changes the exit code. */
471
- function reportRefresh(outcome: BlogRefreshOutcome, output: CliOutput): void {
472
- switch (outcome.kind) {
473
- case "not_configured":
474
- output.log(`cache: the running app shows the change within revalidateSeconds (${String(outcome.revalidateSeconds)} s); set ${BLOG_REFRESH_SECRET_ENV} to refresh it now`);
475
- return;
476
- case "skipped":
477
- output.log("cache: no text changed, nothing to refresh");
478
- return;
479
- case "dry_run":
480
- output.log(`cache: dry run, a commit would refresh ${outcome.url}`);
481
- return;
482
- case "refreshed":
483
- output.log(`cache: refreshed ${outcome.url}`);
484
- return;
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
- /** One line about the IndexNow submit; a failure is a warning and never changes the exit code. */
492
- function reportIndexNow({ paths, outcome }: BlogIndexNowSubmit, output: CliOutput): void {
493
- switch (outcome.kind) {
494
- case "not_configured":
495
- output.log(`indexnow: off, ${outcome.reason}`);
496
- return;
497
- case "skipped":
498
- output.log("indexnow: no public address changed, nothing to submit");
499
- return;
500
- case "dry_run":
501
- output.log(`indexnow: dry run, a commit would submit ${String(outcome.urls.length)} URL(s): ${outcome.urls.join(" ")}`);
502
- return;
503
- case "submitted":
504
- output.log(`indexnow: submitted ${String(outcome.count)} URL(s) (${String(outcome.status)}): ${paths.join(" ")}`);
505
- return;
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 formatChange(change: PublishedChange): string {
513
- const before = change.statusBefore === null || change.slugBefore === null ? "none" : `${change.statusBefore}/${change.slugBefore}`;
514
- return `${change.action} ${change.id} ${before} -> ${change.statusAfter}/${change.slug}`;
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
- function formatProblem(problem: PublishProblem): string {
518
- return `${problem.subject}: ${problem.message}`;
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): Promise<DatabaseHandle> {
582
- return createDatabase(url, { max: 1 });
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
- | { readonly ok: false; readonly error: BlogSlugErrorCode; readonly otherArticleId: string };
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
+ };
@@ -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 publishedAt = input.publishedAt ?? (input.status === "published" ? now : null);
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: null, createdAt: now })
78
+ .values({ id: input.id, ...content, publishedAt, updatedAt, createdAt: now })
68
79
  .returning();
69
- return { ok: true, action: "added", before: null, after: readState(requireRow(inserted, input.id)), previousSlug: null };
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
+ }
@@ -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 result = await publishArticleOrRefuse({ ...ctx, db: tx }, input);
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
- throw new PublishRefused([{ subject: input.id, message: `slug ${input.slug} ${reason} article ${result.otherArticleId} (${result.error})` }]);
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.5",
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?" },
@@ -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 { getSharedDatabase } from "@softure-ai/db";
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 getSharedDatabase(config.database.url);
15
+ const { db } = await getConfiguredDatabase(config.database);
16
16
  return { db, clock: systemClock, config };
17
17
  }
18
18