@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Newest first. Each version lists what changed for an app that uses `@softure-ai/blog`. When an app has run a version in
|
|
4
|
+
production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
|
|
5
|
+
"Definition of done"). Versions before the first one below are described in their GitHub Releases (`blog@x.y.z`).
|
|
6
|
+
|
|
7
|
+
## 0.1.6
|
|
8
|
+
|
|
9
|
+
- Adapters and commands use the configured database handle.
|
|
10
|
+
- `@softure-ai/ui` is a peer dependency; the package keeps its own CSS.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOFTURE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,605 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @softure-ai/blog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Articles and glossary terms kept as Markdown files in the app's repository, and a publish command that
|
|
4
|
+
brings the module's tables to the state of those files. The files are the source of truth: there is no
|
|
5
|
+
editor and no CMS, a text changes only through a commit and `softure-blog publish`.
|
|
6
|
+
|
|
7
|
+
## 1. What it provides
|
|
8
|
+
|
|
9
|
+
- A strict article file format: a YAML frontmatter with English keys (an unknown key is an error),
|
|
10
|
+
extendable by the app's own fields, then the Markdown body.
|
|
11
|
+
- `blog.articles` and `blog.slug_history` with database constraints for every invariant that fits one.
|
|
12
|
+
- `softure-blog publish`: a dry run by default; with `--commit`, all files or none; unchanged files are
|
|
13
|
+
skipped by their content hash; a slug change keeps the old slug as a redirect; one pillar per cluster.
|
|
14
|
+
- Read functions for the pages: `getPublishedArticle`, `findArticleBySlug`, `findSlugRedirect`,
|
|
15
|
+
`listArticles`.
|
|
16
|
+
- `renderArticle(markdown, options)`: the body as safe HTML on the server (no raw HTML, safe link
|
|
17
|
+
schemes only, marked external links, images under the app's image policy), heading ids and an
|
|
18
|
+
optional table of contents, glossary links on the first mention of a term, block plugins for the
|
|
19
|
+
app's own fenced blocks, reading time.
|
|
20
|
+
- Pages, each mounted with one re-export line (`@softure-ai/blog/next`): the listing grouped by cluster
|
|
21
|
+
with the pillar first, an article (dates, summary, contents, FAQ, sources, signature, disclaimer,
|
|
22
|
+
`BlogPosting`/`BreadcrumbList`/`FAQPage` JSON-LD), the glossary index and a term page (`DefinedTerm`,
|
|
23
|
+
the articles that explain it), the optional "how our texts are made" page, an article's OG card.
|
|
24
|
+
Their canonical, Open Graph, JSON-LD and feed URLs follow `@softure-ai/seo`'s origin, host and
|
|
25
|
+
trailing-slash rule when the app lists `seo()` (core's `getSiteUrls`), and `appOrigin` otherwise.
|
|
26
|
+
- `createBlogRedirects` (`@softure-ai/blog/proxy`): 301 from an old slug, 410 for a withdrawn text,
|
|
27
|
+
in the app's `proxy.ts`.
|
|
28
|
+
- `@softure-ai/blog/styles.css`: the pages and the rendered body on the `--sft-*` tokens.
|
|
29
|
+
- Discovery: an RSS 2.0 feed (`serveBlogRss`), "read next" under every article (its cluster first, the
|
|
30
|
+
pillar on top), and, with `@softure-ai/seo` (optional): sitemap entries with each text's real
|
|
31
|
+
`lastmod` (`blogSitemap()`) and an IndexNow submit of the changed addresses after
|
|
32
|
+
`softure-blog publish --commit` (`submitBlogChanges` for an app's own publishing path).
|
|
33
|
+
- A cache refresh route (`refreshBlogCache`, rate-limited through `@softure-ai/security`, a secret from
|
|
34
|
+
`BLOG_REFRESH_SECRET`): `softure-blog publish --commit` calls it before the IndexNow submit, so the
|
|
35
|
+
running app shows the change at once instead of after `revalidateSeconds`.
|
|
36
|
+
- A text quality gate: `softure-blog check` reports structure, link, style, voice and YMYL findings
|
|
37
|
+
with file and line, and `publish` refuses a text going public with an error. Language rulesets
|
|
38
|
+
(`en`, `pl`), severity overrides and rule plugins for the app's own domain.
|
|
39
|
+
- `softure-blog skill install`: an agent skill for writing the texts, generated from the gate's rules
|
|
40
|
+
and the app's config, with `--check` for CI.
|
|
41
|
+
|
|
42
|
+
## 2. Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install @softure-ai/blog @softure-ai/ui
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`@softure-ai/ui` is a peer dependency (any 0.1.x), like the optional `@softure-ai/security` and
|
|
49
|
+
`@softure-ai/seo`: the app installs it once, so the theme tokens come from one copy. Importing
|
|
50
|
+
`@softure-ai/blog/styles.css` from JavaScript is safe with tree-shaking, because the package marks its CSS as a side
|
|
51
|
+
effect.
|
|
52
|
+
|
|
53
|
+
Then add `blog()` to the modules of `softure.config.ts` and run `softure migrate`.
|
|
54
|
+
|
|
55
|
+
## 3. Configuration
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { blog } from "@softure-ai/blog";
|
|
59
|
+
import { pl } from "./messages/pl";
|
|
60
|
+
import { z } from "zod";
|
|
61
|
+
|
|
62
|
+
blog({
|
|
63
|
+
// The folder `softure-blog publish` reads when no path is given. Default: "content/blog".
|
|
64
|
+
contentDir: "content/blog",
|
|
65
|
+
// Extra slugs an article may not take. The routes' own segments (the glossary, the method page
|
|
66
|
+
// when on) are reserved by themselves. Default: none.
|
|
67
|
+
reservedSlugs: [],
|
|
68
|
+
// The app's own frontmatter keys, checked by the app's schema. Default: none.
|
|
69
|
+
fields: z.object({ scenario: z.string().regex(/^[a-z]=\d+(&[a-z]=\d+)*$/).optional() }),
|
|
70
|
+
// The pages' brand: title suffix, signature, JSON-LD author and publisher, OG card colours (hex)
|
|
71
|
+
// and fonts (§8). Default: none (no suffix, no author, the ui theme's dark colours, next/og's font).
|
|
72
|
+
brand: { name: "FIRE Tracker", colors: { background: "#0b0b0c", foreground: "#f5f5f5", accent: "#7aa2f7" } },
|
|
73
|
+
// Mount the method page at routes.method. Default: false (the route answers 404).
|
|
74
|
+
methodPage: true,
|
|
75
|
+
// A note under every article and term, per locale (en required). Default: none.
|
|
76
|
+
disclaimer: { en: "Education, not financial advice.", pl: pl.blog.disclaimer },
|
|
77
|
+
// The heading of each cluster on the listing, per locale; a missing key shows the key. Default: {}.
|
|
78
|
+
clusters: { "investing-basics": { en: "Investing basics", pl: pl.blog.investingBasics } },
|
|
79
|
+
// Block plugins of renderArticle, used by the pages. Default: [].
|
|
80
|
+
blocks: [],
|
|
81
|
+
// Hosts of the app besides APP_ORIGIN's and seo's canonical host, whose links are not external. Default: [].
|
|
82
|
+
siteHosts: ["www.example.com"],
|
|
83
|
+
// Which images bodies may show: site paths and https images on these hosts (subdomains included),
|
|
84
|
+
// with a width and height the app knows. Used by the pages and the quality gate. Default: none
|
|
85
|
+
// (every image renders as its alt text and the gate refuses it).
|
|
86
|
+
images: { hosts: ["cdn.example.com"], dimensions: (src) => imageSizes[src] ?? null },
|
|
87
|
+
// How long the cached reads hold; keep equal to the pages' `revalidate`. Default: 300.
|
|
88
|
+
revalidateSeconds: 300,
|
|
89
|
+
// The app's own sections of the generated writing skill (see "The writing skill"). Default: none.
|
|
90
|
+
skill: { sections: [] },
|
|
91
|
+
// Every route can move: blog({ routes: { index: "/articles" } }).
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Routes: `index` `/blog` (articles at `/blog/<slug>`), `glossary` `/blog/glossary` (terms at
|
|
96
|
+
`/blog/glossary/<slug>`), `method` `/blog/how-we-write`.
|
|
97
|
+
|
|
98
|
+
`fields` may not reuse a key of the module (`FRONTMATTER_KEYS`). Its parsed value is stored in
|
|
99
|
+
`articles.fields`, enters the content hash and must be plain JSON.
|
|
100
|
+
|
|
101
|
+
### The quality gate
|
|
102
|
+
|
|
103
|
+
On by default with the `en` ruleset; `quality: false` turns it off. Every key is optional:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
blog({
|
|
107
|
+
quality: {
|
|
108
|
+
language: "pl", // the ruleset: "en" (default) or "pl"
|
|
109
|
+
ymyl: { ownCalculationMark: "our calculation" }, // or true / false (default): sources, sourced numbers, no profit promises
|
|
110
|
+
voice: {
|
|
111
|
+
forbidFirstPersonSingular: true, // texts signed by the editors: no "I", "my"
|
|
112
|
+
// a global RegExp; wordPattern (from @softure-ai/blog) adds the i flag and word edges in any alphabet
|
|
113
|
+
phrases: [{ id: "finance-cliche", pattern: wordPattern("in the world of finance"), message: "say what happens instead" }],
|
|
114
|
+
},
|
|
115
|
+
limits: { words: { article: { min: 600, max: 4000 } }, answerWords: 70 }, // FIRE's values are the defaults
|
|
116
|
+
severity: { exclamation: "error", "lead-number": "off" }, // per rule: "error", "warning" or "off"
|
|
117
|
+
paths: { articles: "/blog", terms: "/blog/glossary" }, // where internal links to texts point
|
|
118
|
+
ownOrigins: ["https://www.example.com"], // absolute links that count as internal, besides appOrigin and seo's origin
|
|
119
|
+
appDir: "src/app", // routes for internal links; default src/app, else app
|
|
120
|
+
privateRouteSegments: ["api", "(app)"], // route folders that are no link target; default ["api"]
|
|
121
|
+
plugins: [factsPlugin], // the app's own rules, see Hooks
|
|
122
|
+
blocks: [chartBlock], // the block plugins of renderArticle: their requires are checked
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The rules, by group (`listQualityRules(getQualitySettings(config))` lists them with their effective
|
|
128
|
+
severity; the writing skill is kept in step with it):
|
|
129
|
+
|
|
130
|
+
| Group | Rules (errors **bold**) |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| file | **`file`**: the frontmatter parses and the slug equals the file name |
|
|
133
|
+
| structure | `title-length`, `description-length`, **`as-of-future`**, `stale`, **`summary-missing`**, **`lead`** (a paragraph first), `lead-length`, **`lead-number`**, **`heading-h1`**, **`heading-order`**, **`sections`** (two `##`), **`section-question`**, **`section-answer`**, `section-answer-length`, **`length`** (a warning above the maximum), **`footnote-undefined`**, `footnote-unused` |
|
|
134
|
+
| links | **`internal-links`** (a warning for a term), **`internal-link-target`** (`check` only), `external-link-https`, **`external-link-dead`** (`--external` only), **`term-form-conflict`** (`check` only: a checked published term shares a form with another published term; `publish` refuses it whatever its severity) |
|
|
135
|
+
| images | **`image-source`** (a site path or a host of `blog({ images })`; every image while the app has no policy), **`image-alt`**, **`image-dimensions`** (the policy's `dimensions` knows it) |
|
|
136
|
+
| style (ruleset) | **`announcement`**, **`these-days`**, **`not-only-but-also`**, **`not-x-but-y`**, **`meta-commentary`**, **`throat-clearing`**, **`empty-conclusion`**, **`crucial`**, **`plays-a-role`**, **`puffery`**, **`chatbot-phrases`**, **`emoji`**, `filler-words`, `exclamation`, `straight-quotes` (`pl`), `title-case-heading` (`pl`) |
|
|
137
|
+
| style (rhythm) | **`dashes`**, `dashes-paragraph`, `bold-density`, `bold-labels`, `triads`, `long-sentences`, `monotone-rhythm`, `repeated-openings` |
|
|
138
|
+
| voice | **`first-person-singular`** and the app's phrases, when configured |
|
|
139
|
+
| ymyl | **`sources-missing`**, **`source-https`**, **`number-source`**, **`footnote-source`**, **`footnote-not-in-sources`**, **`profit-promise`**, when `ymyl` is on |
|
|
140
|
+
| blocks | **`block-requires`**: a fenced block of a block plugin has the frontmatter keys it `requires`, when `blocks` is set |
|
|
141
|
+
| plugin | the plugins' rules, **`plugin-failed`**, **`plugin-rule-undeclared`** |
|
|
142
|
+
|
|
143
|
+
Style patterns match the prose of the body and the title, description and summary (errors only
|
|
144
|
+
there). A warning pattern is reported once per text with its count. A significant number is an
|
|
145
|
+
amount, a percentage or a number from 1000 up, in the ruleset's notation; years, ages, small counts
|
|
146
|
+
and legal references ("art. 27", "section 401") need no source. Messages are English: they are read
|
|
147
|
+
by developers and by the agents that write the texts.
|
|
148
|
+
|
|
149
|
+
### The article file
|
|
150
|
+
|
|
151
|
+
One file per text, named `<slug>.md`:
|
|
152
|
+
|
|
153
|
+
```markdown
|
|
154
|
+
---
|
|
155
|
+
id: index-funds # stable key, given once; never change it
|
|
156
|
+
slug: index-funds # the address; equals the file name without .md
|
|
157
|
+
kind: article # article | term (a glossary definition); default article
|
|
158
|
+
forms: [tax wrapper] # term only, required: the phrases that link to the definition
|
|
159
|
+
cluster: investing-basics # the topic for "read next" lists; optional
|
|
160
|
+
pillar: true # the main text of its cluster: one per cluster, needs cluster; default false
|
|
161
|
+
title: Index funds in plain words
|
|
162
|
+
description: One or two sentences for search results and the social card.
|
|
163
|
+
summary: A few sentences with numbers for the "in short" box; optional.
|
|
164
|
+
status: published # draft | published | withdrawn
|
|
165
|
+
current_as_of: 2026-10-01 # the day the facts were checked
|
|
166
|
+
published_at: 2026-10-01 # optional; a day (midnight UTC) or a moment with an offset; default: first publication
|
|
167
|
+
sources:
|
|
168
|
+
- name: Fund factsheet
|
|
169
|
+
url: https://example.com/factsheet
|
|
170
|
+
faq:
|
|
171
|
+
- question: Is it safe?
|
|
172
|
+
answer: It follows the market, up and down.
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
The Markdown body.
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Rules:
|
|
179
|
+
|
|
180
|
+
- **Slug change:** change `slug` and rename the file, keep `id`. The old slug goes to the slug history
|
|
181
|
+
and redirects to the new one. A slug another article has now, or had before, is refused.
|
|
182
|
+
- **Glossary forms:** a form belongs to one published term (forms equal up to a capital first letter
|
|
183
|
+
are one). `publish` refuses a run that leaves a form with two terms, one of them in the run, naming
|
|
184
|
+
the form and both slugs; a conflict only between stored terms is a warning. `check` reports it as
|
|
185
|
+
`term-form-conflict`. The renderer links such a form to the first term it was given.
|
|
186
|
+
- **Withdrawal:** `status: withdrawn`. The row stays and its address answers 410. Do not delete the
|
|
187
|
+
file: a deleted file changes nothing in the database.
|
|
188
|
+
- **Update date:** `updated_at` moves by itself when the content of a published text changes (title,
|
|
189
|
+
description, summary, body, date of the facts, sources, FAQ, cluster, forms, fields), never on a
|
|
190
|
+
status, slug, publication date or pillar change alone.
|
|
191
|
+
|
|
192
|
+
## 4. Mounting
|
|
193
|
+
|
|
194
|
+
Each page is one file in the app. Next reads `dynamic` and `revalidate` only as literals in the app's
|
|
195
|
+
own file, so they stay there; `revalidate` should equal `revalidateSeconds`.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// app/blog/page.tsx: the listing, rendered per request over cached reads
|
|
199
|
+
export { BlogIndexPage as default, generateBlogIndexMetadata as generateMetadata } from "@softure-ai/blog/next";
|
|
200
|
+
export const dynamic = "force-dynamic";
|
|
201
|
+
|
|
202
|
+
// app/blog/[slug]/page.tsx: an article, kept for 300 s (ISR); slots take the app's components
|
|
203
|
+
import { BlogArticlePage, type BlogArticlePageProps } from "@softure-ai/blog/next";
|
|
204
|
+
export { generateArticleMetadata as generateMetadata, generateBlogStaticParams as generateStaticParams } from "@softure-ai/blog/next";
|
|
205
|
+
export const revalidate = 300;
|
|
206
|
+
export default function Page({ params }: Pick<BlogArticlePageProps, "params">) {
|
|
207
|
+
return <BlogArticlePage params={params} cta={<MyCta />} afterArticle={<Waitlist placement="blog" />} />;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// app/blog/[slug]/opengraph-image.tsx
|
|
211
|
+
export { BlogArticleOgImage as default, generateBlogStaticParams as generateStaticParams } from "@softure-ai/blog/next";
|
|
212
|
+
export const size = { width: 1200, height: 630 };
|
|
213
|
+
export const contentType = "image/png";
|
|
214
|
+
export const revalidate = 300;
|
|
215
|
+
|
|
216
|
+
// app/blog/glossary/page.tsx GlossaryIndexPage, generateGlossaryIndexMetadata; dynamic = "force-dynamic"
|
|
217
|
+
// app/blog/glossary/[slug]/page.tsx GlossaryTermPage, generateTermMetadata, generateBlogStaticParams; revalidate
|
|
218
|
+
// app/blog/how-we-write/page.tsx BlogMethodPage, generateMethodMetadata (with methodPage: true)
|
|
219
|
+
|
|
220
|
+
// app/blog/rss.xml/route.ts: the feed of published articles and terms, linked from the listing and articles
|
|
221
|
+
export { serveBlogRss as GET } from "@softure-ai/blog/next";
|
|
222
|
+
export const dynamic = "force-dynamic";
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
With `@softure-ai/seo`, the blog joins its sitemap through a contributor; `app/sitemap.ts` must then be
|
|
226
|
+
`force-dynamic` (the contributor reads the database):
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// softure.config.ts (the root entry: this file also loads in plain Node and in bundles outside Next)
|
|
230
|
+
import { blog, blogSitemap } from "@softure-ai/blog";
|
|
231
|
+
seo({ sitemap: { contributors: [blogSitemap()] }, indexNow: { key: "..." } });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The entries are the listing, the articles, the glossary and its terms, each dated by its last content
|
|
235
|
+
change (`updated_at`, else `published_at`), and the method page without a date; an empty listing or
|
|
236
|
+
glossary is left out (it is `noindex`). Paths only: seo makes them absolute with its canonical rule.
|
|
237
|
+
The contributor reads with one query per sitemap request, not through the pages' Next cache.
|
|
238
|
+
|
|
239
|
+
A publish from the command runs outside the app and cannot reach its cache. To show the change at once
|
|
240
|
+
instead of after `revalidateSeconds`, mount the refresh route, list `security()` with the blog's bucket,
|
|
241
|
+
and set one secret (32+ characters, e.g. `openssl rand -base64 32`) as `BLOG_REFRESH_SECRET` for both the
|
|
242
|
+
running app and the command:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// app/api/blog/refresh/route.ts (routes.refresh, default /api/blog/refresh)
|
|
246
|
+
export { refreshBlogCache as POST } from "@softure-ai/blog/next";
|
|
247
|
+
|
|
248
|
+
// softure.config.ts
|
|
249
|
+
import { BLOG_RATE_LIMIT_BUCKETS, blog } from "@softure-ai/blog";
|
|
250
|
+
security({ clientIp: cloudflareIp(), buckets: { ...BLOG_RATE_LIMIT_BUCKETS } });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The route counts every request in the `blog-refresh` bucket (10 per 15 minutes per client address;
|
|
254
|
+
callers without one, such as the command on a private network name, share one count) before it checks
|
|
255
|
+
`Authorization: Bearer <secret>`, then expires the blog's cache tag at once (`revalidateTag(BLOG_CACHE_TAG,
|
|
256
|
+
{ expire: 0 })`: the next request reads the tables, the cached pages included). Answers: 204 refreshed,
|
|
257
|
+
401 a missing or wrong secret, 429 over the bucket (`retry-after`), 503 when counting fails, 500 when
|
|
258
|
+
`BLOG_REFRESH_SECRET` is unset or short (logged by name). Without `security()` or its bucket the route
|
|
259
|
+
throws a setup error naming the fix.
|
|
260
|
+
|
|
261
|
+
301 and 410 are answered before the page, in `proxy.ts` (Node.js runtime, Next 16):
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { createBlogRedirects } from "@softure-ai/blog/proxy";
|
|
265
|
+
const blogRedirects = createBlogRedirects(softureConfig);
|
|
266
|
+
|
|
267
|
+
export async function proxy(request: NextRequest) {
|
|
268
|
+
return (await blogRedirects(request)) ?? NextResponse.next();
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
It handles GET and HEAD on the blog's text paths only, keeps the query on a redirect, remembers a
|
|
273
|
+
decision for 60 s (`ttlMs`) and passes a request on when the database fails. Import the styles after
|
|
274
|
+
ui's: `@import "@softure-ai/blog/styles.css";`. A data change shows after `revalidateSeconds`, or at
|
|
275
|
+
once with `revalidateTag(BLOG_CACHE_TAG, { expire: 0 })` (the refresh route above, for the command). Custom OG fonts: an own `opengraph-image.tsx` calling `renderArticleOgImage({ title, label, brand, fonts })`.
|
|
276
|
+
|
|
277
|
+
The quality gate resolves internal links through `quality.paths` (default `/blog` and
|
|
278
|
+
`/blog/glossary`, the default routes); an app that moves `routes` sets `quality.paths` to match.
|
|
279
|
+
|
|
280
|
+
The commands:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>] [--config <file>]
|
|
284
|
+
softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>] [--config <file>]
|
|
285
|
+
softure-blog skill install [--dir <path>] [--command <cmd>] [--check] [--config <file>]
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
- `<path>` is a file or a folder (every `*.md` but `README.md`, by name); without one, `contentDir`.
|
|
289
|
+
- `--commit` writes; without it the command prints what it would do and writes nothing.
|
|
290
|
+
- `--withdraw` publishes the one given file as `withdrawn` (taking a text down at once); set the file's
|
|
291
|
+
status too, or the next full publish brings the text back.
|
|
292
|
+
- A done run prints one `cache:` line. With `BLOG_REFRESH_SECRET` set, a commit that changed a text
|
|
293
|
+
posts to the app's refresh route on `appOrigin` (`--app-url <origin>` for another way in, such as
|
|
294
|
+
`http://web:3000` in a container network; redirects are not followed) before the IndexNow submit; a
|
|
295
|
+
dry run prints the address. Without the secret the line says the app shows the change after
|
|
296
|
+
`revalidateSeconds`. A failed refresh is a warning naming the answer: the publish stays written, the
|
|
297
|
+
IndexNow submit still goes out and the exit code stays 0. `--no-indexnow` does not skip it.
|
|
298
|
+
- With `seo({ indexNow: { key } })` enabled, a run ends with one `indexnow:` line. A commit submits the
|
|
299
|
+
addresses whose answer changed (a text public before or after, its old slug after a rename, the
|
|
300
|
+
listing or the glossary of its kind) as canonical URLs on seo's origin; a dry run prints them;
|
|
301
|
+
`--no-indexnow` skips the submit (e.g. a local or CI database). A failed submit is a warning: the
|
|
302
|
+
publish stays written and the exit code stays 0.
|
|
303
|
+
- Exit codes: 0 done, 1 refused or failed (nothing written), 2 usage error.
|
|
304
|
+
|
|
305
|
+
Output, one line per text, then a summary:
|
|
306
|
+
|
|
307
|
+
```text
|
|
308
|
+
added index-funds none -> published/index-funds
|
|
309
|
+
changed bonds draft/bonds -> published/bonds
|
|
310
|
+
moved bonds bond-basics -> bonds
|
|
311
|
+
summary: added 1, changed 1, unchanged 12
|
|
312
|
+
dry run: nothing written; pass --commit to write
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Like `softure migrate`, the bin loads `softure.config.(ts|mts|js|mjs)` with Node and opens
|
|
316
|
+
`database.handle` when the config sets one, otherwise `database.url`. When Node cannot load the config (path aliases, a bundled container), call
|
|
317
|
+
`runBlogCli` from an app script:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
// scripts/blog.ts
|
|
321
|
+
import { runBlogCli } from "@softure-ai/blog/cli";
|
|
322
|
+
import config from "../softure.config";
|
|
323
|
+
|
|
324
|
+
process.exitCode = await runBlogCli({ config, argv: process.argv.slice(2) });
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`publish` runs the quality gate on every file going public (status `published`, not `--withdraw`);
|
|
328
|
+
one error refuses the run. The gate in `publish` does not resolve internal link targets, since a
|
|
329
|
+
container that publishes may hold no app folder; run `check` in CI for those. `runBlogCli({ gate })`
|
|
330
|
+
replaces the gate; `runBlogPublish` in `@softure-ai/blog/server` is the same run without a command line.
|
|
331
|
+
After an app's own run, `submitBlogChanges(config, run.changes, { commit: run.committed })` submits the
|
|
332
|
+
same addresses; inside Next, call `revalidateTag(BLOG_CACHE_TAG, { expire: 0 })` first, and outside it
|
|
333
|
+
`requestBlogRefresh(config, run.changes, { commit: run.committed })` (`@softure-ai/blog/server`), so a
|
|
334
|
+
crawler that answers the ping at once gets the new text.
|
|
335
|
+
|
|
336
|
+
`check` and `skill install` need no database, nor a database URL: the bin loads the config with the
|
|
337
|
+
database optional for them (`@softure-ai/core`'s `withDatabaseOptional`), so a CI job without
|
|
338
|
+
`DATABASE_URL` runs them. An app script that runs them wraps its own import the same way:
|
|
339
|
+
`const { default: config } = await withDatabaseOptional(() => import("../softure.config"))`. `check` reads the
|
|
340
|
+
files (default: `contentDir`), resolves internal links against the app's routes and the published texts
|
|
341
|
+
of `contentDir`, compares the glossary forms of the checked terms with every published term there, reads
|
|
342
|
+
every `brand.fonts` source as the OG card's route does (a path from the working directory, an `https`
|
|
343
|
+
URL fetched, so a job with such a font needs network), and prints one line per finding:
|
|
344
|
+
|
|
345
|
+
```text
|
|
346
|
+
content/blog/index-funds.md:12: error [crucial] "crucial": a favourite word of language models; name what depends on the thing
|
|
347
|
+
content/blog/bonds.md: OK
|
|
348
|
+
check: 2 file(s), 1 error(s), 0 warning(s): red, do not publish
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Exit codes: 0 green (warnings allowed), 1 an error, 2 usage error. `--external` also requests every
|
|
352
|
+
external link (HEAD, then GET when a server refuses HEAD; 2xx after redirects). Run it weekly with the
|
|
353
|
+
reusable workflow of this repository, `.github/workflows/blog-links.yml` (its header holds the
|
|
354
|
+
snippet for the app).
|
|
355
|
+
|
|
356
|
+
### The writing skill
|
|
357
|
+
|
|
358
|
+
`softure-blog skill install` writes an agent skill for writing the blog's texts into
|
|
359
|
+
`.claude/skills/blog-write/` (`--dir` to change). It walks the agent through a text: the question,
|
|
360
|
+
facts with sources, a draft by an answer-first structure, a rewrite by the rules, `check`, a
|
|
361
|
+
sceptical second agent with its own prompt, `check --external`, and `publish`. The package ships the
|
|
362
|
+
templates in `skill/`; the command fills them from the app's config:
|
|
363
|
+
|
|
364
|
+
- the language of the texts, the content folder, the article and glossary paths and every limit;
|
|
365
|
+
- `references/rules.md`: exactly the rules the app's gate enforces (`listQualityRules`), each with
|
|
366
|
+
its effective severity, what the gate looks for and what to write instead; the app's voice
|
|
367
|
+
phrases and plugin rules with their own descriptions; a rule set to `"off"` is left out;
|
|
368
|
+
- the YMYL passages (sources, footnotes, the own calculation mark) only when `ymyl` is on, and the
|
|
369
|
+
editors' "we" when `voice.forbidFirstPersonSingular` is on;
|
|
370
|
+
- the app's own sections from `blog({ skill: { sections } })` (below).
|
|
371
|
+
|
|
372
|
+
The app adds its own procedure (where its numbers come from, its block plugins, its fields) as sections
|
|
373
|
+
in the config, so a reinstall keeps them and `--check` covers them:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
blog({
|
|
377
|
+
skill: {
|
|
378
|
+
sections: [
|
|
379
|
+
{ title: "Engine numbers", body: "Every number of an example comes from `npm run engine -- <inputs>`." },
|
|
380
|
+
{ title: "Chart block", body: "One `::chart{scenario=\"…\"}` block after the lead, with the frontmatter's `scenario`." },
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Install writes them to `references/app.md` (`## <title>` and the body, verbatim, never filled like the
|
|
387
|
+
templates) and `SKILL.md` names them; with no sections the file is not written. A title is one line, unique,
|
|
388
|
+
up to 80 characters; a body holds no `#` or `##` heading outside fenced code (use `###`). An app with long
|
|
389
|
+
sections keeps them in a module of its own and imports them into the config.
|
|
390
|
+
|
|
391
|
+
`--command` sets how the skill runs the commands (default `npx softure-blog`; an app with a
|
|
392
|
+
`runBlogCli` script passes e.g. `--command "npm run blog --"`). Commit the folder, so agents in a
|
|
393
|
+
fresh clone have it, and run `softure-blog skill install --check` (with the same options) in CI, with no
|
|
394
|
+
`DATABASE_URL` needed: it writes nothing and exits 1, naming the files, when the folder differs from what
|
|
395
|
+
the config gives.
|
|
396
|
+
Install overwrites only a folder whose `SKILL.md` it generated, so it never replaces a skill the app
|
|
397
|
+
wrote itself. That folder belongs to the command: install removes a `.md` file in it that the config no
|
|
398
|
+
longer gives (`references/app.md` once the sections are gone), logging `removed <path>`, and `--check`
|
|
399
|
+
names such a file. With `quality: false` it refuses: the skill is built on the gate.
|
|
400
|
+
|
|
401
|
+
### Rendering an article
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { getPublishedArticle, listArticles, renderArticle, toGlossary } from "@softure-ai/blog/server";
|
|
405
|
+
|
|
406
|
+
const article = await getPublishedArticle(ctx, slug);
|
|
407
|
+
const glossary = toGlossary(await listArticles(ctx, { kind: "term" }));
|
|
408
|
+
const body = renderArticle(article.bodyMarkdown, {
|
|
409
|
+
glossary,
|
|
410
|
+
selfSlug: article.kind === "term" ? article.slug : undefined,
|
|
411
|
+
termHref: (term) => `/blog/glossary/${term}`, // the default
|
|
412
|
+
siteHosts: ["example.com"], // subdomains included; other hosts are external
|
|
413
|
+
images: { hosts: ["cdn.example.com"], dimensions: (src) => imageSizes[src] ?? null }, // see Images below
|
|
414
|
+
toc: true, // or { maxLevel: 4 }; h2 and h3 by default
|
|
415
|
+
messages: blogMessages.pl.render, // English by default
|
|
416
|
+
blocks: [chartBlock],
|
|
417
|
+
article: { currentAsOf: article.currentAsOf, fields: article.fields },
|
|
418
|
+
});
|
|
419
|
+
// body.html (null when a block returned a node), body.segments, body.headings, body.toc,
|
|
420
|
+
// body.linkedTerms, body.readingMinutes
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- **Allowlist by construction:** raw HTML in the text is escaped, so the output holds only the
|
|
424
|
+
elements Markdown produces. Links keep `http(s)`, `mailto`, relative and `#` targets; any other
|
|
425
|
+
scheme (`javascript:`, `data:`, entity-encoded or split by whitespace) stays text.
|
|
426
|
+
- **Images** (``) follow the image policy (`images`): the source is a site path
|
|
427
|
+
(`/images/x.png`; never `//host` or a path relative to the page) or an `https:` URL on a host in
|
|
428
|
+
`images.hosts` (subdomains included), the alt text is not empty, and `images.dimensions(src)` returns
|
|
429
|
+
positive whole `{ width, height }`. Such an image renders as `<img class="blog-image">` with its
|
|
430
|
+
`width`, `height`, `loading="lazy"` and `decoding="async"`; any other renders as its alt text, and
|
|
431
|
+
without `images` every image does. `src` is the URL the page requests (percent-encoded: a space is
|
|
432
|
+
`%20`). A throwing `dimensions` fails the render (a bug); the gate reports it as `image-dimensions`.
|
|
433
|
+
`checkArticleImage` and `findArticleImages` give the same verdict and the images of a text.
|
|
434
|
+
- **External links** (`http(s)` or `//` to a host outside `siteHosts`) get `rel="noopener noreferrer"`,
|
|
435
|
+
`target="_blank"`, a `↗` marker hidden from screen readers and a visually hidden "(opens in a new tab)".
|
|
436
|
+
- **Headings** get ids from their text (letters folded to ASCII, `-2` for a repeat, `section` without
|
|
437
|
+
letters); `toc` renders `<nav class="blog-toc">` with nested lists.
|
|
438
|
+
- **Glossary:** the first mention of each term form links to its definition; never inside headings,
|
|
439
|
+
links, code or footnotes, never a term page to itself. The longest form wins, word bounds are
|
|
440
|
+
Unicode-aware, case is as written (plus a capital first letter). A hand-written link to a term counts.
|
|
441
|
+
- **Footnotes** (`[^id]`) become numbered references and a notes section with links back.
|
|
442
|
+
|
|
443
|
+
**Block plugins.** A top-level fence whose type an app registers is rendered by the app:
|
|
444
|
+
|
|
445
|
+
````md
|
|
446
|
+
```chart wealth
|
|
447
|
+
scenario: early-retirement
|
|
448
|
+
```
|
|
449
|
+
````
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
const chartBlock: BlockPlugin = {
|
|
453
|
+
type: "chart",
|
|
454
|
+
requires: ["current_as_of", "scenario"], // frontmatter keys the block reads, for the quality gate
|
|
455
|
+
render: ({ info, content, article }) => ({ kind: "html", html: renderChart(info, content, article) }),
|
|
456
|
+
// or { kind: "node", node: <Chart … /> } for a React server component
|
|
457
|
+
};
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Plugin output is the app's own code and is trusted as is: escape what goes into its HTML. A plugin
|
|
461
|
+
that throws fails the render (a bug, not content). Without the plugin the same fence renders as a code
|
|
462
|
+
block. `findArticleBlocks(markdown, plugins)` lists the blocks a text uses with their line and
|
|
463
|
+
`requires`, without rendering. When any block returns a node, `html` is `null`: render `segments` in
|
|
464
|
+
order (`html` segments as HTML, `node` segments as they are).
|
|
465
|
+
|
|
466
|
+
## 5. Migrations and tables
|
|
467
|
+
|
|
468
|
+
Schema `blog`, migration `0001_create_articles.sql`:
|
|
469
|
+
|
|
470
|
+
| Table | Holds |
|
|
471
|
+
| --- | --- |
|
|
472
|
+
| `blog.articles` | one row per text: id, slug, kind, cluster, pillar, title, description, summary, body, status, `current_as_of`, `published_at`, `updated_at`, sources, FAQ, term forms, the app's fields, content hash, `created_at` |
|
|
473
|
+
| `blog.slug_history` | each old slug with its article (the 301 target); removed with the article |
|
|
474
|
+
|
|
475
|
+
Constraints: id, slug, cluster and old slug kebab-case (at most 100 characters); kind and status
|
|
476
|
+
closed lists; text lengths; a published text has a publication date; an update date needs one; a
|
|
477
|
+
pillar has a cluster; a term has forms and an article has none; a unique slug; one pillar per cluster
|
|
478
|
+
among texts not withdrawn (an exclusion constraint deferred to commit, so one run can move the pillar).
|
|
479
|
+
"A slug is not in another article's history" stays in the code, under a row lock; the check reads the current
|
|
480
|
+
slugs before the old ones, so a slug that another run is renaming away from is refused, naming that article,
|
|
481
|
+
whether the rename has committed or not. A run that loses a race for
|
|
482
|
+
a slug (another run committed it between the read and the write) is refused with `blog.slug_taken`,
|
|
483
|
+
naming the article that took it, like any other taken slug.
|
|
484
|
+
|
|
485
|
+
## 6. Environment variables
|
|
486
|
+
|
|
487
|
+
| Variable | Required | Read by |
|
|
488
|
+
| --- | --- | --- |
|
|
489
|
+
| `BLOG_REFRESH_SECRET` | no | the refresh route (`refreshBlogCache`) and `softure-blog publish`: the shared secret, 32+ characters; without it a publish shows after `revalidateSeconds` |
|
|
490
|
+
|
|
491
|
+
The command reads the database URL from the app's config.
|
|
492
|
+
|
|
493
|
+
## 7. Switches
|
|
494
|
+
|
|
495
|
+
None.
|
|
496
|
+
|
|
497
|
+
## 8. Appearance
|
|
498
|
+
|
|
499
|
+
`styles.css` styles every `blog-*` class of the pages and of the rendered body (`blog-external`,
|
|
500
|
+
`blog-external-marker`, `blog-visually-hidden`, `blog-term`, `blog-toc`, `blog-footnote-ref`,
|
|
501
|
+
`blog-footnotes`, `blog-footnote-back`) with the `--sft-*` tokens of `@softure-ai/ui`, in the
|
|
502
|
+
`softure` layer, so the app's own rules win. The OG card takes `brand.colors`, else ui's dark theme.
|
|
503
|
+
|
|
504
|
+
The OG card writes in `brand.fonts`, else in `next/og`'s default font:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
blog({
|
|
508
|
+
brand: {
|
|
509
|
+
name: "FIRE Tracker",
|
|
510
|
+
fonts: [
|
|
511
|
+
// weight: 100…900 (default 400), style: "normal" | "italic" (default "normal")
|
|
512
|
+
{ name: "Inter", weight: 400, src: "assets/fonts/inter-latin-400-normal.woff" },
|
|
513
|
+
{ name: "Inter", weight: 700, src: "assets/fonts/inter-latin-700-normal.woff" },
|
|
514
|
+
// a second file of one weight (a latin-ext subset) under its own name: the card lists every name
|
|
515
|
+
// in order, so it draws the characters the first file lacks
|
|
516
|
+
{ name: "Inter Ext", weight: 700, src: "https://cdn.example.com/inter-latin-ext-700-normal.woff" },
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
- `src` is a `.ttf`, `.otf` or `.woff` file (Satori does not read `.woff2`): a path from the app's root,
|
|
523
|
+
an absolute path, or an `https` URL. Marketing-kit's subset files (`brand.fonts` of `marketing.json`)
|
|
524
|
+
fit as they are.
|
|
525
|
+
- The card's route reads each file on its first card and keeps it for the life of the process. A file
|
|
526
|
+
that cannot be read, or is not such a font, fails the card with a message naming `brand.fonts[i]` and
|
|
527
|
+
the file; the next card tries again. `softure-blog check` reads the same sources, so CI reports such a
|
|
528
|
+
file first (`softure-blog check: Blog OG card: brand.fonts[0] …`, one error, exit 1).
|
|
529
|
+
- Paths are read on the Node.js runtime (the route's default). With `output: "standalone"`, list the
|
|
530
|
+
folder in `outputFileTracingIncludes` (`{ "/blog/[slug]/opengraph-image": ["./assets/fonts/**"] }`);
|
|
531
|
+
a route moved to the edge runtime takes `https` URLs only.
|
|
532
|
+
- With brand fonts the card has no other font: a character none of them has is not drawn.
|
|
533
|
+
|
|
534
|
+
## 9. Copy
|
|
535
|
+
|
|
536
|
+
`src/messages/`: labels of the kinds (`kinds.article`, `kinds.term`) and statuses (`statuses.*`) in
|
|
537
|
+
`en` and `pl`; the renderer's copy under `render.*` (notes heading, footnote label with `{number}`,
|
|
538
|
+
back to text, opens in a new tab, contents label), passed as `renderArticle({ messages })`; the pages'
|
|
539
|
+
copy under `pages.*`, `glossary.*`, `method.*`, `gone.*` (the 410 page), `og.*` and `feed.*` (the feed's 503 body); "read next" is `pages.readNext`. Override any of it
|
|
540
|
+
with `blog({ messages: { pl: { pages: { readMore: "..." } } } })`. Command output and file errors are developer output, in English.
|
|
541
|
+
|
|
542
|
+
## 10. Hooks
|
|
543
|
+
|
|
544
|
+
- `blog({ fields })`: the app's frontmatter schema (FIRE_TRACKER's calculator scenario lives here).
|
|
545
|
+
- `gate` of `runBlogPublish` and `runBlogCli`: `(file, article) => problems`, called only for files
|
|
546
|
+
going public; any problem refuses the whole run. Default in `runBlogCli`: `createQualityGate`.
|
|
547
|
+
- `blog({ quality: { plugins } })`: the app's domain rules. A plugin declares its rules and checks one
|
|
548
|
+
text at a time; it is pure (no network, no file system) and its findings take part in severity
|
|
549
|
+
overrides and the catalog. A throw becomes `plugin-failed`, an undeclared rule id
|
|
550
|
+
`plugin-rule-undeclared`.
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
import type { QualityPlugin } from "@softure-ai/blog/server";
|
|
554
|
+
|
|
555
|
+
export const tickerPlugin: QualityPlugin = {
|
|
556
|
+
name: "tickers",
|
|
557
|
+
rules: [{ id: "ticker-format", severity: "error", description: "tickers are written in capitals" }],
|
|
558
|
+
check: ({ blocks }) =>
|
|
559
|
+
blocks
|
|
560
|
+
.filter((block) => /\$[a-z]{2,5}\b/.test(block.text))
|
|
561
|
+
.map((block) => ({ rule: "ticker-format", severity: "error", message: "write the ticker in capitals", line: block.line })),
|
|
562
|
+
};
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The context holds the parsed `article` (with the app's `fields`), the body `blocks` with file lines
|
|
566
|
+
(directives such as `::chart{…}` are blocks of their own), the fenced `pluginBlocks` of the block
|
|
567
|
+
plugins in `quality.blocks` (type, info, fence line, `requires`), `today` and the language `ruleset` (for
|
|
568
|
+
its number notation); the text helpers (`toProse`, `splitSentences`, `findSignificantNumbers`, …) are
|
|
569
|
+
exported from `@softure-ai/blog/server`.
|
|
570
|
+
- `renderArticle({ blocks })` and `blog({ blocks })`: block plugins for the app's fenced blocks
|
|
571
|
+
(FIRE_TRACKER's engine chart).
|
|
572
|
+
- The pages' `cta` and `afterArticle` slots: the app's call to action and blocks (a waitlist form).
|
|
573
|
+
|
|
574
|
+
## 11. GDPR
|
|
575
|
+
|
|
576
|
+
Articles hold editorial content, no personal data: nothing to export or delete.
|
|
577
|
+
|
|
578
|
+
## 12. Limitations
|
|
579
|
+
|
|
580
|
+
- Two runs that rename one article away from a slug and give it to another at the same moment can leave the
|
|
581
|
+
slug both current and in the slug history (BF-12).
|
|
582
|
+
- The content hash is part of the contract: a field added later enters it only when present.
|
|
583
|
+
- No `--stdin` (a deploy transport).
|
|
584
|
+
- The refresh route expires the cache of the instance that answers it. With several instances and Next's
|
|
585
|
+
default (in-memory) cache handler, the others show a publish after `revalidateSeconds`; a shared cache
|
|
586
|
+
handler covers them.
|
|
587
|
+
- The renderer has no raw HTML and no figures: an image has no caption, and the app hosts and sizes its
|
|
588
|
+
images itself (no `next/image`). A plugin fence inside a list or a quote stays a code
|
|
589
|
+
block (a block node cannot sit inside a list's HTML).
|
|
590
|
+
- The gate reads Markdown line by line (blocks, not a syntax tree): enough for the rules, not a
|
|
591
|
+
renderer. Fenced code and HTML comments are skipped.
|
|
592
|
+
- **Adopting FIRE_TRACKER's gate:** `language: "pl"`, `ymyl: { ownCalculationMark }` with its calculation
|
|
593
|
+
footnote's phrase, `voice.forbidFirstPersonSingular: true` plus its finance phrases, its domain as
|
|
594
|
+
`ownOrigins`, `privateRouteSegments: ["api", "(app)"]`, and `rules-facts.ts` and `rules-chart.ts`
|
|
595
|
+
as plugins (the package's tests hold stand-ins of both). Rule ids are English now (`kluczowy` →
|
|
596
|
+
`crucial`, `myslniki` → `dashes`, …; the map is in the change archive), and the writing skill
|
|
597
|
+
(`skill install`, replacing FIRE's `blog-pisz`) names them; FIRE's engine numbers, calculator scenario
|
|
598
|
+
and chart block go into `blog({ skill: { sections } })`.
|
|
599
|
+
- **Adopting from FIRE_TRACKER:** rename the frontmatter keys once (`typ` → `kind` with `artykul` →
|
|
600
|
+
`article` and `termin` → `term`, `formy` → `forms`, `klaster` → `cluster`, `filar` → `pillar`,
|
|
601
|
+
`tytul` → `title`, `opis` → `description`, `w_skrocie` → `summary`, `aktualne_na` →
|
|
602
|
+
`current_as_of`, `opublikowano` → `published_at`, `zrodla` → `sources` with `nazwa` → `name`,
|
|
603
|
+
`faq` items `pytanie` → `question` and `odpowiedz` → `answer`), move `scenariusz` into the app's
|
|
604
|
+
`fields`, and copy the rows into `blog.articles` with the hash the package computes from the renamed
|
|
605
|
+
files, or the first publish marks every published text as updated.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":""}
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `softure-blog` executable. All logic lives in command.ts and run.ts, which the tests call directly.
|
|
3
|
+
import { runBlogCommand } from "./command.js";
|
|
4
|
+
process.exitCode = await runBlogCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
|
|
5
|
+
//# sourceMappingURL=bin.js.map
|