@softure-ai/blog 0.0.0-stage → 0.1.6
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 +10 -0
- package/LICENSE +21 -0
- package/README.md +604 -2
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +40 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +62 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +555 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/skill.d.ts +47 -0
- package/dist/cli/skill.d.ts.map +1 -0
- package/dist/cli/skill.js +221 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/content/article-file.d.ts +27 -0
- package/dist/content/article-file.d.ts.map +1 -0
- package/dist/content/article-file.js +179 -0
- package/dist/content/article-file.js.map +1 -0
- package/dist/contract.d.ts +77 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/db/articles.d.ts +30 -0
- package/dist/db/articles.d.ts.map +1 -0
- package/dist/db/articles.js +138 -0
- package/dist/db/articles.js.map +1 -0
- package/dist/db/publish-run.d.ts +50 -0
- package/dist/db/publish-run.d.ts.map +1 -0
- package/dist/db/publish-run.js +168 -0
- package/dist/db/publish-run.js.map +1 -0
- package/dist/db/schema.d.ts +403 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +33 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/discovery/dates.d.ts +9 -0
- package/dist/discovery/dates.d.ts.map +1 -0
- package/dist/discovery/dates.js +19 -0
- package/dist/discovery/dates.js.map +1 -0
- package/dist/discovery/index.d.ts +8 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +11 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/indexnow.d.ts +11 -0
- package/dist/discovery/indexnow.d.ts.map +1 -0
- package/dist/discovery/indexnow.js +23 -0
- package/dist/discovery/indexnow.js.map +1 -0
- package/dist/discovery/refresh.d.ts +49 -0
- package/dist/discovery/refresh.d.ts.map +1 -0
- package/dist/discovery/refresh.js +55 -0
- package/dist/discovery/refresh.js.map +1 -0
- package/dist/discovery/related.d.ts +17 -0
- package/dist/discovery/related.d.ts.map +1 -0
- package/dist/discovery/related.js +28 -0
- package/dist/discovery/related.js.map +1 -0
- package/dist/discovery/rss.d.ts +30 -0
- package/dist/discovery/rss.d.ts.map +1 -0
- package/dist/discovery/rss.js +48 -0
- package/dist/discovery/rss.js.map +1 -0
- package/dist/discovery/sitemap.d.ts +25 -0
- package/dist/discovery/sitemap.d.ts.map +1 -0
- package/dist/discovery/sitemap.js +31 -0
- package/dist/discovery/sitemap.js.map +1 -0
- package/dist/discovery/submit.d.ts +44 -0
- package/dist/discovery/submit.d.ts.map +1 -0
- package/dist/discovery/submit.js +46 -0
- package/dist/discovery/submit.js.map +1 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +83 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +83 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +158 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +5 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +78 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +78 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/context.d.ts +6 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +27 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/data.d.ts +11 -0
- package/dist/next/data.d.ts.map +1 -0
- package/dist/next/data.js +40 -0
- package/dist/next/data.js.map +1 -0
- package/dist/next/discovery.d.ts +6 -0
- package/dist/next/discovery.d.ts.map +1 -0
- package/dist/next/discovery.js +49 -0
- package/dist/next/discovery.js.map +1 -0
- package/dist/next/index.d.ts +9 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +10 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/og-fonts.d.ts +6 -0
- package/dist/next/og-fonts.d.ts.map +1 -0
- package/dist/next/og-fonts.js +8 -0
- package/dist/next/og-fonts.js.map +1 -0
- package/dist/next/og-image.d.ts +36 -0
- package/dist/next/og-image.d.ts.map +1 -0
- package/dist/next/og-image.js +77 -0
- package/dist/next/og-image.js.map +1 -0
- package/dist/next/pages.d.ts +45 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +189 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/refresh.d.ts +9 -0
- package/dist/next/refresh.d.ts.map +1 -0
- package/dist/next/refresh.js +81 -0
- package/dist/next/refresh.js.map +1 -0
- package/dist/options.d.ts +320 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +184 -0
- package/dist/options.js.map +1 -0
- package/dist/pages/body.d.ts +18 -0
- package/dist/pages/body.d.ts.map +1 -0
- package/dist/pages/body.js +21 -0
- package/dist/pages/body.js.map +1 -0
- package/dist/pages/dates.d.ts +21 -0
- package/dist/pages/dates.d.ts.map +1 -0
- package/dist/pages/dates.js +24 -0
- package/dist/pages/dates.js.map +1 -0
- package/dist/pages/index.d.ts +7 -0
- package/dist/pages/index.d.ts.map +1 -0
- package/dist/pages/index.js +9 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/json-ld.d.ts +33 -0
- package/dist/pages/json-ld.d.ts.map +1 -0
- package/dist/pages/json-ld.js +95 -0
- package/dist/pages/json-ld.js.map +1 -0
- package/dist/pages/listing.d.ts +46 -0
- package/dist/pages/listing.d.ts.map +1 -0
- package/dist/pages/listing.js +57 -0
- package/dist/pages/listing.js.map +1 -0
- package/dist/pages/paths.d.ts +37 -0
- package/dist/pages/paths.d.ts.map +1 -0
- package/dist/pages/paths.js +61 -0
- package/dist/pages/paths.js.map +1 -0
- package/dist/pages/redirects.d.ts +57 -0
- package/dist/pages/redirects.d.ts.map +1 -0
- package/dist/pages/redirects.js +78 -0
- package/dist/pages/redirects.js.map +1 -0
- package/dist/proxy/index.d.ts +15 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +60 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/quality/blocks.d.ts +21 -0
- package/dist/quality/blocks.d.ts.map +1 -0
- package/dist/quality/blocks.js +113 -0
- package/dist/quality/blocks.js.map +1 -0
- package/dist/quality/catalog.d.ts +10 -0
- package/dist/quality/catalog.d.ts.map +1 -0
- package/dist/quality/catalog.js +71 -0
- package/dist/quality/catalog.js.map +1 -0
- package/dist/quality/check-article.d.ts +34 -0
- package/dist/quality/check-article.d.ts.map +1 -0
- package/dist/quality/check-article.js +74 -0
- package/dist/quality/check-article.js.map +1 -0
- package/dist/quality/check-files.d.ts +23 -0
- package/dist/quality/check-files.d.ts.map +1 -0
- package/dist/quality/check-files.js +44 -0
- package/dist/quality/check-files.js.map +1 -0
- package/dist/quality/external-links.d.ts +19 -0
- package/dist/quality/external-links.d.ts.map +1 -0
- package/dist/quality/external-links.js +30 -0
- package/dist/quality/external-links.js.map +1 -0
- package/dist/quality/finding.d.ts +17 -0
- package/dist/quality/finding.d.ts.map +1 -0
- package/dist/quality/finding.js +15 -0
- package/dist/quality/finding.js.map +1 -0
- package/dist/quality/gate.d.ts +5 -0
- package/dist/quality/gate.d.ts.map +1 -0
- package/dist/quality/gate.js +10 -0
- package/dist/quality/gate.js.map +1 -0
- package/dist/quality/index.d.ts +15 -0
- package/dist/quality/index.d.ts.map +1 -0
- package/dist/quality/index.js +16 -0
- package/dist/quality/index.js.map +1 -0
- package/dist/quality/link-targets.d.ts +44 -0
- package/dist/quality/link-targets.d.ts.map +1 -0
- package/dist/quality/link-targets.js +91 -0
- package/dist/quality/link-targets.js.map +1 -0
- package/dist/quality/options.d.ts +113 -0
- package/dist/quality/options.d.ts.map +1 -0
- package/dist/quality/options.js +93 -0
- package/dist/quality/options.js.map +1 -0
- package/dist/quality/plugin.d.ts +34 -0
- package/dist/quality/plugin.d.ts.map +1 -0
- package/dist/quality/plugin.js +7 -0
- package/dist/quality/plugin.js.map +1 -0
- package/dist/quality/rules/blocks.d.ts +5 -0
- package/dist/quality/rules/blocks.d.ts.map +1 -0
- package/dist/quality/rules/blocks.js +35 -0
- package/dist/quality/rules/blocks.js.map +1 -0
- package/dist/quality/rules/images.d.ts +4 -0
- package/dist/quality/rules/images.d.ts.map +1 -0
- package/dist/quality/rules/images.js +33 -0
- package/dist/quality/rules/images.js.map +1 -0
- package/dist/quality/rules/input.d.ts +11 -0
- package/dist/quality/rules/input.d.ts.map +1 -0
- package/dist/quality/rules/input.js +2 -0
- package/dist/quality/rules/input.js.map +1 -0
- package/dist/quality/rules/links.d.ts +17 -0
- package/dist/quality/rules/links.d.ts.map +1 -0
- package/dist/quality/rules/links.js +46 -0
- package/dist/quality/rules/links.js.map +1 -0
- package/dist/quality/rules/structure.d.ts +13 -0
- package/dist/quality/rules/structure.d.ts.map +1 -0
- package/dist/quality/rules/structure.js +139 -0
- package/dist/quality/rules/structure.js.map +1 -0
- package/dist/quality/rules/style.d.ts +11 -0
- package/dist/quality/rules/style.d.ts.map +1 -0
- package/dist/quality/rules/style.js +129 -0
- package/dist/quality/rules/style.js.map +1 -0
- package/dist/quality/rules/ymyl.d.ts +5 -0
- package/dist/quality/rules/ymyl.d.ts.map +1 -0
- package/dist/quality/rules/ymyl.js +51 -0
- package/dist/quality/rules/ymyl.js.map +1 -0
- package/dist/quality/rulesets/en/ruleset.d.ts +3 -0
- package/dist/quality/rulesets/en/ruleset.d.ts.map +1 -0
- package/dist/quality/rulesets/en/ruleset.js +106 -0
- package/dist/quality/rulesets/en/ruleset.js.map +1 -0
- package/dist/quality/rulesets/index.d.ts +7 -0
- package/dist/quality/rulesets/index.d.ts.map +1 -0
- package/dist/quality/rulesets/index.js +7 -0
- package/dist/quality/rulesets/index.js.map +1 -0
- package/dist/quality/rulesets/pl/ruleset.d.ts +3 -0
- package/dist/quality/rulesets/pl/ruleset.d.ts.map +1 -0
- package/dist/quality/rulesets/pl/ruleset.js +114 -0
- package/dist/quality/rulesets/pl/ruleset.js.map +1 -0
- package/dist/quality/rulesets/types.d.ts +28 -0
- package/dist/quality/rulesets/types.d.ts.map +1 -0
- package/dist/quality/rulesets/types.js +2 -0
- package/dist/quality/rulesets/types.js.map +1 -0
- package/dist/quality/settings.d.ts +26 -0
- package/dist/quality/settings.d.ts.map +1 -0
- package/dist/quality/settings.js +32 -0
- package/dist/quality/settings.js.map +1 -0
- package/dist/quality/text.d.ts +43 -0
- package/dist/quality/text.d.ts.map +1 -0
- package/dist/quality/text.js +85 -0
- package/dist/quality/text.js.map +1 -0
- package/dist/render/glossary.d.ts +27 -0
- package/dist/render/glossary.d.ts.map +1 -0
- package/dist/render/glossary.js +71 -0
- package/dist/render/glossary.js.map +1 -0
- package/dist/render/images.d.ts +42 -0
- package/dist/render/images.d.ts.map +1 -0
- package/dist/render/images.js +84 -0
- package/dist/render/images.js.map +1 -0
- package/dist/render/index.d.ts +6 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/reading-time.d.ts +8 -0
- package/dist/render/reading-time.d.ts.map +1 -0
- package/dist/render/reading-time.js +17 -0
- package/dist/render/reading-time.js.map +1 -0
- package/dist/render/render-article.d.ts +97 -0
- package/dist/render/render-article.d.ts.map +1 -0
- package/dist/render/render-article.js +373 -0
- package/dist/render/render-article.js.map +1 -0
- package/dist/render/slugify-heading.d.ts +2 -0
- package/dist/render/slugify-heading.d.ts.map +1 -0
- package/dist/render/slugify-heading.js +21 -0
- package/dist/render/slugify-heading.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +11 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +13 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/og-fonts.d.ts +24 -0
- package/dist/server/og-fonts.d.ts.map +1 -0
- package/dist/server/og-fonts.js +85 -0
- package/dist/server/og-fonts.js.map +1 -0
- package/dist/server/options.d.ts +17 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +56 -0
- package/dist/server/options.js.map +1 -0
- package/dist/sitemap.d.ts +11 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +35 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/ui/blog-article.d.ts +49 -0
- package/dist/ui/blog-article.d.ts.map +1 -0
- package/dist/ui/blog-article.js +45 -0
- package/dist/ui/blog-article.js.map +1 -0
- package/dist/ui/blog-glossary.d.ts +27 -0
- package/dist/ui/blog-glossary.d.ts.map +1 -0
- package/dist/ui/blog-glossary.js +20 -0
- package/dist/ui/blog-glossary.js.map +1 -0
- package/dist/ui/blog-layout.d.ts +31 -0
- package/dist/ui/blog-layout.d.ts.map +1 -0
- package/dist/ui/blog-layout.js +25 -0
- package/dist/ui/blog-layout.js.map +1 -0
- package/dist/ui/blog-listing.d.ts +15 -0
- package/dist/ui/blog-listing.d.ts.map +1 -0
- package/dist/ui/blog-listing.js +29 -0
- package/dist/ui/blog-listing.js.map +1 -0
- package/dist/ui/blog-method.d.ts +5 -0
- package/dist/ui/blog-method.d.ts.map +1 -0
- package/dist/ui/blog-method.js +21 -0
- package/dist/ui/blog-method.js.map +1 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/page-context.d.ts +15 -0
- package/dist/ui/page-context.d.ts.map +1 -0
- package/dist/ui/page-context.js +2 -0
- package/dist/ui/page-context.js.map +1 -0
- package/migrations/0001_create_articles.sql +67 -0
- package/migrations/README.md +8 -0
- package/module.json +28 -0
- package/package.json +102 -4
- package/skill/SKILL.md +72 -0
- package/skill/references/reviewer.md +39 -0
- package/skill/references/rules.md +122 -0
- package/skill/references/structure.md +69 -0
- package/skill/references/template.md +86 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +49 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/run.ts +591 -0
- package/src/cli/skill.ts +242 -0
- package/src/content/article-file.ts +184 -0
- package/src/contract.ts +88 -0
- package/src/db/articles.ts +160 -0
- package/src/db/publish-run.ts +224 -0
- package/src/db/schema.ts +36 -0
- package/src/discovery/dates.ts +27 -0
- package/src/discovery/index.ts +18 -0
- package/src/discovery/indexnow.ts +25 -0
- package/src/discovery/refresh.ts +79 -0
- package/src/discovery/related.ts +35 -0
- package/src/discovery/rss.ts +77 -0
- package/src/discovery/sitemap.ts +59 -0
- package/src/discovery/submit.ts +69 -0
- package/src/index.ts +99 -0
- package/src/messages/en.ts +82 -0
- package/src/messages/index.ts +7 -0
- package/src/messages/pl.ts +77 -0
- package/src/next/context.ts +30 -0
- package/src/next/data.ts +55 -0
- package/src/next/discovery.ts +49 -0
- package/src/next/index.ts +25 -0
- package/src/next/next-modules.d.ts +15 -0
- package/src/next/og-fonts.ts +14 -0
- package/src/next/og-image.tsx +108 -0
- package/src/next/pages.tsx +251 -0
- package/src/next/refresh.ts +85 -0
- package/src/options.ts +224 -0
- package/src/pages/body.ts +38 -0
- package/src/pages/dates.ts +39 -0
- package/src/pages/index.ts +37 -0
- package/src/pages/json-ld.ts +120 -0
- package/src/pages/listing.ts +95 -0
- package/src/pages/paths.ts +84 -0
- package/src/pages/redirects.ts +120 -0
- package/src/proxy/index.ts +66 -0
- package/src/quality/blocks.ts +130 -0
- package/src/quality/catalog.ts +86 -0
- package/src/quality/check-article.ts +112 -0
- package/src/quality/check-files.ts +63 -0
- package/src/quality/external-links.ts +37 -0
- package/src/quality/finding.ts +29 -0
- package/src/quality/gate.ts +15 -0
- package/src/quality/index.ts +28 -0
- package/src/quality/link-targets.ts +107 -0
- package/src/quality/options.ts +105 -0
- package/src/quality/plugin.ts +43 -0
- package/src/quality/rules/blocks.ts +43 -0
- package/src/quality/rules/images.ts +34 -0
- package/src/quality/rules/input.ts +12 -0
- package/src/quality/rules/links.ts +56 -0
- package/src/quality/rules/structure.ts +137 -0
- package/src/quality/rules/style.ts +137 -0
- package/src/quality/rules/ymyl.ts +57 -0
- package/src/quality/rulesets/en/ruleset.ts +117 -0
- package/src/quality/rulesets/index.ts +9 -0
- package/src/quality/rulesets/pl/ruleset.ts +126 -0
- package/src/quality/rulesets/types.ts +31 -0
- package/src/quality/settings.ts +60 -0
- package/src/quality/text.ts +113 -0
- package/src/render/glossary.ts +103 -0
- package/src/render/images.ts +110 -0
- package/src/render/index.ts +29 -0
- package/src/render/reading-time.ts +18 -0
- package/src/render/render-article.ts +487 -0
- package/src/render/slugify-heading.ts +22 -0
- package/src/server/health.ts +12 -0
- package/src/server/index.ts +28 -0
- package/src/server/og-fonts.ts +102 -0
- package/src/server/options.ts +62 -0
- package/src/sitemap.ts +36 -0
- package/src/ui/blog-article.tsx +186 -0
- package/src/ui/blog-glossary.tsx +104 -0
- package/src/ui/blog-layout.tsx +89 -0
- package/src/ui/blog-listing.tsx +95 -0
- package/src/ui/blog-method.tsx +34 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/page-context.ts +17 -0
- package/styles.css +498 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// A publish run (FIRE_TRACKER `src/db/blog-publish.ts`): the heart of `softure-blog publish`.
|
|
2
|
+
//
|
|
3
|
+
// All or nothing. Every file is read and checked before the first write, and the writes go
|
|
4
|
+
// through one transaction: one bad file or one taken slug, and no row changes. A release publishes
|
|
5
|
+
// the whole folder, and half a folder in production is worse than no change.
|
|
6
|
+
//
|
|
7
|
+
// A dry run unless `commit`: the transaction is rolled back, but the result shows exactly what a
|
|
8
|
+
// commit would do (the rows before and after, read from the database).
|
|
9
|
+
import type { BlogArticleInput, BlogArticleKind, BlogArticleStatus, BlogPublishAction } from "../contract.js";
|
|
10
|
+
import { parseArticleFile, type ParseArticleFileOptions } from "../content/article-file.js";
|
|
11
|
+
import type { Queryable } from "@softure-ai/db";
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
13
|
+
import { findTermFormConflicts, toGlossary } from "../render/glossary.js";
|
|
14
|
+
import { listArticles, publishArticle, type BlogContext } from "./articles.js";
|
|
15
|
+
import { articles } from "./schema.js";
|
|
16
|
+
|
|
17
|
+
export interface ArticleFile {
|
|
18
|
+
/** The file name (or path); it must read `<slug>.md`. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The quality gate (BL-6 fills it): the problems of a file that is about to go public. It runs only
|
|
25
|
+
* for files whose status is `published` and not under `withdraw`: a withdrawal must always pass, and a
|
|
26
|
+
* draft reaches no reader.
|
|
27
|
+
*/
|
|
28
|
+
export type PublishGate = (file: ArticleFile, article: BlogArticleInput) => readonly string[];
|
|
29
|
+
|
|
30
|
+
export interface RunBlogPublishOptions extends ParseArticleFileOptions {
|
|
31
|
+
/** Write the changes; without it the run is a dry run. */
|
|
32
|
+
readonly commit?: boolean;
|
|
33
|
+
/** Publish the files as `withdrawn`, whatever their status (taking a text down at once). */
|
|
34
|
+
readonly withdraw?: boolean;
|
|
35
|
+
readonly gate?: PublishGate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A problem that stops the run; `subject` is a file name, an article id or a cluster. */
|
|
39
|
+
export interface PublishProblem {
|
|
40
|
+
readonly subject: string;
|
|
41
|
+
readonly message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the run did (or would do) to one article; BL-5 derives the changed addresses from it. */
|
|
45
|
+
export interface PublishedChange {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly kind: BlogArticleKind;
|
|
48
|
+
readonly action: BlogPublishAction;
|
|
49
|
+
readonly statusBefore: BlogArticleStatus | null;
|
|
50
|
+
readonly statusAfter: BlogArticleStatus;
|
|
51
|
+
readonly slugBefore: string | null;
|
|
52
|
+
readonly slug: string;
|
|
53
|
+
/** The slug that entered the slug history in this run. */
|
|
54
|
+
readonly previousSlug: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type BlogPublishRun =
|
|
58
|
+
| { readonly status: "refused"; readonly problems: readonly PublishProblem[]; readonly warnings: readonly PublishProblem[] }
|
|
59
|
+
| { readonly status: "done"; readonly committed: boolean; readonly changes: readonly PublishedChange[]; readonly warnings: readonly PublishProblem[] };
|
|
60
|
+
|
|
61
|
+
/** SQLSTATE of an exclusion constraint violation: the deferred one-pillar rule at commit. */
|
|
62
|
+
const EXCLUSION_VIOLATION = "23P01";
|
|
63
|
+
/** SQLSTATE of a unique violation; on `articles_slug_key` it is a lost slug race. */
|
|
64
|
+
const UNIQUE_VIOLATION = "23505";
|
|
65
|
+
const SLUG_CONSTRAINT = "articles_slug_key";
|
|
66
|
+
|
|
67
|
+
class DryRunRollback extends Error {}
|
|
68
|
+
|
|
69
|
+
class PublishRefused extends Error {
|
|
70
|
+
constructor(readonly problems: readonly PublishProblem[]) {
|
|
71
|
+
super(problems.map((problem) => problem.message).join("; "));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runBlogPublish(ctx: BlogContext, files: readonly ArticleFile[], options: RunBlogPublishOptions = {}): Promise<BlogPublishRun> {
|
|
76
|
+
const problems: PublishProblem[] = [];
|
|
77
|
+
const warnings: PublishProblem[] = [];
|
|
78
|
+
const inputs: BlogArticleInput[] = [];
|
|
79
|
+
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const parsed = parseArticleFile(file.text, file.name, options);
|
|
82
|
+
if (!parsed.ok) {
|
|
83
|
+
problems.push(...parsed.errors.map((message) => ({ subject: file.name, message })));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const isGoingPublic = options.withdraw !== true && parsed.article.status === "published";
|
|
87
|
+
const gateProblems = isGoingPublic && options.gate !== undefined ? options.gate(file, parsed.article) : [];
|
|
88
|
+
if (gateProblems.length > 0) {
|
|
89
|
+
problems.push(...gateProblems.map((message) => ({ subject: file.name, message: `quality gate: ${message}` })));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (options.withdraw === true && parsed.article.status !== "withdrawn") {
|
|
93
|
+
warnings.push({
|
|
94
|
+
subject: file.name,
|
|
95
|
+
message: `the file says status: ${parsed.article.status}; set it to withdrawn, or the next full publish brings the text back`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
inputs.push(options.withdraw === true ? { ...parsed.article, status: "withdrawn" } : parsed.article);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
problems.push(...findDuplicateIds(inputs), ...findPillarProblems(inputs));
|
|
102
|
+
if (problems.length > 0) return { status: "refused", problems, warnings };
|
|
103
|
+
|
|
104
|
+
const changes: PublishedChange[] = [];
|
|
105
|
+
try {
|
|
106
|
+
await ctx.db.transaction(async (tx) => {
|
|
107
|
+
for (const input of inputs) {
|
|
108
|
+
const result = await publishArticleOrRefuse({ ...ctx, db: tx }, input);
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
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})` }]);
|
|
112
|
+
}
|
|
113
|
+
changes.push({
|
|
114
|
+
id: input.id,
|
|
115
|
+
kind: input.kind,
|
|
116
|
+
action: result.action,
|
|
117
|
+
statusBefore: result.before?.status ?? null,
|
|
118
|
+
statusAfter: result.after.status,
|
|
119
|
+
slugBefore: result.before?.slug ?? null,
|
|
120
|
+
slug: result.after.slug,
|
|
121
|
+
previousSlug: result.previousSlug,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const glossary = await checkGlossaryForms({ ...ctx, db: tx }, inputs);
|
|
125
|
+
if (glossary.problems.length > 0) throw new PublishRefused(glossary.problems);
|
|
126
|
+
warnings.push(...glossary.warnings);
|
|
127
|
+
if (options.commit !== true) throw new DryRunRollback();
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof DryRunRollback) return { status: "done", committed: false, changes, warnings };
|
|
131
|
+
if (error instanceof PublishRefused) return { status: "refused", problems: error.problems, warnings };
|
|
132
|
+
if (findDriverError(error)?.code === EXCLUSION_VIOLATION) {
|
|
133
|
+
return {
|
|
134
|
+
status: "refused",
|
|
135
|
+
problems: [{ subject: "pillar", message: "a cluster would have two pillars: one is in the database and not in this run; mark only one text of a cluster pillar: true" }],
|
|
136
|
+
warnings,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return { status: "done", committed: true, changes, warnings };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findDuplicateIds(inputs: readonly BlogArticleInput[]): PublishProblem[] {
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
const duplicates = new Set<string>();
|
|
147
|
+
for (const { id } of inputs) {
|
|
148
|
+
if (seen.has(id)) duplicates.add(id);
|
|
149
|
+
seen.add(id);
|
|
150
|
+
}
|
|
151
|
+
return [...duplicates].map((id) => ({ subject: id, message: "two files have this id" }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One pillar per cluster among the run's texts that are not withdrawn, checked before any write
|
|
156
|
+
* for a clear message. The database's deferred exclusion constraint also covers texts outside the
|
|
157
|
+
* run (a run of one file).
|
|
158
|
+
*/
|
|
159
|
+
function findPillarProblems(inputs: readonly BlogArticleInput[]): PublishProblem[] {
|
|
160
|
+
const pillars = new Map<string, string[]>();
|
|
161
|
+
for (const input of inputs) {
|
|
162
|
+
if (input.isPillar && input.cluster !== null && input.status !== "withdrawn") {
|
|
163
|
+
pillars.set(input.cluster, [...(pillars.get(input.cluster) ?? []), input.id]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [...pillars]
|
|
167
|
+
.filter(([, ids]) => ids.length > 1)
|
|
168
|
+
.map(([cluster, ids]) => ({ subject: cluster, message: `two pillars in one cluster: ${ids.join(", ")}` }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One term per glossary form, over the published terms as this run leaves them (read inside its
|
|
173
|
+
* transaction, after its writes), so a run of one file sees the terms stored before it. A conflict
|
|
174
|
+
* with a term of this run refuses the run; one only between stored terms is not this run's doing and
|
|
175
|
+
* is a warning. The renderer links such a form to one of the terms either way.
|
|
176
|
+
*/
|
|
177
|
+
async function checkGlossaryForms(ctx: BlogContext, inputs: readonly BlogArticleInput[]): Promise<{ problems: PublishProblem[]; warnings: PublishProblem[] }> {
|
|
178
|
+
const runTerms = new Set(inputs.filter((input) => input.kind === "term").map((input) => input.slug));
|
|
179
|
+
const problems: PublishProblem[] = [];
|
|
180
|
+
const warnings: PublishProblem[] = [];
|
|
181
|
+
for (const conflict of findTermFormConflicts(toGlossary(await listArticles(ctx, { kind: "term" })))) {
|
|
182
|
+
const problem = { subject: "glossary", message: `form "${conflict.form}" is claimed by terms ${conflict.slugs.join(", ")}; a form belongs to one term, so remove it from all but one` };
|
|
183
|
+
if (conflict.slugs.some((slug) => runTerms.has(slug))) problems.push(problem);
|
|
184
|
+
else warnings.push(problem);
|
|
185
|
+
}
|
|
186
|
+
return { problems, warnings };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `publishArticle`, with a lost slug race turned into a refusal. Another run can commit the same
|
|
191
|
+
* slug between this run's free-slug read and its write; the unique index then makes the write wait
|
|
192
|
+
* for that run and fail with 23505. The savepoint is rolled back, so the transaction still reads
|
|
193
|
+
* (at read committed, the winner's row is visible now) and names the article that took the slug.
|
|
194
|
+
*/
|
|
195
|
+
async function publishArticleOrRefuse(ctx: BlogContext, input: BlogArticleInput): ReturnType<typeof publishArticle> {
|
|
196
|
+
try {
|
|
197
|
+
return await publishArticle(ctx, input);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const driverError = findDriverError(error);
|
|
200
|
+
if (driverError?.code !== UNIQUE_VIOLATION || driverError.constraint !== SLUG_CONSTRAINT) throw error;
|
|
201
|
+
const winner = await findSlugOwner(ctx.db, input.slug);
|
|
202
|
+
const owner = winner === undefined ? "another article published at the same time" : `article ${winner}`;
|
|
203
|
+
throw new PublishRefused([{ subject: input.id, message: `slug ${input.slug} is the slug of ${owner} (blog.slug_taken)` }]);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function findSlugOwner(db: Queryable, slug: string): Promise<string | undefined> {
|
|
208
|
+
const [row] = await db.select({ id: articles.id }).from(articles).where(eq(articles.slug, slug));
|
|
209
|
+
return row?.id;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface DriverError {
|
|
213
|
+
readonly code: string;
|
|
214
|
+
readonly constraint: string | undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The driver error (SQLSTATE and constraint) behind an error; drizzle wraps it as the `cause`. */
|
|
218
|
+
function findDriverError(error: unknown): DriverError | undefined {
|
|
219
|
+
for (let current: unknown = error, depth = 0; current instanceof Error && depth < 3; current = current.cause, depth += 1) {
|
|
220
|
+
const { code, constraint } = current as { code?: unknown; constraint?: unknown };
|
|
221
|
+
if (typeof code === "string") return { code, constraint: typeof constraint === "string" ? constraint : undefined };
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Drizzle view of the module's tables (migrations/0001_create_articles.sql). The migration is the
|
|
2
|
+
// source of truth; this file only types the queries.
|
|
3
|
+
import { boolean, date, jsonb, pgSchema, text, timestamp } from "drizzle-orm/pg-core";
|
|
4
|
+
import type { BlogArticleKind, BlogArticleStatus, BlogFaqEntry, BlogFields, BlogSource } from "../contract.js";
|
|
5
|
+
|
|
6
|
+
export const blogSchema = pgSchema("blog");
|
|
7
|
+
|
|
8
|
+
export const articles = blogSchema.table("articles", {
|
|
9
|
+
id: text("id").primaryKey(),
|
|
10
|
+
slug: text("slug").notNull().unique("articles_slug_key"),
|
|
11
|
+
kind: text("kind").$type<BlogArticleKind>().notNull(),
|
|
12
|
+
cluster: text("cluster"),
|
|
13
|
+
isPillar: boolean("is_pillar").notNull().default(false),
|
|
14
|
+
title: text("title").notNull(),
|
|
15
|
+
description: text("description").notNull(),
|
|
16
|
+
summary: text("summary"),
|
|
17
|
+
bodyMarkdown: text("body_markdown").notNull(),
|
|
18
|
+
status: text("status").$type<BlogArticleStatus>().notNull(),
|
|
19
|
+
currentAsOf: date("current_as_of", { mode: "string" }).notNull(),
|
|
20
|
+
publishedAt: timestamp("published_at", { withTimezone: true }),
|
|
21
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
|
22
|
+
sources: jsonb("sources").$type<readonly BlogSource[]>().notNull().default([]),
|
|
23
|
+
faq: jsonb("faq").$type<readonly BlogFaqEntry[]>().notNull().default([]),
|
|
24
|
+
termForms: jsonb("term_forms").$type<readonly string[]>().notNull().default([]),
|
|
25
|
+
fields: jsonb("fields").$type<BlogFields>().notNull().default({}),
|
|
26
|
+
contentSha256: text("content_sha256").notNull(),
|
|
27
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const slugHistory = blogSchema.table("slug_history", {
|
|
31
|
+
oldSlug: text("old_slug").primaryKey(),
|
|
32
|
+
articleId: text("article_id")
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => articles.id, { onDelete: "cascade" }),
|
|
35
|
+
changedAt: timestamp("changed_at", { withTimezone: true }).notNull(),
|
|
36
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The dates search engines and feed readers get: the last real change of a text, never a build or
|
|
2
|
+
// request date. `updated_at` moves only when the content hash of a published text changes (BL-2), so
|
|
3
|
+
// it is a `lastmod` a search engine can trust.
|
|
4
|
+
import type { BlogArticle } from "../contract.js";
|
|
5
|
+
|
|
6
|
+
export type DatedText = Pick<BlogArticle, "slug" | "publishedAt" | "updatedAt">;
|
|
7
|
+
|
|
8
|
+
/** When a published text last changed: its update, else its publication. */
|
|
9
|
+
export function getTextLastModified(text: DatedText): Date {
|
|
10
|
+
return text.updatedAt ?? requirePublishedAt(text);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The newest change among the texts; `null` for none. */
|
|
14
|
+
export function getLatestModified(texts: readonly DatedText[]): Date | null {
|
|
15
|
+
return texts.reduce<Date | null>((latest, text) => {
|
|
16
|
+
const moment = getTextLastModified(text);
|
|
17
|
+
return latest === null || moment > latest ? moment : latest;
|
|
18
|
+
}, null);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A published text always has `published_at` (the store sets it); a missing one is a bug, not a date to invent. */
|
|
22
|
+
export function requirePublishedAt(text: Pick<BlogArticle, "slug" | "publishedAt">): Date {
|
|
23
|
+
if (text.publishedAt === null) {
|
|
24
|
+
throw new Error(`requirePublishedAt: text ${text.slug} has no published_at; discovery takes published texts only`);
|
|
25
|
+
}
|
|
26
|
+
return text.publishedAt;
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Discovery without React and without Next: sitemap entries, the RSS feed, "read next", the IndexNow
|
|
2
|
+
// paths and submit, and the cache refresh request after a publish. The Next pieces
|
|
3
|
+
// (`../next/discovery.ts`, `../next/refresh.ts`) render and serve what it returns.
|
|
4
|
+
export { getLatestModified, getTextLastModified, type DatedText } from "./dates.js";
|
|
5
|
+
export { getIndexNowPaths, type IndexNowChange } from "./indexnow.js";
|
|
6
|
+
export {
|
|
7
|
+
BLOG_REFRESH_RATE_LIMIT_BUCKET,
|
|
8
|
+
BLOG_REFRESH_SECRET_ENV,
|
|
9
|
+
MIN_REFRESH_SECRET_LENGTH,
|
|
10
|
+
requestBlogRefresh,
|
|
11
|
+
type BlogRefreshFailure,
|
|
12
|
+
type BlogRefreshOutcome,
|
|
13
|
+
type RequestBlogRefreshOptions,
|
|
14
|
+
} from "./refresh.js";
|
|
15
|
+
export { getRelatedArticles, PILLAR_RELATED_LIMIT, RELATED_LIMIT, type RelatedCandidate } from "./related.js";
|
|
16
|
+
export { buildBlogRss, escapeXml, type BuildBlogRssInput, type FeedChannel, type FeedText } from "./rss.js";
|
|
17
|
+
export { getBlogSitemapEntries, type BlogSitemapEntry, type BlogSitemapInput, type SitemapText } from "./sitemap.js";
|
|
18
|
+
export { submitBlogChanges, type BlogIndexNowOutcome, type BlogIndexNowSubmit, type SubmitBlogChangesOptions } from "./submit.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The addresses a publish run changed, for IndexNow (FIRE_TRACKER `src/lib/indexnow.ts`).
|
|
2
|
+
import type { PublishedChange } from "../db/publish-run.js";
|
|
3
|
+
import { getTextPath, type BlogRoutes } from "../pages/paths.js";
|
|
4
|
+
|
|
5
|
+
export type IndexNowChange = Pick<PublishedChange, "kind" | "action" | "statusBefore" | "statusAfter" | "slug" | "previousSlug">;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The paths whose answer changed: a text public before or after (a withdrawn one answers 410 now, and
|
|
9
|
+
* search engines should learn it), its old slug when it was public (now a 301), and the hub of its
|
|
10
|
+
* kind (the listing for an article, the glossary for a term). A draft that stayed a draft and an
|
|
11
|
+
* unchanged text add nothing. Texts first, then hubs, without repeats.
|
|
12
|
+
*/
|
|
13
|
+
export function getIndexNowPaths(changes: readonly IndexNowChange[], routes: BlogRoutes): string[] {
|
|
14
|
+
const texts = new Set<string>();
|
|
15
|
+
const hubs = new Set<string>();
|
|
16
|
+
for (const change of changes) {
|
|
17
|
+
const wasPublic = change.statusBefore === "published";
|
|
18
|
+
const isPublic = change.statusAfter === "published";
|
|
19
|
+
if (change.action === "unchanged" || (!wasPublic && !isPublic)) continue;
|
|
20
|
+
texts.add(getTextPath(routes, change.kind, change.slug));
|
|
21
|
+
if (change.previousSlug !== null && wasPublic) texts.add(getTextPath(routes, change.kind, change.previousSlug));
|
|
22
|
+
hubs.add(change.kind === "term" ? routes.glossary : routes.index);
|
|
23
|
+
}
|
|
24
|
+
return [...texts, ...hubs];
|
|
25
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The cache refresh after a publish: the command runs outside the app and cannot reach Next's cache,
|
|
2
|
+
// so it asks the running app, through the route `refreshBlogCache` (`@softure-ai/blog/next`), to
|
|
3
|
+
// expire the blog's cached reads. Both sides read one secret from the environment.
|
|
4
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
5
|
+
import { getBlogOptions, getBlogRefreshPath } from "../server/options.js";
|
|
6
|
+
import type { IndexNowChange } from "./indexnow.js";
|
|
7
|
+
|
|
8
|
+
/** The variable both the route and the command read the shared secret from. */
|
|
9
|
+
export const BLOG_REFRESH_SECRET_ENV = "BLOG_REFRESH_SECRET";
|
|
10
|
+
/** A shorter secret is a setup bug: the route refuses to run with it and the command does not send it. */
|
|
11
|
+
export const MIN_REFRESH_SECRET_LENGTH = 32;
|
|
12
|
+
/** The security bucket the route counts in; `BLOG_RATE_LIMIT_BUCKETS` holds its default. */
|
|
13
|
+
export const BLOG_REFRESH_RATE_LIMIT_BUCKET = "blog-refresh";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
export type BlogRefreshFailure = "blog.refresh_invalid_secret" | "blog.refresh_unreachable" | "blog.refresh_rejected";
|
|
18
|
+
|
|
19
|
+
export type BlogRefreshOutcome =
|
|
20
|
+
/** No secret in the environment: the app shows the change after `revalidateSeconds`. */
|
|
21
|
+
| { readonly kind: "not_configured"; readonly revalidateSeconds: number }
|
|
22
|
+
/** The run changed no text. */
|
|
23
|
+
| { readonly kind: "skipped" }
|
|
24
|
+
/** No commit: the address a commit would call, and no request. */
|
|
25
|
+
| { readonly kind: "dry_run"; readonly url: string }
|
|
26
|
+
| { readonly kind: "refreshed"; readonly url: string }
|
|
27
|
+
| { readonly kind: "failed"; readonly code: BlogRefreshFailure; readonly reason: string; readonly revalidateSeconds: number };
|
|
28
|
+
|
|
29
|
+
export interface RequestBlogRefreshOptions {
|
|
30
|
+
/** Sends the request only when `true`, i.e. after a committed run; otherwise a dry run. */
|
|
31
|
+
readonly commit?: boolean;
|
|
32
|
+
/** The app's origin to call, when it differs from `appOrigin` (a private name in a container). */
|
|
33
|
+
readonly appUrl?: string;
|
|
34
|
+
/** Default: `process.env`. */
|
|
35
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
36
|
+
readonly fetchImpl?: typeof fetch;
|
|
37
|
+
readonly timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Asks the running app to refresh the blog's cache after a publish run. Call it before the IndexNow
|
|
42
|
+
* submit, so a crawler that answers the ping finds the new text. Never throws for an expected
|
|
43
|
+
* failure: the refresh is an extra after a publish and must not undo it.
|
|
44
|
+
*/
|
|
45
|
+
export async function requestBlogRefresh(config: SoftureConfig, changes: readonly IndexNowChange[], options: RequestBlogRefreshOptions = {}): Promise<BlogRefreshOutcome> {
|
|
46
|
+
const { revalidateSeconds } = getBlogOptions(config);
|
|
47
|
+
if (changes.every((change) => change.action === "unchanged")) return { kind: "skipped" };
|
|
48
|
+
const secret = ((options.env ?? process.env)[BLOG_REFRESH_SECRET_ENV] ?? "").trim();
|
|
49
|
+
if (secret === "") return { kind: "not_configured", revalidateSeconds };
|
|
50
|
+
|
|
51
|
+
// Also on a dry run: the editor learns of a short secret before the commit that needs it.
|
|
52
|
+
if (secret.length < MIN_REFRESH_SECRET_LENGTH) {
|
|
53
|
+
return { kind: "failed", code: "blog.refresh_invalid_secret", reason: `${BLOG_REFRESH_SECRET_ENV} must be at least ${String(MIN_REFRESH_SECRET_LENGTH)} characters`, revalidateSeconds };
|
|
54
|
+
}
|
|
55
|
+
const url = new URL(getBlogRefreshPath(config), options.appUrl ?? config.appOrigin).toString();
|
|
56
|
+
if (options.commit !== true) return { kind: "dry_run", url };
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { authorization: `Bearer ${secret}` },
|
|
62
|
+
// Never followed: a redirect would carry the request, or drop the secret, somewhere else.
|
|
63
|
+
redirect: "manual",
|
|
64
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
if (response.status === 204 || response.status === 200) return { kind: "refreshed", url };
|
|
67
|
+
return { kind: "failed", code: "blog.refresh_rejected", reason: `${url} answered ${String(response.status)}${describeStatus(response.status)}`, revalidateSeconds };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { kind: "failed", code: "blog.refresh_unreachable", reason: `${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`, revalidateSeconds };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describeStatus(status: number): string {
|
|
74
|
+
if (status === 401) return ` (the app has another ${BLOG_REFRESH_SECRET_ENV})`;
|
|
75
|
+
if (status === 404) return " (mount refreshBlogCache at that path)";
|
|
76
|
+
if (status === 429) return " (too many refreshes from this address)";
|
|
77
|
+
if (status >= 300 && status < 400) return " (a redirect; pass the final origin with --app-url)";
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// "Read next" under an article (FIRE_TRACKER `src/lib/blog-discovery.ts`), chosen without manual lists.
|
|
2
|
+
import type { BlogArticle } from "../contract.js";
|
|
3
|
+
|
|
4
|
+
export type RelatedCandidate = Pick<BlogArticle, "id" | "cluster" | "isPillar">;
|
|
5
|
+
|
|
6
|
+
/** How many texts "read next" shows under an article. */
|
|
7
|
+
export const RELATED_LIMIT = 4;
|
|
8
|
+
|
|
9
|
+
/** At most this many satellites under a pillar: its whole cluster, but a longer list is a listing. */
|
|
10
|
+
export const PILLAR_RELATED_LIMIT = 6;
|
|
11
|
+
|
|
12
|
+
/** Pillars first, then the input order (newest first). */
|
|
13
|
+
function pillarsFirst<T extends RelatedCandidate>(texts: readonly T[]): T[] {
|
|
14
|
+
return [...texts.filter((text) => text.isPillar), ...texts.filter((text) => !text.isPillar)];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* - **A satellite:** the pillar of its cluster, the rest of the cluster, then pillars and texts of
|
|
19
|
+
* other clusters; {@link RELATED_LIMIT} in total.
|
|
20
|
+
* - **A pillar:** every satellite of its cluster (up to {@link PILLAR_RELATED_LIMIT}), filled up to
|
|
21
|
+
* {@link RELATED_LIMIT} from other clusters.
|
|
22
|
+
* - **No cluster:** pillars, then the newest.
|
|
23
|
+
*
|
|
24
|
+
* `published` comes newest first (the store's order); an article never recommends itself.
|
|
25
|
+
*/
|
|
26
|
+
export function getRelatedArticles<T extends RelatedCandidate>(article: RelatedCandidate, published: readonly T[]): T[] {
|
|
27
|
+
const others = published.filter((candidate) => candidate.id !== article.id);
|
|
28
|
+
const sameCluster = article.cluster === null ? [] : others.filter((candidate) => candidate.cluster === article.cluster);
|
|
29
|
+
const rest = pillarsFirst(others.filter((candidate) => !sameCluster.includes(candidate)));
|
|
30
|
+
if (article.isPillar) {
|
|
31
|
+
const satellites = sameCluster.slice(0, PILLAR_RELATED_LIMIT);
|
|
32
|
+
return [...satellites, ...rest].slice(0, Math.max(satellites.length, RELATED_LIMIT));
|
|
33
|
+
}
|
|
34
|
+
return [...pillarsFirst(sameCluster), ...rest].slice(0, RELATED_LIMIT);
|
|
35
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The blog's RSS 2.0 feed (FIRE_TRACKER `src/lib/blog-discovery.ts`). An item is the title, the
|
|
2
|
+
// address, the description and the publication date, **without the body**: the reader goes to the
|
|
3
|
+
// page, where the sources and the disclaimer are; a text torn from them would be advice without context.
|
|
4
|
+
import type { SiteUrls } from "@softure-ai/core";
|
|
5
|
+
import type { BlogArticle } from "../contract.js";
|
|
6
|
+
import { getArticlePath, getTermPath, type BlogRoutes } from "../pages/paths.js";
|
|
7
|
+
import { getLatestModified, requirePublishedAt } from "./dates.js";
|
|
8
|
+
|
|
9
|
+
export type FeedText = Pick<BlogArticle, "id" | "slug" | "kind" | "title" | "description" | "cluster" | "publishedAt" | "updatedAt">;
|
|
10
|
+
|
|
11
|
+
export interface FeedChannel {
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly description: string;
|
|
14
|
+
/** The feed's language, e.g. `en` or `pl`. */
|
|
15
|
+
readonly language: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BuildBlogRssInput {
|
|
19
|
+
readonly articles: readonly FeedText[];
|
|
20
|
+
readonly terms: readonly FeedText[];
|
|
21
|
+
/** The site's origin and canonical rule (`getSiteUrls(config)`): items and the channel link a page's canonical URL. */
|
|
22
|
+
readonly urls: SiteUrls;
|
|
23
|
+
readonly routes: BlogRoutes;
|
|
24
|
+
/** The feed's own path (`routes.rss`), for `atom:link rel="self"`. */
|
|
25
|
+
readonly feedPath: string;
|
|
26
|
+
readonly channel: FeedChannel;
|
|
27
|
+
/** An item's `<category>`: e.g. the cluster label of an article, the glossary title of a term; `null` for none. */
|
|
28
|
+
readonly getCategory: (text: FeedText) => string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const XML_ESCAPES: Readonly<Record<string, string>> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
32
|
+
|
|
33
|
+
export function escapeXml(text: string): string {
|
|
34
|
+
return text.replace(/[&<>"']/g, (character) => XML_ESCAPES[character] ?? character);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The feed of the published articles and terms together, newest publication first. `guid` is the
|
|
39
|
+
* text's id with `isPermaLink="false"`: it survives a slug change, so a reader never shows an old text
|
|
40
|
+
* again as new. `lastBuildDate` is the newest change, absent for an empty blog.
|
|
41
|
+
*/
|
|
42
|
+
export function buildBlogRss(input: BuildBlogRssInput): string {
|
|
43
|
+
const lastModified = getLatestModified([...input.articles, ...input.terms]);
|
|
44
|
+
const items = [
|
|
45
|
+
...input.articles.map((text) => ({ text, path: getArticlePath(input.routes, text.slug) })),
|
|
46
|
+
...input.terms.map((text) => ({ text, path: getTermPath(input.routes, text.slug) })),
|
|
47
|
+
]
|
|
48
|
+
.sort((a, b) => requirePublishedAt(b.text).getTime() - requirePublishedAt(a.text).getTime())
|
|
49
|
+
.map(({ text, path }) => {
|
|
50
|
+
const category = input.getCategory(text);
|
|
51
|
+
return [
|
|
52
|
+
" <item>",
|
|
53
|
+
` <title>${escapeXml(text.title)}</title>`,
|
|
54
|
+
` <link>${escapeXml(input.urls.getCanonicalUrl(path))}</link>`,
|
|
55
|
+
` <guid isPermaLink="false">${escapeXml(text.id)}</guid>`,
|
|
56
|
+
` <description>${escapeXml(text.description)}</description>`,
|
|
57
|
+
` <pubDate>${requirePublishedAt(text).toUTCString()}</pubDate>`,
|
|
58
|
+
...(category === null ? [] : [` <category>${escapeXml(category)}</category>`]),
|
|
59
|
+
" </item>",
|
|
60
|
+
].join("\n");
|
|
61
|
+
});
|
|
62
|
+
return [
|
|
63
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
64
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
|
65
|
+
" <channel>",
|
|
66
|
+
` <title>${escapeXml(input.channel.title)}</title>`,
|
|
67
|
+
` <link>${escapeXml(input.urls.getCanonicalUrl(input.routes.index))}</link>`,
|
|
68
|
+
` <description>${escapeXml(input.channel.description)}</description>`,
|
|
69
|
+
` <language>${escapeXml(input.channel.language)}</language>`,
|
|
70
|
+
` <atom:link href="${escapeXml(`${input.urls.origin}${input.feedPath}`)}" rel="self" type="application/rss+xml"/>`,
|
|
71
|
+
...(lastModified === null ? [] : [` <lastBuildDate>${lastModified.toUTCString()}</lastBuildDate>`]),
|
|
72
|
+
...items,
|
|
73
|
+
" </channel>",
|
|
74
|
+
"</rss>",
|
|
75
|
+
"",
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The blog's entries for `@softure-ai/seo`'s sitemap (FIRE_TRACKER `src/lib/blog-discovery.ts`). Paths
|
|
2
|
+
// only: seo makes them absolute on its site origin with its canonical rule.
|
|
3
|
+
import type { BlogArticle } from "../contract.js";
|
|
4
|
+
import { getTermPath, getArticlePath, type BlogRoutes } from "../pages/paths.js";
|
|
5
|
+
import { getLatestModified, getTextLastModified } from "./dates.js";
|
|
6
|
+
|
|
7
|
+
/** The shape of seo's `SitemapEntry`, written out so the blog needs no seo import. */
|
|
8
|
+
export interface BlogSitemapEntry {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly lastModified?: Date;
|
|
11
|
+
readonly priority: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SitemapText = Pick<BlogArticle, "slug" | "isPillar" | "publishedAt" | "updatedAt">;
|
|
15
|
+
|
|
16
|
+
export interface BlogSitemapInput {
|
|
17
|
+
/** Published articles. */
|
|
18
|
+
readonly articles: readonly SitemapText[];
|
|
19
|
+
/** Published glossary terms. */
|
|
20
|
+
readonly terms: readonly SitemapText[];
|
|
21
|
+
readonly routes: BlogRoutes;
|
|
22
|
+
/** The method page's path when the app mounts it, else `null`. */
|
|
23
|
+
readonly methodPath: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HUB_PRIORITY = 0.7;
|
|
27
|
+
const PILLAR_PRIORITY = 0.7;
|
|
28
|
+
const ARTICLE_PRIORITY = 0.6;
|
|
29
|
+
const GLOSSARY_PRIORITY = 0.5;
|
|
30
|
+
const METHOD_PRIORITY = 0.3;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The listing and its articles, the glossary and its terms, and the method page. **An empty list adds
|
|
34
|
+
* no hub**: an empty listing or glossary is `noindex`, and a sitemap pointing at a page kept out of the
|
|
35
|
+
* index is a contradicting signal. The method page has no content date, so it has no `lastModified`.
|
|
36
|
+
*/
|
|
37
|
+
export function getBlogSitemapEntries({ articles, terms, routes, methodPath }: BlogSitemapInput): BlogSitemapEntry[] {
|
|
38
|
+
const entries: BlogSitemapEntry[] = [];
|
|
39
|
+
const listingModified = getLatestModified(articles);
|
|
40
|
+
if (listingModified !== null) {
|
|
41
|
+
entries.push(
|
|
42
|
+
{ path: routes.index, lastModified: listingModified, priority: HUB_PRIORITY },
|
|
43
|
+
...articles.map((article) => ({
|
|
44
|
+
path: getArticlePath(routes, article.slug),
|
|
45
|
+
lastModified: getTextLastModified(article),
|
|
46
|
+
priority: article.isPillar ? PILLAR_PRIORITY : ARTICLE_PRIORITY,
|
|
47
|
+
})),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const glossaryModified = getLatestModified(terms);
|
|
51
|
+
if (glossaryModified !== null) {
|
|
52
|
+
entries.push(
|
|
53
|
+
{ path: routes.glossary, lastModified: glossaryModified, priority: GLOSSARY_PRIORITY },
|
|
54
|
+
...terms.map((term) => ({ path: getTermPath(routes, term.slug), lastModified: getTextLastModified(term), priority: GLOSSARY_PRIORITY })),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (methodPath !== null) entries.push({ path: methodPath, priority: METHOD_PRIORITY });
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The IndexNow submit after a publish: the CLI calls it after `softure-blog publish`, and an app with
|
|
2
|
+
// its own publishing path calls it after `runBlogPublish`. It reaches `@softure-ai/seo` through a
|
|
3
|
+
// dynamic import, and only when the config lists `seo()`: seo is an optional peer, so an app without
|
|
4
|
+
// it still runs the blog.
|
|
5
|
+
import { getModule, type SoftureConfig } from "@softure-ai/core";
|
|
6
|
+
import { getBlogRoutes } from "../server/options.js";
|
|
7
|
+
import { getIndexNowPaths, type IndexNowChange } from "./indexnow.js";
|
|
8
|
+
|
|
9
|
+
const SEO_MODULE_ID = "seo";
|
|
10
|
+
|
|
11
|
+
export type BlogIndexNowOutcome =
|
|
12
|
+
/** seo is not listed, or it has no IndexNow key: nothing is sent. */
|
|
13
|
+
| { readonly kind: "not_configured"; readonly reason: string }
|
|
14
|
+
/** The run changed no public address. */
|
|
15
|
+
| { readonly kind: "skipped" }
|
|
16
|
+
/** No commit: the URLs a commit would submit, and no request. */
|
|
17
|
+
| { readonly kind: "dry_run"; readonly endpoint: string; readonly urls: readonly string[] }
|
|
18
|
+
| { readonly kind: "submitted"; readonly status: number; readonly count: number }
|
|
19
|
+
| { readonly kind: "failed"; readonly code: string; readonly reason: string };
|
|
20
|
+
|
|
21
|
+
export interface BlogIndexNowSubmit {
|
|
22
|
+
/** The changed paths (`getIndexNowPaths`), also when nothing is sent. */
|
|
23
|
+
readonly paths: readonly string[];
|
|
24
|
+
readonly outcome: BlogIndexNowOutcome;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SubmitBlogChangesOptions {
|
|
28
|
+
/** Sends the request only when `true`, i.e. after a committed run; otherwise a dry run. */
|
|
29
|
+
readonly commit?: boolean;
|
|
30
|
+
readonly fetchImpl?: typeof fetch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Submits the addresses a publish run changed through seo's IndexNow, on seo's site origin. Refresh
|
|
35
|
+
* the app's cache first (`requestBlogRefresh`, or `revalidateTag("softure-blog", { expire: 0 })` inside
|
|
36
|
+
* Next), so a crawler that comes at once sees the new text.
|
|
37
|
+
* Never throws for an expected failure: a submit is an extra after a publish and must not undo it.
|
|
38
|
+
*/
|
|
39
|
+
export async function submitBlogChanges(config: SoftureConfig, changes: readonly IndexNowChange[], options: SubmitBlogChangesOptions = {}): Promise<BlogIndexNowSubmit> {
|
|
40
|
+
const paths = getIndexNowPaths(changes, getBlogRoutes(config));
|
|
41
|
+
const seoModule = getModule(config, SEO_MODULE_ID);
|
|
42
|
+
if (seoModule === undefined) return { paths, outcome: { kind: "not_configured", reason: "the seo module is not enabled; add seo({ indexNow: { key } }) to modules" } };
|
|
43
|
+
|
|
44
|
+
const [{ buildCanonicalUrl, resolveSeoSettings }, { submitToIndexNow }] = await Promise.all([import("@softure-ai/seo"), import("@softure-ai/seo/server")]);
|
|
45
|
+
// The seo module factory parsed its options and declares both routes.
|
|
46
|
+
const settings = resolveSeoSettings(seoModule.options as Parameters<typeof resolveSeoSettings>[0], {
|
|
47
|
+
appOrigin: config.appOrigin,
|
|
48
|
+
routes: seoModule.routes as unknown as Parameters<typeof resolveSeoSettings>[1]["routes"],
|
|
49
|
+
});
|
|
50
|
+
if (settings.indexNowKey === null) return { paths, outcome: { kind: "not_configured", reason: "seo has no IndexNow key; set seo({ indexNow: { key } })" } };
|
|
51
|
+
|
|
52
|
+
// Canonical URLs (seo's host and trailing-slash rule), so the engines learn the address the pages declare.
|
|
53
|
+
const urls = paths.map((path) => buildCanonicalUrl(path, settings));
|
|
54
|
+
const result = await submitToIndexNow(urls, {
|
|
55
|
+
key: settings.indexNowKey,
|
|
56
|
+
siteOrigin: settings.siteOrigin,
|
|
57
|
+
keyPath: settings.routes.indexNowKey,
|
|
58
|
+
commit: options.commit === true,
|
|
59
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
60
|
+
});
|
|
61
|
+
switch (result.kind) {
|
|
62
|
+
case "dry_run":
|
|
63
|
+
return { paths, outcome: { kind: "dry_run", endpoint: result.endpoint, urls: result.body.urlList } };
|
|
64
|
+
case "failed":
|
|
65
|
+
return { paths, outcome: { kind: "failed", code: result.code, reason: result.reason } };
|
|
66
|
+
default:
|
|
67
|
+
return { paths, outcome: result };
|
|
68
|
+
}
|
|
69
|
+
}
|