@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,86 @@
|
|
|
1
|
+
// The rule catalog: every rule the gate can report for an app's settings, with its effective
|
|
2
|
+
// severity. The writing skill (BL-7) is checked against it, so a rule the gate enforces is named in
|
|
3
|
+
// the skill and the skill names no rule the gate lacks.
|
|
4
|
+
import type { QualitySeverity } from "./finding.js";
|
|
5
|
+
import type { QualityRuleInfo } from "./plugin.js";
|
|
6
|
+
import { getEffectiveSeverity, type QualitySettings } from "./settings.js";
|
|
7
|
+
|
|
8
|
+
export const QUALITY_RULE_GROUPS = ["file", "structure", "links", "images", "ymyl", "style", "voice", "plugin"] as const;
|
|
9
|
+
export type QualityRuleGroup = (typeof QUALITY_RULE_GROUPS)[number];
|
|
10
|
+
|
|
11
|
+
export interface QualityCatalogRule extends QualityRuleInfo {
|
|
12
|
+
readonly group: QualityRuleGroup;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const rule = (group: QualityRuleGroup, id: string, severity: QualitySeverity, description: string): QualityCatalogRule => ({ group, id, severity, description });
|
|
16
|
+
|
|
17
|
+
/** Rules that do not depend on the ruleset, the voice or plugins. */
|
|
18
|
+
const CORE_RULES: readonly QualityCatalogRule[] = [
|
|
19
|
+
rule("file", "file", "error", "the file parses: frontmatter keys and values, slug equal to the file name"),
|
|
20
|
+
rule("structure", "title-length", "warning", "the title fits in search results (limits.titleChars)"),
|
|
21
|
+
rule("structure", "description-length", "warning", "the description is between limits.descriptionChars.min and max characters"),
|
|
22
|
+
rule("structure", "as-of-future", "error", "current_as_of is not in the future"),
|
|
23
|
+
rule("structure", "stale", "warning", "current_as_of is not older than limits.staleAfterDays"),
|
|
24
|
+
rule("structure", "summary-missing", "error", "an article has a summary"),
|
|
25
|
+
rule("structure", "lead", "error", "the text opens with a paragraph that answers"),
|
|
26
|
+
rule("structure", "lead-length", "warning", "the first paragraph fits in limits.leadWords"),
|
|
27
|
+
rule("structure", "lead-number", "error", "an article has a concrete number in its first two paragraphs"),
|
|
28
|
+
rule("structure", "heading-h1", "error", "no # heading in the body"),
|
|
29
|
+
rule("structure", "heading-order", "error", "headings do not skip a level"),
|
|
30
|
+
rule("structure", "sections", "error", "an article has at least two ## sections"),
|
|
31
|
+
rule("structure", "section-question", "error", "at least one ## heading of an article is a question"),
|
|
32
|
+
rule("structure", "section-answer", "error", "a question heading is followed by a paragraph"),
|
|
33
|
+
rule("structure", "section-answer-length", "warning", "the answer under a question fits in limits.answerWords"),
|
|
34
|
+
rule("structure", "length", "error", "the word count is within limits.words (a warning above the maximum)"),
|
|
35
|
+
rule("structure", "footnote-undefined", "error", "every footnote reference has a definition"),
|
|
36
|
+
rule("structure", "footnote-unused", "warning", "every footnote definition is referenced"),
|
|
37
|
+
rule("links", "internal-links", "error", "at least limits.internalLinks internal links (a warning for a term)"),
|
|
38
|
+
rule("links", "internal-link-target", "error", "every internal link leads to a page, article or glossary term"),
|
|
39
|
+
rule("links", "external-link-https", "warning", "external links use https"),
|
|
40
|
+
rule("links", "external-link-dead", "error", "external links answer 2xx (only with --external)"),
|
|
41
|
+
rule("links", "term-form-conflict", "error", "a glossary form belongs to one published term (only in check; publish refuses it always)"),
|
|
42
|
+
rule("images", "image-source", "error", "an image comes from the site or a host of blog({ images: { hosts } })"),
|
|
43
|
+
rule("images", "image-alt", "error", "an image has alt text"),
|
|
44
|
+
rule("images", "image-dimensions", "error", "the app knows an image's width and height (blog({ images: { dimensions } }))"),
|
|
45
|
+
rule("style", "dashes", "error", "at most one dash per limits.wordsPerDash words"),
|
|
46
|
+
rule("style", "dashes-paragraph", "warning", "at most one dash per paragraph"),
|
|
47
|
+
rule("style", "bold-density", "warning", "at most one bold phrase per limits.wordsPerBold words"),
|
|
48
|
+
rule("style", "bold-labels", "warning", "fewer than three list items start with a bold label"),
|
|
49
|
+
rule("style", "triads", "warning", "at most three lists of three"),
|
|
50
|
+
rule("style", "long-sentences", "warning", "sentences fit in limits.sentenceWords"),
|
|
51
|
+
rule("style", "monotone-rhythm", "warning", "sentence lengths vary"),
|
|
52
|
+
rule("style", "repeated-openings", "warning", "no three sentences in a row start with the same word"),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const YMYL_RULES: readonly QualityCatalogRule[] = [
|
|
56
|
+
rule("ymyl", "sources-missing", "error", "an article lists its sources"),
|
|
57
|
+
rule("ymyl", "source-https", "error", "every source address uses https"),
|
|
58
|
+
rule("ymyl", "number-source", "error", "every significant number (amount, percentage, 1000 or more) carries a footnote"),
|
|
59
|
+
rule("ymyl", "footnote-source", "error", "every footnote has a source address or the app's own calculation mark"),
|
|
60
|
+
rule("ymyl", "footnote-not-in-sources", "error", "every address in a footnote is a listed source"),
|
|
61
|
+
rule("ymyl", "profit-promise", "error", "no promise of profit and no order to buy"),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const PLUGIN_RULES: readonly QualityCatalogRule[] = [
|
|
65
|
+
rule("plugin", "plugin-failed", "error", "a rule plugin ran without throwing"),
|
|
66
|
+
rule("plugin", "plugin-rule-undeclared", "error", "a rule plugin reports only the rules it declares"),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Every rule the gate can report with these settings, with the effective severity; rules switched off are left out. */
|
|
70
|
+
export function listQualityRules(settings: QualitySettings): QualityCatalogRule[] {
|
|
71
|
+
const { ruleset, options } = settings;
|
|
72
|
+
const rules: QualityCatalogRule[] = [
|
|
73
|
+
...CORE_RULES,
|
|
74
|
+
...(ruleset.flagsTitleCaseHeadings ? [rule("style", "title-case-heading", "warning", "headings use sentence case")] : []),
|
|
75
|
+
...ruleset.patterns.map((pattern) => rule("style", pattern.id, pattern.severity, pattern.message)),
|
|
76
|
+
...settings.voicePatterns.map((pattern) => rule("voice", pattern.id, pattern.severity, pattern.message)),
|
|
77
|
+
...(options.ymyl === null ? [] : YMYL_RULES),
|
|
78
|
+
...(options.blocks.length === 0 ? [] : [rule("structure", "block-requires", "error", "a block plugin's fenced block has the frontmatter keys it requires")]),
|
|
79
|
+
...(options.plugins.length === 0 ? [] : PLUGIN_RULES),
|
|
80
|
+
...options.plugins.flatMap((plugin) => plugin.rules.map((info) => rule("plugin", info.id, info.severity, info.description))),
|
|
81
|
+
];
|
|
82
|
+
return rules.flatMap((entry) => {
|
|
83
|
+
const severity = getEffectiveSeverity(settings, entry.id, entry.severity);
|
|
84
|
+
return severity === null ? [] : [{ ...entry, severity }];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The gate over one article (FIRE_TRACKER `src/lib/blog/quality/check-article.ts`): one entry for
|
|
2
|
+
// `softure-blog check`, the publish gate and the tests. Pure: the parsed article, today and the link
|
|
3
|
+
// resolver come in; the network check of external links is separate.
|
|
4
|
+
import type { BlogArticleInput } from "../contract.js";
|
|
5
|
+
import { findArticleImages } from "../render/images.js";
|
|
6
|
+
import { findArticleBlocks, type FoundBlock } from "../render/render-article.js";
|
|
7
|
+
import { parseArticleFile, type ParseArticleFileOptions } from "../content/article-file.js";
|
|
8
|
+
import { splitArticleBody, splitBlocks } from "./blocks.js";
|
|
9
|
+
import { sortFindings, type QualityFinding } from "./finding.js";
|
|
10
|
+
import type { QualityPlugin } from "./plugin.js";
|
|
11
|
+
import { checkBlockRequires } from "./rules/blocks.js";
|
|
12
|
+
import { checkImages } from "./rules/images.js";
|
|
13
|
+
import type { RuleInput } from "./rules/input.js";
|
|
14
|
+
import { checkLinks, collectLinks, type InternalLinkResolver } from "./rules/links.js";
|
|
15
|
+
import { checkFootnotes, checkLead, checkLength, checkMetadata, checkSections } from "./rules/structure.js";
|
|
16
|
+
import { checkMetadataStyle, checkRhythm, checkStyle } from "./rules/style.js";
|
|
17
|
+
import { checkNumberSources, checkSources } from "./rules/ymyl.js";
|
|
18
|
+
import { getEffectiveSeverity, type QualitySettings } from "./settings.js";
|
|
19
|
+
|
|
20
|
+
export interface QualityCheckResult {
|
|
21
|
+
readonly findings: readonly QualityFinding[];
|
|
22
|
+
/** External addresses for the network check (`--external`). */
|
|
23
|
+
readonly externalLinks: readonly { readonly url: string; readonly line: number }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CheckArticleInput {
|
|
27
|
+
/** The parsed file. */
|
|
28
|
+
readonly article: BlogArticleInput;
|
|
29
|
+
/** The whole file text, for the body's line numbers. */
|
|
30
|
+
readonly text: string;
|
|
31
|
+
readonly settings: QualitySettings;
|
|
32
|
+
/** `YYYY-MM-DD` in the app's time zone. */
|
|
33
|
+
readonly today: string;
|
|
34
|
+
/** Default: every internal link exists (the publish gate; `check` resolves them). */
|
|
35
|
+
readonly resolveInternalLink?: InternalLinkResolver;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkArticle(input: CheckArticleInput): QualityCheckResult {
|
|
39
|
+
const { article, settings, today } = input;
|
|
40
|
+
const split = splitArticleBody(input.text);
|
|
41
|
+
const blocks = split === null ? splitBlocks(article.bodyMarkdown) : splitBlocks(split.body, split.bodyStartLine);
|
|
42
|
+
const body = split?.body ?? article.bodyMarkdown;
|
|
43
|
+
const bodyStartLine = split?.bodyStartLine ?? 1;
|
|
44
|
+
const pluginBlocks = findPluginBlocks(body, bodyStartLine, settings);
|
|
45
|
+
const images = findArticleImages(body).map((image) => ({ ...image, line: image.line + bodyStartLine - 1 }));
|
|
46
|
+
const ruleInput: RuleInput = { article, blocks, settings, today };
|
|
47
|
+
const links = collectLinks(ruleInput);
|
|
48
|
+
|
|
49
|
+
const builtIn = [
|
|
50
|
+
...checkMetadata(ruleInput),
|
|
51
|
+
...checkMetadataStyle(ruleInput),
|
|
52
|
+
...checkLead(ruleInput),
|
|
53
|
+
...checkSections(ruleInput),
|
|
54
|
+
...checkLength(ruleInput),
|
|
55
|
+
...checkFootnotes(ruleInput),
|
|
56
|
+
...(settings.options.ymyl === null ? [] : [...checkSources(ruleInput), ...checkNumberSources(ruleInput)]),
|
|
57
|
+
...checkLinks(ruleInput, links, input.resolveInternalLink ?? (() => true)),
|
|
58
|
+
...checkStyle(ruleInput),
|
|
59
|
+
...checkRhythm(ruleInput),
|
|
60
|
+
...checkBlockRequires(article, pluginBlocks),
|
|
61
|
+
...checkImages(images, settings.images),
|
|
62
|
+
];
|
|
63
|
+
const fromPlugins = settings.options.plugins.flatMap((plugin) => runPlugin(plugin, { article, blocks, pluginBlocks, today, ruleset: settings.ruleset }));
|
|
64
|
+
return { findings: applySeverity(settings, [...builtIn, ...fromPlugins]), externalLinks: links.external };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CheckArticleTextInput extends Omit<CheckArticleInput, "article"> {
|
|
68
|
+
/** A path or a bare name; it must read `<slug>.md`. */
|
|
69
|
+
readonly fileName: string;
|
|
70
|
+
/** The app's `fields` and reserved slugs, as the publish run applies them. */
|
|
71
|
+
readonly parse?: ParseArticleFileOptions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parses the file first; parse errors are `file` findings and stop the check. */
|
|
75
|
+
export function checkArticleText(input: CheckArticleTextInput): QualityCheckResult {
|
|
76
|
+
const parsed = parseArticleFile(input.text, input.fileName, input.parse);
|
|
77
|
+
if (!parsed.ok) {
|
|
78
|
+
return { findings: applySeverity(input.settings, parsed.errors.map((message) => ({ rule: "file", severity: "error", message }))), externalLinks: [] };
|
|
79
|
+
}
|
|
80
|
+
return checkArticle({ ...input, article: parsed.article });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The app's plugin blocks with file lines; none without `quality.blocks`. */
|
|
84
|
+
function findPluginBlocks(body: string, bodyStartLine: number, settings: QualitySettings): FoundBlock[] {
|
|
85
|
+
if (settings.options.blocks.length === 0) return [];
|
|
86
|
+
return findArticleBlocks(body, settings.options.blocks).map((block) => ({ ...block, line: block.line + bodyStartLine - 1 }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runPlugin(plugin: QualityPlugin, context: Parameters<QualityPlugin["check"]>[0]): QualityFinding[] {
|
|
90
|
+
let findings: readonly QualityFinding[];
|
|
91
|
+
try {
|
|
92
|
+
findings = plugin.check(context);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// A plugin bug must not crash a publish with a stack trace; it refuses the file instead.
|
|
95
|
+
return [{ rule: "plugin-failed", severity: "error", message: `plugin ${plugin.name} failed: ${error instanceof Error ? error.message : String(error)}` }];
|
|
96
|
+
}
|
|
97
|
+
const declared = new Set(plugin.rules.map((info) => info.id));
|
|
98
|
+
return findings.map((finding) =>
|
|
99
|
+
declared.has(finding.rule)
|
|
100
|
+
? finding
|
|
101
|
+
: { rule: "plugin-rule-undeclared", severity: "error", message: `plugin ${plugin.name} reported rule ${finding.rule}, which it does not declare`, ...(finding.line === undefined ? {} : { line: finding.line }) },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function applySeverity(settings: QualitySettings, findings: readonly QualityFinding[]): QualityFinding[] {
|
|
106
|
+
return sortFindings(
|
|
107
|
+
findings.flatMap((finding) => {
|
|
108
|
+
const severity = getEffectiveSeverity(settings, finding.rule, finding.severity);
|
|
109
|
+
return severity === null ? [] : [{ ...finding, severity }];
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The gate over article files for `softure-blog check` (FIRE_TRACKER `check-file.ts`): parse, rules,
|
|
2
|
+
// internal links resolved against the app and its content, external links when a fetch is given, and
|
|
3
|
+
// the glossary forms of the whole content folder (a form belongs to one term).
|
|
4
|
+
import type { ArticleFile } from "../db/publish-run.js";
|
|
5
|
+
import { parseArticleFile, type ParseArticleFileOptions } from "../content/article-file.js";
|
|
6
|
+
import { findTermFormConflicts, type GlossaryTerm, type TermFormConflict } from "../render/glossary.js";
|
|
7
|
+
import { checkArticleText } from "./check-article.js";
|
|
8
|
+
import { checkExternalLinks, type FetchLike } from "./external-links.js";
|
|
9
|
+
import { sortFindings, type QualityFinding } from "./finding.js";
|
|
10
|
+
import type { InternalLinkResolver } from "./rules/links.js";
|
|
11
|
+
import { getEffectiveSeverity, type QualitySettings } from "./settings.js";
|
|
12
|
+
|
|
13
|
+
export interface CheckArticleFilesOptions {
|
|
14
|
+
readonly settings: QualitySettings;
|
|
15
|
+
readonly today: string;
|
|
16
|
+
readonly resolveInternalLink: InternalLinkResolver;
|
|
17
|
+
readonly parse?: ParseArticleFileOptions;
|
|
18
|
+
/** When given, external links are requested over the network. */
|
|
19
|
+
readonly fetch?: FetchLike;
|
|
20
|
+
/** The site's published terms (`readGlossaryTerms`); a checked term that shares a form with another is reported. */
|
|
21
|
+
readonly glossary?: readonly GlossaryTerm[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FileCheckResult {
|
|
25
|
+
readonly file: string;
|
|
26
|
+
readonly findings: readonly QualityFinding[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function checkArticleFiles(files: readonly ArticleFile[], options: CheckArticleFilesOptions): Promise<FileCheckResult[]> {
|
|
30
|
+
const results: FileCheckResult[] = [];
|
|
31
|
+
const conflicts = findTermFormConflicts(options.glossary ?? []);
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const result = checkArticleText({
|
|
34
|
+
text: file.text,
|
|
35
|
+
fileName: file.name,
|
|
36
|
+
settings: options.settings,
|
|
37
|
+
today: options.today,
|
|
38
|
+
resolveInternalLink: options.resolveInternalLink,
|
|
39
|
+
...(options.parse === undefined ? {} : { parse: options.parse }),
|
|
40
|
+
});
|
|
41
|
+
const external = options.fetch === undefined ? [] : await checkExternalLinks(result.externalLinks, options.fetch);
|
|
42
|
+
const forms = checkTermForms(file, conflicts, options);
|
|
43
|
+
results.push({ file: file.name, findings: sortFindings([...result.findings, ...external, ...forms]) });
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `term-form-conflict` for a published term file whose forms another term claims too. */
|
|
49
|
+
function checkTermForms(file: ArticleFile, conflicts: readonly TermFormConflict[], options: CheckArticleFilesOptions): QualityFinding[] {
|
|
50
|
+
if (conflicts.length === 0) return [];
|
|
51
|
+
const parsed = parseArticleFile(file.text, file.name, options.parse);
|
|
52
|
+
if (!parsed.ok || parsed.article.kind !== "term" || parsed.article.status !== "published") return [];
|
|
53
|
+
const { slug } = parsed.article;
|
|
54
|
+
const severity = getEffectiveSeverity(options.settings, "term-form-conflict", "error");
|
|
55
|
+
if (severity === null) return [];
|
|
56
|
+
return conflicts
|
|
57
|
+
.filter((conflict) => conflict.slugs.includes(slug))
|
|
58
|
+
.map((conflict) => ({
|
|
59
|
+
rule: "term-form-conflict",
|
|
60
|
+
severity,
|
|
61
|
+
message: `form "${conflict.form}" is also a form of term ${conflict.slugs.filter((other) => other !== slug).join(", ")}; a form belongs to one term, so remove it from all but one`,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// External links answer 2xx (FIRE_TRACKER `src/lib/blog/quality/external-links.ts`). Over the network
|
|
2
|
+
// only on request (`softure-blog check --external`, the weekly workflow): tests and publishing never
|
|
3
|
+
// depend on other people's servers. `fetch` is injected so tests stay offline.
|
|
4
|
+
import type { QualityFinding } from "./finding.js";
|
|
5
|
+
|
|
6
|
+
export type FetchLike = (
|
|
7
|
+
url: string,
|
|
8
|
+
init: { readonly method: string; readonly redirect: "follow"; readonly signal: AbortSignal; readonly headers: Record<string, string> },
|
|
9
|
+
) => Promise<{ readonly status: number }>;
|
|
10
|
+
|
|
11
|
+
/** Some public-sector servers refuse requests that do not introduce themselves. */
|
|
12
|
+
const USER_AGENT = "softure-blog-link-check/1.0";
|
|
13
|
+
/** Servers without HEAD support answer with these; then the check asks with GET. */
|
|
14
|
+
const RETRY_WITH_GET = new Set([400, 403, 404, 405, 501]);
|
|
15
|
+
const TIMEOUT_MS = 15_000;
|
|
16
|
+
|
|
17
|
+
export async function checkExternalUrl(url: string, fetchImpl: FetchLike): Promise<{ ok: boolean; detail: string }> {
|
|
18
|
+
const request = (method: string) => fetchImpl(url, { method, redirect: "follow", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "user-agent": USER_AGENT } });
|
|
19
|
+
try {
|
|
20
|
+
let response = await request("HEAD");
|
|
21
|
+
if (RETRY_WITH_GET.has(response.status)) response = await request("GET");
|
|
22
|
+
return { ok: response.status >= 200 && response.status < 300, detail: `HTTP ${String(response.status)}` };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// A network failure is the finding itself: its message tells the editor what happened.
|
|
25
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Each address once, at its first line. */
|
|
30
|
+
export async function checkExternalLinks(links: readonly { readonly url: string; readonly line: number }[], fetchImpl: FetchLike): Promise<QualityFinding[]> {
|
|
31
|
+
const unique = new Map<string, number>();
|
|
32
|
+
for (const link of links) if (!unique.has(link.url)) unique.set(link.url, link.line);
|
|
33
|
+
const results = await Promise.all([...unique].map(async ([url, line]) => ({ url, line, ...(await checkExternalUrl(url, fetchImpl)) })));
|
|
34
|
+
return results
|
|
35
|
+
.filter((result) => !result.ok)
|
|
36
|
+
.map((result) => ({ rule: "external-link-dead", severity: "error", message: `an external link does not answer 2xx (${result.detail}): ${result.url}`, line: result.line }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A finding of the quality gate (FIRE_TRACKER `src/lib/blog/quality/finding.ts`). An error refuses a
|
|
2
|
+
// publish and fails `softure-blog check`; a warning only informs.
|
|
3
|
+
|
|
4
|
+
export const QUALITY_SEVERITIES = ["error", "warning"] as const;
|
|
5
|
+
export type QualitySeverity = (typeof QUALITY_SEVERITIES)[number];
|
|
6
|
+
|
|
7
|
+
export interface QualityFinding {
|
|
8
|
+
/** The stable id of the rule, used for severity overrides and by the writing skill. */
|
|
9
|
+
readonly rule: string;
|
|
10
|
+
readonly severity: QualitySeverity;
|
|
11
|
+
/** What is wrong and what to do instead, in English. */
|
|
12
|
+
readonly message: string;
|
|
13
|
+
/** The line of the file, from 1, when the rule points at one. */
|
|
14
|
+
readonly line?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hasQualityErrors(findings: readonly QualityFinding[]): boolean {
|
|
18
|
+
return findings.some((finding) => finding.severity === "error");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Errors first, then by line; findings without a line go first within their severity. */
|
|
22
|
+
export function sortFindings(findings: readonly QualityFinding[]): QualityFinding[] {
|
|
23
|
+
return [...findings].sort((a, b) => (a.severity === b.severity ? (a.line ?? 0) - (b.line ?? 0) : a.severity === "error" ? -1 : 1));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `[rule] line N: message`, the form the publish command prints. */
|
|
27
|
+
export function formatFinding(finding: QualityFinding): string {
|
|
28
|
+
return `[${finding.rule}]${finding.line === undefined ? "" : ` line ${String(finding.line)}`}: ${finding.message}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The quality gate for `softure-blog publish` (FIRE_TRACKER `src/lib/blog/quality/publish-gate.ts`):
|
|
2
|
+
// the errors of a file going public, one line each. Internal links are not resolved here, since a
|
|
3
|
+
// container that publishes may hold no app folder; `softure-blog check` in CI resolves them.
|
|
4
|
+
import type { Clock } from "@softure-ai/core";
|
|
5
|
+
import type { PublishGate } from "../db/publish-run.js";
|
|
6
|
+
import { checkArticle } from "./check-article.js";
|
|
7
|
+
import { formatFinding } from "./finding.js";
|
|
8
|
+
import { getLocalDate, type QualitySettings } from "./settings.js";
|
|
9
|
+
|
|
10
|
+
export function createQualityGate(settings: QualitySettings, clock: Clock): PublishGate {
|
|
11
|
+
return (file, article) => {
|
|
12
|
+
const { findings } = checkArticle({ article, text: file.text, settings, today: getLocalDate(clock.now(), settings.timeZone) });
|
|
13
|
+
return findings.filter((finding) => finding.severity === "error").map(formatFinding);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The quality gate's public surface, re-exported from `@softure-ai/blog/server`.
|
|
2
|
+
export { listQualityRules, QUALITY_RULE_GROUPS, type QualityCatalogRule, type QualityRuleGroup } from "./catalog.js";
|
|
3
|
+
export { checkArticle, checkArticleText, type CheckArticleInput, type CheckArticleTextInput, type QualityCheckResult } from "./check-article.js";
|
|
4
|
+
export { checkArticleFiles, type CheckArticleFilesOptions, type FileCheckResult } from "./check-files.js";
|
|
5
|
+
export { getProseBlocks, splitArticleBody, splitBlocks, type Block, type BlockKind } from "./blocks.js";
|
|
6
|
+
export { checkExternalLinks, checkExternalUrl, type FetchLike } from "./external-links.js";
|
|
7
|
+
export { formatFinding, hasQualityErrors, QUALITY_SEVERITIES, sortFindings, type QualityFinding, type QualitySeverity } from "./finding.js";
|
|
8
|
+
export { createQualityGate } from "./gate.js";
|
|
9
|
+
export { collectAppRoutes, createInternalLinkResolver, findAppDir, readContentFolder, readPublishedContent, type AppRoute, type InternalLinkResolverOptions } from "./link-targets.js";
|
|
10
|
+
export { qualityOptionsSchema, type QualityLimits, type QualityOptions, type QualityOptionsInput } from "./options.js";
|
|
11
|
+
export { isQualityPlugin, type QualityPlugin, type QualityPluginContext, type QualityRuleInfo } from "./plugin.js";
|
|
12
|
+
export { collectLinks, type InternalLinkResolver, type LinkSummary } from "./rules/links.js";
|
|
13
|
+
export { enRuleset, plRuleset, QUALITY_LANGUAGES, QUALITY_RULESETS, type LanguageRuleset, type QualityLanguage, type StylePattern } from "./rulesets/index.js";
|
|
14
|
+
export { getLocalDate, resolveQualitySettings, type QualitySettings } from "./settings.js";
|
|
15
|
+
export {
|
|
16
|
+
countWords,
|
|
17
|
+
findBareUrls,
|
|
18
|
+
findFootnoteRefs,
|
|
19
|
+
findLinks,
|
|
20
|
+
findSignificantNumbers,
|
|
21
|
+
normalizeNumber,
|
|
22
|
+
parseNumber,
|
|
23
|
+
splitSentences,
|
|
24
|
+
toProse,
|
|
25
|
+
wordPattern,
|
|
26
|
+
type MarkdownLink,
|
|
27
|
+
type NumberNotation,
|
|
28
|
+
} from "./text.js";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Internal link targets from the file system (FIRE_TRACKER `src/lib/blog/quality/link-targets.ts`),
|
|
2
|
+
// for `softure-blog check`. A route exists when the Next.js app folder has a `page.*` or `route.*` for
|
|
3
|
+
// it (route groups vanish from the path; private segments and `_folders` are skipped). An article or
|
|
4
|
+
// a glossary term exists when the content folder has its published file of that kind, under
|
|
5
|
+
// `quality.paths`; a static page under the same path wins over the dynamic article route.
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parseArticleFile, type ParseArticleFileOptions } from "../content/article-file.js";
|
|
9
|
+
import type { BlogArticleKind } from "../contract.js";
|
|
10
|
+
import type { GlossaryTerm } from "../render/glossary.js";
|
|
11
|
+
import type { InternalLinkResolver } from "./rules/links.js";
|
|
12
|
+
|
|
13
|
+
const ROUTE_FILES = /^(?:page|route)\.(?:tsx?|jsx?|mdx?)$/;
|
|
14
|
+
|
|
15
|
+
export interface AppRoute {
|
|
16
|
+
readonly pattern: RegExp;
|
|
17
|
+
/** Has a `[param]` segment. */
|
|
18
|
+
readonly isDynamic: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InternalLinkResolverOptions {
|
|
22
|
+
/** The Next.js app folder, or `null` when the app has none (only content links resolve). */
|
|
23
|
+
readonly appDir: string | null;
|
|
24
|
+
readonly privateRouteSegments: readonly string[];
|
|
25
|
+
readonly paths: { readonly articles: string; readonly terms: string };
|
|
26
|
+
/** Published texts by slug. */
|
|
27
|
+
readonly content: ReadonlyMap<string, BlogArticleKind>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The app folder to read: the given one, else `src/app`, else `app`, else none. */
|
|
31
|
+
export function findAppDir(cwd: string, appDir: string | undefined): string | null {
|
|
32
|
+
if (appDir !== undefined) return join(cwd, appDir);
|
|
33
|
+
return [join(cwd, "src/app"), join(cwd, "app")].find((dir) => existsSync(dir)) ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function collectAppRoutes(appDir: string, privateSegments: readonly string[]): AppRoute[] {
|
|
37
|
+
const routes: AppRoute[] = [];
|
|
38
|
+
const walk = (dir: string, segments: readonly string[]): void => {
|
|
39
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
if (entries.some((entry) => entry.isFile() && ROUTE_FILES.test(entry.name))) {
|
|
41
|
+
const visible = segments.filter((segment) => !/^\(.*\)$/.test(segment));
|
|
42
|
+
const pattern = visible.map((segment) => (/^\[.*\]$/.test(segment) ? "[^/]+" : escapeRegExp(segment))).join("/");
|
|
43
|
+
routes.push({ pattern: new RegExp(`^/${pattern}$`), isDynamic: visible.some((segment) => /^\[.*\]$/.test(segment)) });
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (entry.isDirectory() && !privateSegments.includes(entry.name) && !entry.name.startsWith("_")) walk(join(dir, entry.name), [...segments, entry.name]);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(appDir, []);
|
|
50
|
+
return routes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Published texts in the content folders by slug: the file name is the slug (BL-2), the kind and status come from the frontmatter. */
|
|
54
|
+
export function readPublishedContent(files: readonly { readonly name: string; readonly text: string }[]): Map<string, BlogArticleKind> {
|
|
55
|
+
const content = new Map<string, BlogArticleKind>();
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(file.text)?.[1] ?? "";
|
|
58
|
+
if (!/^status:\s*(["']?)published\1\s*(?:#.*)?$/m.test(frontmatter)) continue;
|
|
59
|
+
content.set(file.name.replace(/\.md$/, ""), /^kind:\s*(["']?)term\1\s*(?:#.*)?$/m.test(frontmatter) ? "term" : "article");
|
|
60
|
+
}
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The published glossary terms of the content folders, for the form conflict check: a later file
|
|
66
|
+
* with the same slug replaces an earlier one (a checked file over its copy in the folder). A file that
|
|
67
|
+
* does not parse is skipped; its own check reports it.
|
|
68
|
+
*/
|
|
69
|
+
export function readGlossaryTerms(files: readonly { readonly name: string; readonly text: string }[], parse: ParseArticleFileOptions = {}): GlossaryTerm[] {
|
|
70
|
+
const terms = new Map<string, GlossaryTerm>();
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const parsed = parseArticleFile(file.text, file.name, parse);
|
|
73
|
+
if (!parsed.ok) continue;
|
|
74
|
+
const { slug, kind, status, termForms } = parsed.article;
|
|
75
|
+
if (kind === "term" && status === "published") terms.set(slug, { slug, forms: termForms });
|
|
76
|
+
else terms.delete(slug);
|
|
77
|
+
}
|
|
78
|
+
return [...terms.values()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createInternalLinkResolver(options: InternalLinkResolverOptions): InternalLinkResolver {
|
|
82
|
+
const routes = options.appDir === null || !existsSync(options.appDir) ? [] : collectAppRoutes(options.appDir, options.privateRouteSegments);
|
|
83
|
+
const term = new RegExp(`^${escapeRegExp(options.paths.terms)}/([^/]+)$`);
|
|
84
|
+
const article = new RegExp(`^${escapeRegExp(options.paths.articles === "/" ? "" : options.paths.articles)}/([^/]+)$`);
|
|
85
|
+
const isStaticRoute = (pathname: string) => routes.some((route) => !route.isDynamic && route.pattern.test(pathname));
|
|
86
|
+
return (rawPathname) => {
|
|
87
|
+
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/$/, "") : rawPathname;
|
|
88
|
+
const termSlug = term.exec(pathname)?.[1];
|
|
89
|
+
if (termSlug !== undefined) return options.content.get(termSlug) === "term" || isStaticRoute(pathname);
|
|
90
|
+
const articleSlug = article.exec(pathname)?.[1];
|
|
91
|
+
if (articleSlug !== undefined) return options.content.get(articleSlug) === "article" || isStaticRoute(pathname);
|
|
92
|
+
return routes.some((route) => route.pattern.test(pathname));
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reads `*.md` files of a folder except README.md. */
|
|
97
|
+
export function readContentFolder(dir: string): { name: string; text: string }[] {
|
|
98
|
+
if (!existsSync(dir)) return [];
|
|
99
|
+
return readdirSync(dir)
|
|
100
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
|
101
|
+
.sort()
|
|
102
|
+
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeRegExp(text: string): string {
|
|
106
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
107
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `blog({ quality })`: the gate's options, parsed at startup with the rest of the blog's options.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { BlockPlugin } from "../render/render-article.js";
|
|
4
|
+
import { QUALITY_SEVERITIES } from "./finding.js";
|
|
5
|
+
import { isQualityPlugin, type QualityPlugin } from "./plugin.js";
|
|
6
|
+
import { QUALITY_LANGUAGES } from "./rulesets/types.js";
|
|
7
|
+
|
|
8
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
9
|
+
|
|
10
|
+
function isBlockPlugin(value: unknown): value is BlockPlugin {
|
|
11
|
+
if (typeof value !== "object" || value === null) return false;
|
|
12
|
+
const candidate = value as { type?: unknown; render?: unknown };
|
|
13
|
+
return typeof candidate.type === "string" && typeof candidate.render === "function";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const range = (min: number, max: number) =>
|
|
17
|
+
z
|
|
18
|
+
.strictObject({ min: z.number().int().min(0).default(min), max: z.number().int().positive().default(max) })
|
|
19
|
+
.prefault({})
|
|
20
|
+
.refine((value) => value.min <= value.max, "min must not exceed max");
|
|
21
|
+
|
|
22
|
+
const byKind = (article: number, term: number) =>
|
|
23
|
+
z.strictObject({ article: z.number().int().min(0).default(article), term: z.number().int().min(0).default(term) }).prefault({});
|
|
24
|
+
|
|
25
|
+
/** Thresholds; the defaults are FIRE's, chosen for answer-first texts that AI assistants quote. */
|
|
26
|
+
export const qualityLimitsSchema = z
|
|
27
|
+
.strictObject({
|
|
28
|
+
words: z.strictObject({ article: range(600, 4000), term: range(60, 700) }).prefault({}),
|
|
29
|
+
leadWords: byKind(90, 60),
|
|
30
|
+
answerWords: z.number().int().positive().default(70),
|
|
31
|
+
internalLinks: byKind(2, 1),
|
|
32
|
+
staleAfterDays: z.number().int().positive().default(365),
|
|
33
|
+
titleChars: z.number().int().positive().default(70),
|
|
34
|
+
descriptionChars: range(50, 160),
|
|
35
|
+
wordsPerDash: z.number().int().positive().default(150),
|
|
36
|
+
wordsPerBold: z.number().int().positive().default(200),
|
|
37
|
+
sentenceWords: z.number().int().positive().default(35),
|
|
38
|
+
})
|
|
39
|
+
.prefault({});
|
|
40
|
+
|
|
41
|
+
const sitePath = z.string().regex(/^\/[a-z0-9/_-]*$/, "must be a path from the site root, e.g. /blog").transform((path) => (path.length > 1 ? path.replace(/\/$/, "") : path));
|
|
42
|
+
|
|
43
|
+
export const qualityOptionsSchema = z.strictObject({
|
|
44
|
+
/** The ruleset of the blog's language. */
|
|
45
|
+
language: z.enum(QUALITY_LANGUAGES).default("en"),
|
|
46
|
+
/** Your money or your life: sources required, every significant number footnoted, no profit promises. */
|
|
47
|
+
ymyl: z
|
|
48
|
+
.union([z.boolean(), z.strictObject({ ownCalculationMark: z.string().trim().min(1).optional() })])
|
|
49
|
+
.default(false)
|
|
50
|
+
.transform((value) => (value === false ? null : { ownCalculationMark: value === true ? null : (value.ownCalculationMark ?? null) })),
|
|
51
|
+
voice: z
|
|
52
|
+
.strictObject({
|
|
53
|
+
/** Texts signed by an editorial team: no "I", "my", "in my opinion". */
|
|
54
|
+
forbidFirstPersonSingular: z.boolean().default(false),
|
|
55
|
+
/**
|
|
56
|
+
* The brand's banned phrases: a global regular expression, matched as given. `wordPattern("...")`
|
|
57
|
+
* builds one that is case-insensitive and knows word edges in any alphabet.
|
|
58
|
+
*/
|
|
59
|
+
phrases: z
|
|
60
|
+
.array(
|
|
61
|
+
z.strictObject({
|
|
62
|
+
id: z.string().max(60).regex(KEBAB, "kebab-case, e.g. finance-cliche"),
|
|
63
|
+
pattern: z.instanceof(RegExp, { error: "must be a regular expression" }).refine((pattern) => pattern.global, "must be global (flag g), so every hit counts"),
|
|
64
|
+
message: z.string().trim().min(1),
|
|
65
|
+
severity: z.enum(QUALITY_SEVERITIES).default("error"),
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
.default([]),
|
|
69
|
+
})
|
|
70
|
+
.prefault({}),
|
|
71
|
+
limits: qualityLimitsSchema,
|
|
72
|
+
/** Per rule: another severity, or "off". */
|
|
73
|
+
severity: z.record(z.string().regex(KEBAB), z.enum(["error", "warning", "off"])).default({}),
|
|
74
|
+
/** Where the pages live; BL-4 serves them there. */
|
|
75
|
+
paths: z.strictObject({ articles: sitePath.default("/blog"), terms: sitePath.default("/blog/glossary") }).prefault({}),
|
|
76
|
+
/** Absolute origins whose links count as internal, besides the config's `appOrigin` and the canonical site origin (`getSiteUrls`). */
|
|
77
|
+
ownOrigins: z.array(z.url({ protocol: /^https?$/ })).default([]),
|
|
78
|
+
/** The Next.js app folder `softure-blog check` reads routes from. Default: `src/app`, else `app`. */
|
|
79
|
+
appDir: z.string().trim().min(1).optional(),
|
|
80
|
+
/** Route folders that are no link target (API, pages behind a login). Route groups count too, e.g. "(app)". */
|
|
81
|
+
privateRouteSegments: z.array(z.string().min(1)).default(["api"]),
|
|
82
|
+
plugins: z.array(z.custom<QualityPlugin>(isQualityPlugin, "must be a plugin: { name, rules, check }")).default([]),
|
|
83
|
+
/** The block plugins the app renders with (`renderArticle({ blocks })`): their `requires` keys must be in the frontmatter. */
|
|
84
|
+
blocks: z.array(z.custom<BlockPlugin>(isBlockPlugin, "must be a block plugin: { type, render }")).default([]),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export type QualityOptionsInput = z.input<typeof qualityOptionsSchema>;
|
|
88
|
+
export type QualityOptions = z.output<typeof qualityOptionsSchema>;
|
|
89
|
+
export type QualityLimits = QualityOptions["limits"];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `blog({ quality })`: the options, or `false` to turn the gate off. Not a zod union, whose error
|
|
93
|
+
* would read "Invalid input" and hide which option is wrong: the options' own issues are reported.
|
|
94
|
+
* The cast gives it the types of a defaulted option: optional in, never undefined out.
|
|
95
|
+
*/
|
|
96
|
+
export const qualitySettingSchema = z
|
|
97
|
+
.unknown()
|
|
98
|
+
.optional()
|
|
99
|
+
.transform((value, ctx): QualityOptions | false => {
|
|
100
|
+
if (value === false) return false;
|
|
101
|
+
const parsed = qualityOptionsSchema.safeParse(value ?? {});
|
|
102
|
+
if (parsed.success) return parsed.data;
|
|
103
|
+
for (const issue of parsed.error.issues) ctx.addIssue({ code: "custom", message: issue.message, path: issue.path });
|
|
104
|
+
return z.NEVER;
|
|
105
|
+
}) as unknown as z.ZodDefault<z.ZodType<QualityOptions | false, QualityOptionsInput | false>>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The rule plugin API: an app adds its domain rules (FIRE: legal figures equal the engine's, numbers
|
|
2
|
+
// next to a chart stand in its table) to `blog({ quality: { plugins } })`.
|
|
3
|
+
import type { BlogArticleInput } from "../contract.js";
|
|
4
|
+
import type { FoundBlock } from "../render/render-article.js";
|
|
5
|
+
import type { Block } from "./blocks.js";
|
|
6
|
+
import type { QualityFinding, QualitySeverity } from "./finding.js";
|
|
7
|
+
import type { LanguageRuleset } from "./rulesets/types.js";
|
|
8
|
+
|
|
9
|
+
/** A rule as the catalog lists it: what the writing skill names and the gate enforces. */
|
|
10
|
+
export interface QualityRuleInfo {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
/** The default severity; `quality.severity` overrides it. */
|
|
13
|
+
readonly severity: QualitySeverity;
|
|
14
|
+
readonly description: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QualityPluginContext {
|
|
18
|
+
/** The parsed file: frontmatter values, the app's `fields` and the Markdown body. */
|
|
19
|
+
readonly article: BlogArticleInput;
|
|
20
|
+
/** The body cut into blocks with file lines; fenced code is skipped, `::name{…}` directives are blocks of their own. */
|
|
21
|
+
readonly blocks: readonly Block[];
|
|
22
|
+
/** The fenced blocks of the app's block plugins (`quality.blocks`), with the file line of the opening fence. */
|
|
23
|
+
readonly pluginBlocks: readonly FoundBlock[];
|
|
24
|
+
/** `YYYY-MM-DD` in the app's time zone. */
|
|
25
|
+
readonly today: string;
|
|
26
|
+
/** The app's language ruleset, for its number notation. */
|
|
27
|
+
readonly ruleset: LanguageRuleset;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface QualityPlugin {
|
|
31
|
+
/** Names the plugin in a failure message. */
|
|
32
|
+
readonly name: string;
|
|
33
|
+
/** Every rule id the plugin may report. A finding with another id is refused as `plugin-rule-undeclared`. */
|
|
34
|
+
readonly rules: readonly QualityRuleInfo[];
|
|
35
|
+
/** Pure: no network, no file system. A throw becomes a `plugin-failed` error. */
|
|
36
|
+
readonly check: (context: QualityPluginContext) => readonly QualityFinding[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isQualityPlugin(value: unknown): value is QualityPlugin {
|
|
40
|
+
if (typeof value !== "object" || value === null) return false;
|
|
41
|
+
const candidate = value as { name?: unknown; rules?: unknown; check?: unknown };
|
|
42
|
+
return typeof candidate.name === "string" && Array.isArray(candidate.rules) && typeof candidate.check === "function";
|
|
43
|
+
}
|