@softure-ai/blog 0.0.0-stage → 0.1.5
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/LICENSE +21 -0
- package/README.md +602 -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 +552 -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 +98 -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 +589 -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,251 @@
|
|
|
1
|
+
// The blog's pages, ready to mount with one line each (docs/02-module-standard.md §8):
|
|
2
|
+
//
|
|
3
|
+
// app/blog/page.tsx export { BlogIndexPage as default, generateBlogIndexMetadata as generateMetadata } from "@softure-ai/blog/next";
|
|
4
|
+
// export const dynamic = "force-dynamic";
|
|
5
|
+
// app/blog/[slug]/page.tsx export { BlogArticlePage as default, generateArticleMetadata as generateMetadata, generateBlogStaticParams as generateStaticParams } from "@softure-ai/blog/next";
|
|
6
|
+
// export const revalidate = 300;
|
|
7
|
+
// app/blog/glossary/page.tsx GlossaryIndexPage, generateGlossaryIndexMetadata; dynamic
|
|
8
|
+
// app/blog/glossary/[slug]/page.tsx GlossaryTermPage, generateTermMetadata, generateBlogStaticParams; revalidate
|
|
9
|
+
// app/blog/how-we-write/page.tsx BlogMethodPage, generateMethodMetadata (with `blog({ methodPage: true })`)
|
|
10
|
+
// app/blog/rss.xml/route.ts serveBlogRss as GET; dynamic (see discovery.ts)
|
|
11
|
+
//
|
|
12
|
+
// Next reads `dynamic` and `revalidate` statically, so they stay literals in the app's files: the
|
|
13
|
+
// listing and the glossary index render per request (a build has no database) over cached reads;
|
|
14
|
+
// articles and terms render on their first request and are kept for `revalidate` seconds (ISR).
|
|
15
|
+
// 301 and 410 are answered before the page by `@softure-ai/blog/proxy`; anything else that is not a
|
|
16
|
+
// published text of the page's kind is a 404 here.
|
|
17
|
+
import { formatMessage, getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
18
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
19
|
+
// `next/types.js`, not `next`: the root entry adds Next's globals (a read-only NODE_ENV) to every
|
|
20
|
+
// program that includes this file.
|
|
21
|
+
import type { Metadata } from "next/types.js";
|
|
22
|
+
import { notFound } from "next/navigation";
|
|
23
|
+
import type { ReactNode } from "react";
|
|
24
|
+
import type { BlogArticle } from "../contract.js";
|
|
25
|
+
import { getRelatedArticles } from "../discovery/related.js";
|
|
26
|
+
import { findArticlesLinkingTerm, renderPageBody, type RenderPageBodyOptions } from "../pages/body.js";
|
|
27
|
+
import { getArticleDates } from "../pages/dates.js";
|
|
28
|
+
import { getArticleJsonLd, getGlossaryJsonLd, getTermJsonLd, serializeJsonLd, type JsonLdContext } from "../pages/json-ld.js";
|
|
29
|
+
import { getArticleCrumbs, getClusterLabel, getTermCrumbs, groupByCluster, sortTerms, type CrumbLabels } from "../pages/listing.js";
|
|
30
|
+
import { getArticlePath, getTermPath } from "../pages/paths.js";
|
|
31
|
+
import { toGlossary } from "../render/glossary.js";
|
|
32
|
+
import { getBlogOptions } from "../server/options.js";
|
|
33
|
+
import { BlogArticleView } from "../ui/blog-article.js";
|
|
34
|
+
import { GlossaryIndexView, GlossaryTermView } from "../ui/blog-glossary.js";
|
|
35
|
+
import { BlogListingView } from "../ui/blog-listing.js";
|
|
36
|
+
import { BlogMethodView } from "../ui/blog-method.js";
|
|
37
|
+
import type { BlogPageContext } from "../ui/page-context.js";
|
|
38
|
+
import { getPageContext } from "./context.js";
|
|
39
|
+
import { getPublishedArticles, getPublishedTerms, getTextBySlug } from "./data.js";
|
|
40
|
+
|
|
41
|
+
type SlugParams = Promise<{ readonly slug: string }>;
|
|
42
|
+
|
|
43
|
+
export interface BlogIndexPageProps {
|
|
44
|
+
/** The app's call to action under the cards. */
|
|
45
|
+
readonly cta?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BlogArticlePageProps {
|
|
49
|
+
readonly params: SlugParams;
|
|
50
|
+
/** The app's call to action right after the text. */
|
|
51
|
+
readonly cta?: ReactNode;
|
|
52
|
+
/** The app's block under the article, e.g. `<Waitlist placement="blog" />`. */
|
|
53
|
+
readonly afterArticle?: ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GlossaryTermPageProps {
|
|
57
|
+
readonly params: SlugParams;
|
|
58
|
+
readonly cta?: ReactNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** No page is built ahead: the build has no database. The first request renders a slug. */
|
|
62
|
+
export function generateBlogStaticParams(): { slug: string }[] {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isPublished(text: BlogArticle | null, kind: BlogArticle["kind"]): text is BlogArticle {
|
|
67
|
+
return text !== null && text.status === "published" && text.kind === kind;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getCrumbLabels(config: SoftureConfig, context: BlogPageContext): CrumbLabels {
|
|
71
|
+
const clusters = getBlogOptions(config).clusters;
|
|
72
|
+
return {
|
|
73
|
+
blog: context.messages.pages.blogTitle,
|
|
74
|
+
glossary: context.messages.glossary.title,
|
|
75
|
+
cluster: (cluster) => getClusterLabel(cluster, clusters, config.locale),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getJsonLdContext(config: SoftureConfig, context: BlogPageContext): JsonLdContext {
|
|
80
|
+
return { urls: getSiteUrls(config), routes: context.routes, locale: config.locale, timezone: config.timezone, brand: context.brand };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getBodyOptions(config: SoftureConfig, context: BlogPageContext, terms: readonly BlogArticle[]): RenderPageBodyOptions {
|
|
84
|
+
const origins = [config.appOrigin, getSiteUrls(config).origin];
|
|
85
|
+
return { glossary: toGlossary(terms), routes: context.routes, options: getBlogOptions(config), origins, messages: context.messages };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function withBrand(title: string, context: BlogPageContext): string {
|
|
89
|
+
return context.brand === null ? title : formatMessage(context.messages.pages.titleWithBrand, { title, brand: context.brand });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A page's canonical URL: seo's host and trailing-slash rule when the app lists seo, else on `appOrigin`. */
|
|
93
|
+
function getCanonicalUrl(config: SoftureConfig, path: string): string {
|
|
94
|
+
return getSiteUrls(config).getCanonicalUrl(path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The feed link a reader finds in `<head>` (`<link rel="alternate" type="application/rss+xml">`); a file, so no trailing-slash rule. */
|
|
98
|
+
function getFeedAlternates(config: SoftureConfig, context: BlogPageContext): NonNullable<Metadata["alternates"]>["types"] {
|
|
99
|
+
return { "application/rss+xml": [{ url: `${getSiteUrls(config).origin}${context.routes.rss}`, title: withBrand(context.messages.pages.blogTitle, context) }] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Metadata of a page with a fixed path; an empty listing stays out of the index (thin content). */
|
|
103
|
+
function getStaticMetadata(config: SoftureConfig, context: BlogPageContext, page: { title: string; description: string; path: string; isEmpty?: boolean; hasFeed?: boolean }): Metadata {
|
|
104
|
+
return {
|
|
105
|
+
title: withBrand(page.title, context),
|
|
106
|
+
description: page.description,
|
|
107
|
+
robots: { index: page.isEmpty !== true, follow: true },
|
|
108
|
+
alternates: { canonical: getCanonicalUrl(config, page.path), ...(page.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
109
|
+
openGraph: { type: "website", title: page.title, description: page.description, url: getCanonicalUrl(config, page.path), ...(context.brand === null ? {} : { siteName: context.brand }) },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getTextMetadata(config: SoftureConfig, context: BlogPageContext, text: BlogArticle, path: string, options: { hasFeed?: boolean } = {}): Metadata {
|
|
114
|
+
const dates = getArticleDates(text, config.timezone);
|
|
115
|
+
const url = getCanonicalUrl(config, path);
|
|
116
|
+
return {
|
|
117
|
+
title: withBrand(text.title, context),
|
|
118
|
+
description: text.description,
|
|
119
|
+
robots: { index: true, follow: true },
|
|
120
|
+
alternates: { canonical: url, ...(options.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
121
|
+
openGraph: {
|
|
122
|
+
type: "article",
|
|
123
|
+
title: text.title,
|
|
124
|
+
description: text.description,
|
|
125
|
+
url,
|
|
126
|
+
locale: config.locale,
|
|
127
|
+
publishedTime: dates.published,
|
|
128
|
+
modifiedTime: dates.updated ?? dates.published,
|
|
129
|
+
...(context.brand === null ? {} : { siteName: context.brand }),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function generateBlogIndexMetadata(): Promise<Metadata> {
|
|
135
|
+
const config = getSoftureConfig();
|
|
136
|
+
const context = getPageContext(config);
|
|
137
|
+
const articles = await getPublishedArticles(config);
|
|
138
|
+
const copy = context.messages.pages;
|
|
139
|
+
return getStaticMetadata(config, context, { title: copy.blogTitle, description: copy.blogDescription, path: context.routes.index, isEmpty: articles.length === 0, hasFeed: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The listing: cards grouped by cluster, the pillar first. Mount with `dynamic = "force-dynamic"`. */
|
|
143
|
+
export async function BlogIndexPage({ cta }: BlogIndexPageProps = {}) {
|
|
144
|
+
const config = getSoftureConfig();
|
|
145
|
+
const context = getPageContext(config);
|
|
146
|
+
const labels = getCrumbLabels(config, context);
|
|
147
|
+
const [articles, terms] = await Promise.all([getPublishedArticles(config), getPublishedTerms(config)]);
|
|
148
|
+
const groups = groupByCluster(articles, labels.cluster, context.messages.pages.otherCluster);
|
|
149
|
+
return <BlogListingView context={context} groups={groups} termCount={terms.length} timezone={config.timezone} cta={cta} />;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function generateArticleMetadata({ params }: { readonly params: SlugParams }): Promise<Metadata> {
|
|
153
|
+
const config = getSoftureConfig();
|
|
154
|
+
const { slug } = await params;
|
|
155
|
+
const article = await getTextBySlug(config, slug);
|
|
156
|
+
if (!isPublished(article, "article")) return {};
|
|
157
|
+
const context = getPageContext(config);
|
|
158
|
+
return getTextMetadata(config, context, article, getArticlePath(context.routes, article.slug), { hasFeed: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An article with "read next" under it. Mount with `revalidate` and `generateBlogStaticParams`. */
|
|
162
|
+
export async function BlogArticlePage({ params, cta, afterArticle }: BlogArticlePageProps) {
|
|
163
|
+
const config = getSoftureConfig();
|
|
164
|
+
const { slug } = await params;
|
|
165
|
+
const article = await getTextBySlug(config, slug);
|
|
166
|
+
if (!isPublished(article, "article")) notFound();
|
|
167
|
+
const context = getPageContext(config);
|
|
168
|
+
const [terms, published] = await Promise.all([getPublishedTerms(config), getPublishedArticles(config)]);
|
|
169
|
+
const body = renderPageBody<ReactNode>(article, getBodyOptions(config, context, terms));
|
|
170
|
+
const crumbs = getArticleCrumbs(article, context.routes, getCrumbLabels(config, context));
|
|
171
|
+
const jsonLd = serializeJsonLd(getArticleJsonLd(article, crumbs, getJsonLdContext(config, context)));
|
|
172
|
+
return (
|
|
173
|
+
<BlogArticleView
|
|
174
|
+
context={context}
|
|
175
|
+
article={article}
|
|
176
|
+
dates={getArticleDates(article, config.timezone)}
|
|
177
|
+
body={body}
|
|
178
|
+
crumbs={crumbs}
|
|
179
|
+
jsonLd={jsonLd}
|
|
180
|
+
cta={cta}
|
|
181
|
+
afterArticle={afterArticle}
|
|
182
|
+
related={getRelatedArticles(article, published)}
|
|
183
|
+
/>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function generateGlossaryIndexMetadata(): Promise<Metadata> {
|
|
188
|
+
const config = getSoftureConfig();
|
|
189
|
+
const context = getPageContext(config);
|
|
190
|
+
const terms = await getPublishedTerms(config);
|
|
191
|
+
const copy = context.messages.glossary;
|
|
192
|
+
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.glossary, isEmpty: terms.length === 0 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The glossary index. Mount with `dynamic = "force-dynamic"`. */
|
|
196
|
+
export async function GlossaryIndexPage() {
|
|
197
|
+
const config = getSoftureConfig();
|
|
198
|
+
const context = getPageContext(config);
|
|
199
|
+
const terms = sortTerms(await getPublishedTerms(config), config.locale);
|
|
200
|
+
const jsonLd = terms.length === 0 ? null : serializeJsonLd(getGlossaryJsonLd(terms, getJsonLdContext(config, context), context.messages.glossary.title));
|
|
201
|
+
return <GlossaryIndexView context={context} terms={terms} jsonLd={jsonLd} />;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function generateTermMetadata({ params }: { readonly params: SlugParams }): Promise<Metadata> {
|
|
205
|
+
const config = getSoftureConfig();
|
|
206
|
+
const { slug } = await params;
|
|
207
|
+
const term = await getTextBySlug(config, slug);
|
|
208
|
+
if (!isPublished(term, "term")) return {};
|
|
209
|
+
const context = getPageContext(config);
|
|
210
|
+
return getTextMetadata(config, context, term, getTermPath(context.routes, term.slug));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A glossary term with the articles that expand on it. Mount with `revalidate` and `generateBlogStaticParams`. */
|
|
214
|
+
export async function GlossaryTermPage({ params, cta }: GlossaryTermPageProps) {
|
|
215
|
+
const config = getSoftureConfig();
|
|
216
|
+
const { slug } = await params;
|
|
217
|
+
const term = await getTextBySlug(config, slug);
|
|
218
|
+
if (!isPublished(term, "term")) notFound();
|
|
219
|
+
const context = getPageContext(config);
|
|
220
|
+
const [terms, articles] = await Promise.all([getPublishedTerms(config), getPublishedArticles(config)]);
|
|
221
|
+
const bodyOptions = getBodyOptions(config, context, terms);
|
|
222
|
+
const crumbs = getTermCrumbs(term, context.routes, getCrumbLabels(config, context));
|
|
223
|
+
const jsonLd = serializeJsonLd(getTermJsonLd(term, crumbs, getJsonLdContext(config, context), context.messages.glossary.title));
|
|
224
|
+
return (
|
|
225
|
+
<GlossaryTermView
|
|
226
|
+
context={context}
|
|
227
|
+
term={term}
|
|
228
|
+
dates={getArticleDates(term, config.timezone)}
|
|
229
|
+
body={renderPageBody<ReactNode>(term, bodyOptions)}
|
|
230
|
+
crumbs={crumbs}
|
|
231
|
+
jsonLd={jsonLd}
|
|
232
|
+
articles={findArticlesLinkingTerm(articles, term.slug, bodyOptions)}
|
|
233
|
+
cta={cta}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function generateMethodMetadata(): Metadata {
|
|
239
|
+
const config = getSoftureConfig();
|
|
240
|
+
const context = getPageContext(config);
|
|
241
|
+
const copy = context.messages.method;
|
|
242
|
+
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.method });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** "How our texts are made". Mount it with `blog({ methodPage: true })`; without that it is a 404. */
|
|
246
|
+
export function BlogMethodPage() {
|
|
247
|
+
const config = getSoftureConfig();
|
|
248
|
+
const context = getPageContext(config);
|
|
249
|
+
if (context.methodPath === null) notFound();
|
|
250
|
+
return <BlogMethodView context={context} />;
|
|
251
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The cache refresh route: `softure-blog publish --commit` calls it so the running app shows the change
|
|
2
|
+
// at once instead of after `revalidateSeconds`. Mount it in app/api/blog/refresh/route.ts:
|
|
3
|
+
//
|
|
4
|
+
// export { refreshBlogCache as POST } from "@softure-ai/blog/next";
|
|
5
|
+
//
|
|
6
|
+
// The caller is the command, not a person: no session, so the route checks a Bearer secret from the
|
|
7
|
+
// environment (BLOG_REFRESH_SECRET). Order, as in mcp-access: identify the client, count the
|
|
8
|
+
// request, check the secret, then expire the tag. Each refusal happens before the next step costs
|
|
9
|
+
// anything. security is an optional dependency of the blog, reached through a dynamic import.
|
|
10
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { errorLogLabel, getModule, type SoftureConfig } from "@softure-ai/core";
|
|
12
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
13
|
+
import { revalidateTag } from "next/cache";
|
|
14
|
+
import { BLOG_REFRESH_RATE_LIMIT_BUCKET, BLOG_REFRESH_SECRET_ENV, MIN_REFRESH_SECRET_LENGTH } from "../discovery/refresh.js";
|
|
15
|
+
import { getBlogContext } from "./context.js";
|
|
16
|
+
import { BLOG_CACHE_TAG } from "./data.js";
|
|
17
|
+
|
|
18
|
+
const SECURITY_MODULE_ID = "security";
|
|
19
|
+
/**
|
|
20
|
+
* The one rate limit key of every caller `identifyClient` cannot place: the command often calls the
|
|
21
|
+
* app on a private name (`--app-url http://web:3000`), past the proxy that sets the client header.
|
|
22
|
+
*/
|
|
23
|
+
const UNIDENTIFIED_CLIENT_KEY = "unidentified";
|
|
24
|
+
const BEARER_PREFIX = /^Bearer[ \t]+/i;
|
|
25
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
26
|
+
|
|
27
|
+
function answer(status: number, headers: Readonly<Record<string, string>> = {}): Response {
|
|
28
|
+
return new Response(null, { status, headers: { ...NO_STORE, ...headers } });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `POST <routes.refresh>`: expires every cached read of the blog, so the next request reads the
|
|
33
|
+
* tables again. Answers 204 refreshed, 401 for a missing or wrong secret, 429 over the `blog-refresh`
|
|
34
|
+
* bucket (per client address; callers without one share a single count), 503 when counting fails,
|
|
35
|
+
* 500 when the secret is not set or shorter than 32 characters. A missing security module or bucket
|
|
36
|
+
* is a setup error, thrown.
|
|
37
|
+
*/
|
|
38
|
+
export async function refreshBlogCache(request: Request): Promise<Response> {
|
|
39
|
+
const secret = (process.env[BLOG_REFRESH_SECRET_ENV] ?? "").trim();
|
|
40
|
+
if (secret.length < MIN_REFRESH_SECRET_LENGTH) {
|
|
41
|
+
console.error(`@softure-ai/blog: the cache refresh route needs ${BLOG_REFRESH_SECRET_ENV} of at least ${String(MIN_REFRESH_SECRET_LENGTH)} characters`);
|
|
42
|
+
return answer(500);
|
|
43
|
+
}
|
|
44
|
+
const config = getSoftureConfig();
|
|
45
|
+
assertRateLimitBucket(config);
|
|
46
|
+
const { consumeRateLimit, identifyClient } = await import("@softure-ai/security/server");
|
|
47
|
+
|
|
48
|
+
const client = identifyClient({ config }, request.headers);
|
|
49
|
+
let limit;
|
|
50
|
+
try {
|
|
51
|
+
// Counted before the secret is checked, so a flood of guesses is stopped first.
|
|
52
|
+
limit = await consumeRateLimit(await getBlogContext(config), { bucket: BLOG_REFRESH_RATE_LIMIT_BUCKET, key: client.ok ? client.value : UNIDENTIFIED_CLIENT_KEY });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Fails closed and says nothing: the route is public and this runs before authentication.
|
|
55
|
+
console.error(`@softure-ai/blog: counting a cache refresh failed: ${errorLogLabel(error)}`);
|
|
56
|
+
return answer(503);
|
|
57
|
+
}
|
|
58
|
+
if (!limit.ok) return answer(429, { "retry-after": String(limit.retryAfterSeconds) });
|
|
59
|
+
|
|
60
|
+
if (!hasSecret(request.headers.get("authorization"), secret)) return answer(401, { "www-authenticate": "Bearer" });
|
|
61
|
+
// `expire: 0`: the next request waits for fresh reads. The "max" profile would serve the old page once more.
|
|
62
|
+
revalidateTag(BLOG_CACHE_TAG, { expire: 0 });
|
|
63
|
+
return answer(204);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Compares digests in constant time, so the answer time says nothing about the secret's length or prefix. */
|
|
67
|
+
function hasSecret(header: string | null, secret: string): boolean {
|
|
68
|
+
if (header === null || !BEARER_PREFIX.test(header)) return false;
|
|
69
|
+
const given = createHash("sha256").update(header.replace(BEARER_PREFIX, "").trim()).digest();
|
|
70
|
+
return timingSafeEqual(given, createHash("sha256").update(secret).digest());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A missing module or bucket is a setup bug: thrown, so it reaches the log by name instead of a quiet 503. */
|
|
74
|
+
function assertRateLimitBucket(config: SoftureConfig): void {
|
|
75
|
+
const security = getModule(config, SECURITY_MODULE_ID);
|
|
76
|
+
if (security === undefined) {
|
|
77
|
+
throw new Error("@softure-ai/blog: the cache refresh route is rate-limited by the security module; add security() to modules");
|
|
78
|
+
}
|
|
79
|
+
const options = security.options as { buckets?: Record<string, unknown> } | undefined;
|
|
80
|
+
if (options?.buckets?.[BLOG_REFRESH_RATE_LIMIT_BUCKET] === undefined) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`@softure-ai/blog: the security module has no "${BLOG_REFRESH_RATE_LIMIT_BUCKET}" bucket; spread BLOG_RATE_LIMIT_BUCKETS into security({ buckets })`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The options an app passes to `blog({ ... })` in softure.config.ts, parsed at startup.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { qualitySettingSchema } from "./quality/options.js";
|
|
4
|
+
import type { ArticleImagePolicy } from "./render/images.js";
|
|
5
|
+
import type { BlockPlugin } from "./render/render-article.js";
|
|
6
|
+
|
|
7
|
+
/** Where the app keeps its article files unless a command names a path. */
|
|
8
|
+
export const DEFAULT_CONTENT_DIR = "content/blog";
|
|
9
|
+
|
|
10
|
+
/** The frontmatter keys of the module; the app's `fields` may not reuse them. */
|
|
11
|
+
export const FRONTMATTER_KEYS = [
|
|
12
|
+
"id",
|
|
13
|
+
"slug",
|
|
14
|
+
"kind",
|
|
15
|
+
"cluster",
|
|
16
|
+
"pillar",
|
|
17
|
+
"title",
|
|
18
|
+
"description",
|
|
19
|
+
"summary",
|
|
20
|
+
"status",
|
|
21
|
+
"current_as_of",
|
|
22
|
+
"published_at",
|
|
23
|
+
"sources",
|
|
24
|
+
"faq",
|
|
25
|
+
"forms",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/** What a schema's `safeParse` reports; zod 3 and 4 both fit. */
|
|
29
|
+
export type BlogFieldsParseResult =
|
|
30
|
+
| { readonly success: true; readonly data: unknown }
|
|
31
|
+
| { readonly success: false; readonly error: { readonly issues: readonly { readonly path: readonly PropertyKey[]; readonly message: string }[] } };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The app's own frontmatter fields: an object schema from the app's `zod`, e.g.
|
|
35
|
+
* `z.object({ scenario: z.string().optional() })`. Its keys join the frontmatter; the parsed value
|
|
36
|
+
* is stored with the article and enters its content hash. It must parse to plain JSON values.
|
|
37
|
+
*/
|
|
38
|
+
export interface BlogFieldsSchema {
|
|
39
|
+
readonly shape: Readonly<Record<string, unknown>>;
|
|
40
|
+
readonly safeParse: (value: unknown) => BlogFieldsParseResult;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
44
|
+
|
|
45
|
+
function isFieldsSchema(value: unknown): value is BlogFieldsSchema {
|
|
46
|
+
if (typeof value !== "object" || value === null) return false;
|
|
47
|
+
const candidate = value as { shape?: unknown; safeParse?: unknown };
|
|
48
|
+
return typeof candidate.shape === "object" && candidate.shape !== null && typeof candidate.safeParse === "function";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBlockPlugin(value: unknown): value is BlockPlugin {
|
|
52
|
+
if (typeof value !== "object" || value === null) return false;
|
|
53
|
+
const candidate = value as { type?: unknown; render?: unknown };
|
|
54
|
+
return typeof candidate.type === "string" && KEBAB.test(candidate.type) && typeof candidate.render === "function";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How long the pages cache their reads by default, in seconds; the app's `revalidate` should match. */
|
|
58
|
+
export const DEFAULT_REVALIDATE_SECONDS = 300;
|
|
59
|
+
|
|
60
|
+
/** Text the app writes per locale; `en` is the fallback for a locale it leaves out. */
|
|
61
|
+
const localizedTextSchema = z.strictObject({
|
|
62
|
+
en: z.string().trim().min(1),
|
|
63
|
+
pl: z.string().trim().min(1).optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type LocalizedText = z.output<typeof localizedTextSchema>;
|
|
67
|
+
|
|
68
|
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
69
|
+
const colorSchema = z.string().regex(HEX_COLOR, "must be a six-digit hex colour, e.g. #0c0c0d");
|
|
70
|
+
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
71
|
+
|
|
72
|
+
/** The weights Satori (`next/og`) draws: static fonts only, in steps of 100. */
|
|
73
|
+
export const OG_FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
|
|
74
|
+
|
|
75
|
+
export type OgFontWeight = (typeof OG_FONT_WEIGHTS)[number];
|
|
76
|
+
|
|
77
|
+
const WOFF2 = /\.woff2$/i;
|
|
78
|
+
|
|
79
|
+
/** The part of a font source that names the file: a URL's path, or the path as written. */
|
|
80
|
+
function getSourceFileName(src: string): string {
|
|
81
|
+
return src.startsWith("https://") ? new URL(src).pathname : src;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isFontSource(src: string): boolean {
|
|
85
|
+
if (src.startsWith("https://")) return URL.canParse(src);
|
|
86
|
+
return !/^[a-z][a-z0-9+.-]*:\/\//i.test(src);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ogFontSchema = z.strictObject({
|
|
90
|
+
/** The family name the card writes in; a second file of one weight and style needs its own name. */
|
|
91
|
+
name: z.string().trim().min(1).max(80),
|
|
92
|
+
weight: z
|
|
93
|
+
.custom<OgFontWeight>((value) => OG_FONT_WEIGHTS.some((weight) => weight === value), "must be a weight from 100 to 900 in steps of 100")
|
|
94
|
+
.default(400),
|
|
95
|
+
style: z.enum(["normal", "italic"]).default("normal"),
|
|
96
|
+
/** A .ttf, .otf or .woff file: an https URL, an absolute path, or a path from the app's root. */
|
|
97
|
+
src: z
|
|
98
|
+
.string()
|
|
99
|
+
.trim()
|
|
100
|
+
.min(1)
|
|
101
|
+
.refine(isFontSource, "must be an https URL or a file path, e.g. fonts/inter-700.woff")
|
|
102
|
+
.refine((src) => !isFontSource(src) || !WOFF2.test(getSourceFileName(src)), "must be a .ttf, .otf or .woff file; the card cannot read .woff2"),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export type OgFontSource = z.output<typeof ogFontSchema>;
|
|
106
|
+
|
|
107
|
+
const brandSchema = z.strictObject({
|
|
108
|
+
/** The site's name: in page titles, as the author and publisher in JSON-LD, on the OG card. */
|
|
109
|
+
name: z.string().trim().min(1).max(80),
|
|
110
|
+
/** The OG card's colours; each defaults to the dark scheme of @softure-ai/ui's default theme. */
|
|
111
|
+
colors: z
|
|
112
|
+
.strictObject({ background: colorSchema.optional(), foreground: colorSchema.optional(), accent: colorSchema.optional() })
|
|
113
|
+
.optional(),
|
|
114
|
+
/**
|
|
115
|
+
* The OG card's fonts, read by the card's route on its first render and kept for the life of the
|
|
116
|
+
* process; without them the card uses `next/og`'s default font.
|
|
117
|
+
*/
|
|
118
|
+
fonts: z
|
|
119
|
+
.array(ogFontSchema)
|
|
120
|
+
.min(1, "must list at least one font")
|
|
121
|
+
.superRefine((fonts, ctx) => {
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
for (const [index, font] of fonts.entries()) {
|
|
124
|
+
const key = `"${font.name}" ${font.weight} ${font.style}`;
|
|
125
|
+
if (seen.has(key)) {
|
|
126
|
+
ctx.addIssue({ code: "custom", path: [index], message: `${key} is listed twice; give a second file its own name, e.g. "${font.name} Ext"` });
|
|
127
|
+
}
|
|
128
|
+
seen.add(key);
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
.optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** Whether a Markdown body holds a `#` or `##` heading outside fenced code. */
|
|
135
|
+
function hasTopHeading(body: string): boolean {
|
|
136
|
+
let fence: string | null = null;
|
|
137
|
+
for (const line of body.split(/\r?\n/)) {
|
|
138
|
+
const trimmed = line.trimStart();
|
|
139
|
+
const marker = trimmed.startsWith("```") ? "```" : trimmed.startsWith("~~~") ? "~~~" : null;
|
|
140
|
+
if (marker !== null) fence = fence === null ? marker : fence === marker ? null : fence;
|
|
141
|
+
else if (fence === null && /^ {0,3}#{1,2}(?:[ \t]|$)/.test(line)) return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One section of the app's own in the writing skill: `## <title>` and the Markdown body, verbatim. */
|
|
147
|
+
const skillSectionSchema = z.strictObject({
|
|
148
|
+
title: z
|
|
149
|
+
.string()
|
|
150
|
+
.trim()
|
|
151
|
+
.min(1)
|
|
152
|
+
.max(80)
|
|
153
|
+
.refine((title) => !/[\r\n]/.test(title), "must be one line"),
|
|
154
|
+
body: z
|
|
155
|
+
.string()
|
|
156
|
+
.trim()
|
|
157
|
+
.min(1)
|
|
158
|
+
.refine((body) => !hasTopHeading(body), "must not hold a # or ## heading; use ### and deeper (the title is the section's ## heading)"),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const skillSchema = z.strictObject({
|
|
162
|
+
/** The app's own sections (its numbers, block plugins, fields), written to `references/app.md` of the skill. */
|
|
163
|
+
sections: z.array(skillSectionSchema).superRefine((sections, ctx) => {
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
for (const [index, section] of sections.entries()) {
|
|
166
|
+
if (seen.has(section.title)) ctx.addIssue({ code: "custom", path: [index, "title"], message: `"${section.title}" is the title of another section` });
|
|
167
|
+
seen.add(section.title);
|
|
168
|
+
}
|
|
169
|
+
}).default([]),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export type BlogSkillSection = z.output<typeof skillSectionSchema>;
|
|
173
|
+
|
|
174
|
+
const imagePolicySchema = z.strictObject({
|
|
175
|
+
/** Hosts whose https images are allowed besides the site's own paths (subdomains included). */
|
|
176
|
+
hosts: z.array(z.string().regex(HOSTNAME, "must be a host name, e.g. cdn.example.com")).readonly().default([]),
|
|
177
|
+
/** `(src) => ({ width, height })` in pixels, or `null` for an unknown image. */
|
|
178
|
+
dimensions: z.custom<ArticleImagePolicy["dimensions"]>((value) => typeof value === "function", "must be a function: (src) => ({ width, height }) or null"),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export const blogOptionsSchema = z
|
|
182
|
+
.strictObject({
|
|
183
|
+
/** The folder with the article files, relative to the app's root. */
|
|
184
|
+
contentDir: z.string().trim().min(1).default(DEFAULT_CONTENT_DIR),
|
|
185
|
+
/**
|
|
186
|
+
* Slugs taken by static pages under the blog's path (a glossary index, a method page). A static
|
|
187
|
+
* route wins over the article route, so an article with such a slug would be unreachable.
|
|
188
|
+
*/
|
|
189
|
+
reservedSlugs: z.array(z.string().max(100).regex(KEBAB, "must be kebab-case, e.g. how-we-write")).default([]),
|
|
190
|
+
fields: z.custom<BlogFieldsSchema>(isFieldsSchema, "must be an object schema, e.g. z.object({ scenario: z.string() })").optional(),
|
|
191
|
+
/** The site's brand for titles, JSON-LD and the OG card; without it pages carry no brand. */
|
|
192
|
+
brand: brandSchema.optional(),
|
|
193
|
+
/** Whether the app mounts the "how our texts are made" page (`BlogMethodPage` at `routes.method`). */
|
|
194
|
+
methodPage: z.boolean().default(false),
|
|
195
|
+
/** A note under every text (not advice, not a recommendation…); none by default. */
|
|
196
|
+
disclaimer: localizedTextSchema.optional(),
|
|
197
|
+
/** Display names of clusters by key; a cluster without one shows its key with spaces. */
|
|
198
|
+
clusters: z.record(z.string().regex(KEBAB, "must be kebab-case, e.g. investing-basics"), localizedTextSchema).default({}),
|
|
199
|
+
/** Block plugins for the app's fenced blocks (`renderArticle({ blocks })`), used by the pages. */
|
|
200
|
+
blocks: z.array(z.custom<BlockPlugin>(isBlockPlugin, "must be a block plugin: { type: \"chart\", render(block) }")).default([]),
|
|
201
|
+
/** Hosts besides those of `appOrigin` and the canonical site origin (`getSiteUrls`) whose links are not marked external (subdomains included). */
|
|
202
|
+
siteHosts: z.array(z.string().regex(HOSTNAME, "must be a host name, e.g. example.com")).default([]),
|
|
203
|
+
/**
|
|
204
|
+
* Which images article bodies may show (`renderArticle({ images })`), used by the pages and the
|
|
205
|
+
* quality gate; without it every image renders as its alt text and the gate refuses it.
|
|
206
|
+
*/
|
|
207
|
+
images: imagePolicySchema.optional(),
|
|
208
|
+
/** How long the listing and glossary cache their reads; keep it equal to the pages' `revalidate`. */
|
|
209
|
+
revalidateSeconds: z.number().int().min(1).default(DEFAULT_REVALIDATE_SECONDS),
|
|
210
|
+
/** The text quality gate (`softure-blog check`, and every publish); `false` turns it off. */
|
|
211
|
+
quality: qualitySettingSchema,
|
|
212
|
+
/** What `softure-blog skill install` adds to the generated writing skill. */
|
|
213
|
+
skill: skillSchema.default({ sections: [] }),
|
|
214
|
+
})
|
|
215
|
+
.superRefine((options, ctx) => {
|
|
216
|
+
for (const key of Object.keys(options.fields?.shape ?? {})) {
|
|
217
|
+
if ((FRONTMATTER_KEYS as readonly string[]).includes(key)) {
|
|
218
|
+
ctx.addIssue({ code: "custom", path: ["fields", key], message: `"${key}" is a frontmatter key of the module` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export type BlogOptionsInput = z.input<typeof blogOptionsSchema>;
|
|
224
|
+
export type BlogOptions = z.output<typeof blogOptionsSchema>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A stored text's body rendered for its page: the renderer with the app's glossary, block plugins,
|
|
2
|
+
// hosts and image policy, and the module's copy. A term page passes the term itself, so it never links
|
|
3
|
+
// to itself.
|
|
4
|
+
import type { BlogArticle } from "../contract.js";
|
|
5
|
+
import type { BlogMessages } from "../messages/index.js";
|
|
6
|
+
import type { BlogOptions } from "../options.js";
|
|
7
|
+
import type { GlossaryTerm } from "../render/glossary.js";
|
|
8
|
+
import { renderArticle, type BlockPlugin, type RenderedArticle } from "../render/render-article.js";
|
|
9
|
+
import { getTermPath, type BlogRoutes } from "./paths.js";
|
|
10
|
+
|
|
11
|
+
export interface RenderPageBodyOptions {
|
|
12
|
+
readonly glossary: readonly GlossaryTerm[];
|
|
13
|
+
readonly routes: BlogRoutes;
|
|
14
|
+
readonly options: Pick<BlogOptions, "blocks" | "images" | "siteHosts">;
|
|
15
|
+
/** The app's own origins (`appOrigin` and the canonical site origin); their hosts are the site's (links to them are not external). */
|
|
16
|
+
readonly origins: readonly string[];
|
|
17
|
+
readonly messages: BlogMessages;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderPageBody<TNode = unknown>(text: BlogArticle, input: RenderPageBodyOptions): RenderedArticle<TNode> {
|
|
21
|
+
return renderArticle<TNode>(text.bodyMarkdown, {
|
|
22
|
+
glossary: input.glossary,
|
|
23
|
+
...(text.kind === "term" ? { selfSlug: text.slug } : {}),
|
|
24
|
+
termHref: (slug) => getTermPath(input.routes, slug),
|
|
25
|
+
siteHosts: [...input.origins.map((origin) => new URL(origin).hostname), ...input.options.siteHosts],
|
|
26
|
+
...(input.options.images === undefined ? {} : { images: input.options.images }),
|
|
27
|
+
// The options keep plugins untyped (`unknown` nodes); the pages render React nodes, the type the
|
|
28
|
+
// app's plugins return.
|
|
29
|
+
blocks: input.options.blocks as readonly BlockPlugin<TNode>[],
|
|
30
|
+
article: { currentAsOf: text.currentAsOf, fields: text.fields },
|
|
31
|
+
messages: input.messages.render,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Published articles whose body links the term, by hand or by its first mention: the same rule as the links. */
|
|
36
|
+
export function findArticlesLinkingTerm(articles: readonly BlogArticle[], termSlug: string, input: RenderPageBodyOptions): BlogArticle[] {
|
|
37
|
+
return articles.filter((article) => renderPageBody(article, input).linkedTerms.includes(termSlug));
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The days a page shows for a text, in the app's time zone. JSON-LD takes the same days, so the dates
|
|
2
|
+
// a search engine compares (`dateModified` against the visible update) cannot drift apart.
|
|
3
|
+
import type { Locale } from "@softure-ai/core";
|
|
4
|
+
import type { BlogArticle } from "../contract.js";
|
|
5
|
+
|
|
6
|
+
export interface ArticleDates {
|
|
7
|
+
/** The day of first publication, `YYYY-MM-DD`. */
|
|
8
|
+
readonly published: string;
|
|
9
|
+
/** The day of the last content change; `null` without one, or when it falls on the publication day. */
|
|
10
|
+
readonly updated: string | null;
|
|
11
|
+
/** The day the facts were checked, `YYYY-MM-DD`. */
|
|
12
|
+
readonly currentAsOf: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The calendar day of a moment in a time zone, `YYYY-MM-DD`. */
|
|
16
|
+
export function getDayInZone(moment: Date, timezone: string): string {
|
|
17
|
+
// `en-CA` formats a date as YYYY-MM-DD.
|
|
18
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(moment);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The visible dates of a published text. Two equal days one under the other read like a mistake, so
|
|
23
|
+
* "updated" disappears when it falls on the publication day. A text without a publication date has
|
|
24
|
+
* no page: calling this for one is a bug.
|
|
25
|
+
*/
|
|
26
|
+
export function getArticleDates(article: Pick<BlogArticle, "slug" | "publishedAt" | "updatedAt" | "currentAsOf">, timezone: string): ArticleDates {
|
|
27
|
+
if (article.publishedAt === null) {
|
|
28
|
+
throw new Error(`getArticleDates: article ${article.slug} has no published_at; pages show only published texts`);
|
|
29
|
+
}
|
|
30
|
+
const published = getDayInZone(article.publishedAt, timezone);
|
|
31
|
+
const updated = article.updatedAt === null ? null : getDayInZone(article.updatedAt, timezone);
|
|
32
|
+
return { published, updated: updated === published ? null : updated, currentAsOf: article.currentAsOf };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `YYYY-MM-DD` day as the locale writes it in full (en: "October 4, 2026"). */
|
|
36
|
+
export function formatDay(day: string, locale: Locale): string {
|
|
37
|
+
// The day is already a calendar day: format it at UTC midnight in UTC, so no zone shifts it.
|
|
38
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(`${day}T00:00:00Z`));
|
|
39
|
+
}
|