@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/src/cli/run.ts
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
// `softure-blog`: publishes the app's article files into the module's tables. The app passes its
|
|
2
|
+
// validated config, so the same function serves the bin and an app script that a bundler packs
|
|
3
|
+
// for a container:
|
|
4
|
+
//
|
|
5
|
+
// // scripts/blog.ts
|
|
6
|
+
// import { runBlogCli } from "@softure-ai/blog/cli";
|
|
7
|
+
// import config from "../softure.config";
|
|
8
|
+
// process.exitCode = await runBlogCli({ config, argv: process.argv.slice(2) });
|
|
9
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { systemClock, type Clock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
|
|
13
|
+
import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
|
|
14
|
+
import { BLOG_REFRESH_SECRET_ENV, requestBlogRefresh, type BlogRefreshOutcome } from "../discovery/refresh.js";
|
|
15
|
+
import { submitBlogChanges, type BlogIndexNowSubmit } from "../discovery/submit.js";
|
|
16
|
+
import { runBlogPublish, type ArticleFile, type BlogPublishRun, type PublishedChange, type PublishGate, type PublishProblem } from "../db/publish-run.js";
|
|
17
|
+
import { checkArticleFiles, type FileCheckResult } from "../quality/check-files.js";
|
|
18
|
+
import type { FetchLike } from "../quality/external-links.js";
|
|
19
|
+
import { createQualityGate } from "../quality/gate.js";
|
|
20
|
+
import { createInternalLinkResolver, findAppDir, readContentFolder, readGlossaryTerms, readPublishedContent } from "../quality/link-targets.js";
|
|
21
|
+
import { getLocalDate } from "../quality/settings.js";
|
|
22
|
+
import type { OgFontSource } from "../options.js";
|
|
23
|
+
import { createOgFontLoader } from "../server/og-fonts.js";
|
|
24
|
+
import { getBlogOptions, getBlogReservedSlugs, getQualitySettings } from "../server/options.js";
|
|
25
|
+
import { DEFAULT_SKILL_COMMAND, DEFAULT_SKILL_DIR, renderBlogSkill, SKILL_MARKER, type SkillFile } from "./skill.js";
|
|
26
|
+
|
|
27
|
+
export interface CliOutput {
|
|
28
|
+
readonly log: (line: string) => void;
|
|
29
|
+
readonly error: (line: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunBlogCliOptions {
|
|
33
|
+
/** The app's config with `blog()` among its modules. */
|
|
34
|
+
readonly config: SoftureConfig;
|
|
35
|
+
/** The arguments after the executable, e.g. `["publish", "content/blog", "--commit"]`. */
|
|
36
|
+
readonly argv: readonly string[];
|
|
37
|
+
/** Relative paths resolve against it. Default: `process.cwd()`. */
|
|
38
|
+
readonly cwd?: string;
|
|
39
|
+
readonly output?: CliOutput;
|
|
40
|
+
/** Opens the database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
|
|
41
|
+
readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
|
|
42
|
+
/** The gate for files going public. Default: the quality gate of `blog({ quality })`, none with `quality: false`. */
|
|
43
|
+
readonly gate?: PublishGate;
|
|
44
|
+
readonly clock?: Clock;
|
|
45
|
+
/** For `check --external`. Default: the global `fetch`. */
|
|
46
|
+
readonly fetch?: FetchLike;
|
|
47
|
+
/** For `check` reading the https sources of `brand.fonts`. Default: the global `fetch`. */
|
|
48
|
+
readonly fontFetch?: typeof fetch;
|
|
49
|
+
/** For the IndexNow submit after `publish`. Default: the global `fetch`. */
|
|
50
|
+
readonly indexNowFetch?: typeof fetch;
|
|
51
|
+
/** For the cache refresh request after `publish`. Default: the global `fetch`. */
|
|
52
|
+
readonly refreshFetch?: typeof fetch;
|
|
53
|
+
/** Where `publish` reads BLOG_REFRESH_SECRET. Default: `process.env`. */
|
|
54
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const EXIT_OK = 0;
|
|
58
|
+
export const EXIT_FAILED = 1;
|
|
59
|
+
export const EXIT_USAGE = 2;
|
|
60
|
+
|
|
61
|
+
export const BLOG_USAGE = `Usage:
|
|
62
|
+
softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>]
|
|
63
|
+
softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>]
|
|
64
|
+
softure-blog skill install [--dir <path>] [--command <cmd>] [--check]
|
|
65
|
+
|
|
66
|
+
publish Brings the blog's tables to the state of the article files. A <path> is a file or a
|
|
67
|
+
folder (every *.md in it except README.md); without one, blog({ contentDir }).
|
|
68
|
+
Every file is checked before the first write, and one problem writes nothing.
|
|
69
|
+
--commit write the changes; without it, a dry run that shows them and writes nothing
|
|
70
|
+
--withdraw publish the one given file as withdrawn, whatever its status
|
|
71
|
+
--no-indexnow do not submit the changed addresses to IndexNow
|
|
72
|
+
--app-url the running app's origin for the cache refresh; default: appOrigin
|
|
73
|
+
Files going public pass the quality gate first; an error writes nothing.
|
|
74
|
+
With ${BLOG_REFRESH_SECRET_ENV} set, a commit that changed a text asks the running
|
|
75
|
+
app (refreshBlogCache) to refresh its blog cache, before the IndexNow submit.
|
|
76
|
+
With seo({ indexNow }) enabled, a commit submits the addresses whose answer
|
|
77
|
+
changed (the text, its old slug, its listing) to IndexNow; a dry run prints them.
|
|
78
|
+
|
|
79
|
+
check Runs the quality gate of blog({ quality }) over the files, without a database, and
|
|
80
|
+
prints every finding as file:line: severity [rule] message. Exits 1 on any error.
|
|
81
|
+
--external also request every external link (2xx after redirects)
|
|
82
|
+
--today the date to check freshness against; default: today in the app's time zone
|
|
83
|
+
|
|
84
|
+
skill install
|
|
85
|
+
Writes the article writing skill, filled from blog({ quality, skill }),
|
|
86
|
+
into ${DEFAULT_SKILL_DIR}. Overwrites only a skill it generated before.
|
|
87
|
+
--dir the skill folder; default ${DEFAULT_SKILL_DIR}
|
|
88
|
+
--command how the skill runs this command; default "${DEFAULT_SKILL_COMMAND}"
|
|
89
|
+
--check write nothing; exit 1 when the folder differs from what install would write
|
|
90
|
+
|
|
91
|
+
Options:
|
|
92
|
+
--config <file> the app's softure.config file (bin only)
|
|
93
|
+
--help show this help`;
|
|
94
|
+
|
|
95
|
+
const consoleOutput: CliOutput = {
|
|
96
|
+
log: (line) => console.log(line),
|
|
97
|
+
error: (line) => console.error(line),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type BlogCommand =
|
|
101
|
+
| { readonly kind: "help" }
|
|
102
|
+
| {
|
|
103
|
+
readonly kind: "publish";
|
|
104
|
+
readonly paths: readonly string[];
|
|
105
|
+
readonly commit: boolean;
|
|
106
|
+
readonly withdraw: boolean;
|
|
107
|
+
readonly indexNow: boolean;
|
|
108
|
+
/** The origin `--app-url` gives; `null`: `appOrigin`. */
|
|
109
|
+
readonly appUrl: string | null;
|
|
110
|
+
}
|
|
111
|
+
| { readonly kind: "check"; readonly paths: readonly string[]; readonly external: boolean; readonly today: string | null }
|
|
112
|
+
| { readonly kind: "skill-install"; readonly dir: string; readonly command: string; readonly check: boolean };
|
|
113
|
+
|
|
114
|
+
/** Runs the command and returns the process exit code: 0 done, 1 refused or failed, 2 usage error. */
|
|
115
|
+
export async function runBlogCli(options: RunBlogCliOptions): Promise<number> {
|
|
116
|
+
const output = options.output ?? consoleOutput;
|
|
117
|
+
const command = parseBlogCommand(options.argv);
|
|
118
|
+
if (typeof command === "string") {
|
|
119
|
+
output.error(`softure-blog: ${command}`);
|
|
120
|
+
output.error(BLOG_USAGE);
|
|
121
|
+
return EXIT_USAGE;
|
|
122
|
+
}
|
|
123
|
+
if (command.kind === "help") {
|
|
124
|
+
output.log(BLOG_USAGE);
|
|
125
|
+
return EXIT_OK;
|
|
126
|
+
}
|
|
127
|
+
if (command.kind === "skill-install") return runSkillInstall(command, options, output);
|
|
128
|
+
return command.kind === "check" ? runCheck(command, options, output) : runPublish(command, options, output);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The command the arguments name, or why they name none. Reads nothing. */
|
|
132
|
+
export function parseBlogCommand(argv: readonly string[]): BlogCommand | string {
|
|
133
|
+
const [name, ...rest] = argv;
|
|
134
|
+
if (name === undefined) return "missing command; use publish, check or skill install";
|
|
135
|
+
if (name === "--help") return { kind: "help" };
|
|
136
|
+
if (name === "check") return parseCheckCommand(rest);
|
|
137
|
+
if (name === "skill") return parseSkillCommand(rest);
|
|
138
|
+
if (name !== "publish") return `unknown command "${name}"; use publish, check or skill install`;
|
|
139
|
+
|
|
140
|
+
let parsed: ReturnType<typeof parsePublishArgs>;
|
|
141
|
+
try {
|
|
142
|
+
parsed = parsePublishArgs(rest);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return error instanceof Error ? error.message : String(error);
|
|
145
|
+
}
|
|
146
|
+
const { values, positionals } = parsed;
|
|
147
|
+
if (values.help === true) return { kind: "help" };
|
|
148
|
+
const withdraw = values.withdraw === true;
|
|
149
|
+
if (withdraw && positionals.length !== 1) return "--withdraw takes exactly one article file";
|
|
150
|
+
const appUrl = values["app-url"] === undefined ? null : parseOrigin(values["app-url"]);
|
|
151
|
+
if (appUrl === undefined) return `--app-url needs an http or https origin, e.g. http://web:3000, not "${values["app-url"] ?? ""}"`;
|
|
152
|
+
return { kind: "publish", paths: positionals, commit: values.commit === true, withdraw, indexNow: values["no-indexnow"] !== true, appUrl };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The origin of an http(s) URL, or `undefined` for anything else. */
|
|
156
|
+
function parseOrigin(value: string): string | undefined {
|
|
157
|
+
try {
|
|
158
|
+
const url = new URL(value);
|
|
159
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined;
|
|
160
|
+
} catch {
|
|
161
|
+
// The caller names the value.
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseCheckCommand(args: readonly string[]): BlogCommand | string {
|
|
167
|
+
let parsed: ReturnType<typeof parseCheckArgs>;
|
|
168
|
+
try {
|
|
169
|
+
parsed = parseCheckArgs(args);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return error instanceof Error ? error.message : String(error);
|
|
172
|
+
}
|
|
173
|
+
const { values, positionals } = parsed;
|
|
174
|
+
if (values.help === true) return { kind: "help" };
|
|
175
|
+
const today = values.today ?? null;
|
|
176
|
+
if (today !== null && (!/^\d{4}-\d{2}-\d{2}$/.test(today) || Number.isNaN(Date.parse(`${today}T00:00:00Z`)))) return `--today needs a date YYYY-MM-DD, not "${today}"`;
|
|
177
|
+
return { kind: "check", paths: positionals, external: values.external === true, today };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseSkillCommand(args: readonly string[]): BlogCommand | string {
|
|
181
|
+
const [subcommand, ...rest] = args;
|
|
182
|
+
if (subcommand === "--help") return { kind: "help" };
|
|
183
|
+
if (subcommand !== "install") return subcommand === undefined ? "skill needs a subcommand: install" : `unknown skill subcommand "${subcommand}"; use install`;
|
|
184
|
+
let parsed: ReturnType<typeof parseSkillArgs>;
|
|
185
|
+
try {
|
|
186
|
+
parsed = parseSkillArgs(rest);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return error instanceof Error ? error.message : String(error);
|
|
189
|
+
}
|
|
190
|
+
const { values, positionals } = parsed;
|
|
191
|
+
if (values.help === true) return { kind: "help" };
|
|
192
|
+
if (positionals.length > 0) return `skill install takes no paths; use --dir <path>, not "${positionals.join(" ")}"`;
|
|
193
|
+
const dir = values.dir ?? DEFAULT_SKILL_DIR;
|
|
194
|
+
const command = values.command ?? DEFAULT_SKILL_COMMAND;
|
|
195
|
+
if (dir.trim() === "") return "--dir needs a folder";
|
|
196
|
+
if (command.trim() === "") return "--command needs the command, e.g. \"npm run blog --\"";
|
|
197
|
+
return { kind: "skill-install", dir, command: command.trim(), check: values.check === true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseSkillArgs(args: readonly string[]) {
|
|
201
|
+
return parseArgs({
|
|
202
|
+
args: [...args],
|
|
203
|
+
options: {
|
|
204
|
+
dir: { type: "string" },
|
|
205
|
+
command: { type: "string" },
|
|
206
|
+
check: { type: "boolean" },
|
|
207
|
+
help: { type: "boolean" },
|
|
208
|
+
},
|
|
209
|
+
strict: true,
|
|
210
|
+
allowPositionals: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseCheckArgs(args: readonly string[]) {
|
|
215
|
+
return parseArgs({
|
|
216
|
+
args: [...args],
|
|
217
|
+
options: {
|
|
218
|
+
external: { type: "boolean" },
|
|
219
|
+
today: { type: "string" },
|
|
220
|
+
help: { type: "boolean" },
|
|
221
|
+
},
|
|
222
|
+
strict: true,
|
|
223
|
+
allowPositionals: true,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parsePublishArgs(args: readonly string[]) {
|
|
228
|
+
return parseArgs({
|
|
229
|
+
args: [...args],
|
|
230
|
+
options: {
|
|
231
|
+
commit: { type: "boolean" },
|
|
232
|
+
withdraw: { type: "boolean" },
|
|
233
|
+
"no-indexnow": { type: "boolean" },
|
|
234
|
+
"app-url": { type: "string" },
|
|
235
|
+
help: { type: "boolean" },
|
|
236
|
+
},
|
|
237
|
+
strict: true,
|
|
238
|
+
allowPositionals: true,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, options: RunBlogCliOptions, output: CliOutput): Promise<number> {
|
|
243
|
+
const cwd = options.cwd ?? process.cwd();
|
|
244
|
+
const { config } = options;
|
|
245
|
+
let blogOptions: ReturnType<typeof getBlogOptions>;
|
|
246
|
+
try {
|
|
247
|
+
blogOptions = getBlogOptions(config);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
output.error(`softure-blog publish: ${describeError(error)}`);
|
|
250
|
+
return EXIT_FAILED;
|
|
251
|
+
}
|
|
252
|
+
const paths = command.paths.length > 0 ? command.paths : [blogOptions.contentDir];
|
|
253
|
+
const files = await readArticleFiles(paths.map((path) => resolve(cwd, path)));
|
|
254
|
+
if (typeof files === "string") {
|
|
255
|
+
output.error(`softure-blog publish: ${files}`);
|
|
256
|
+
return EXIT_FAILED;
|
|
257
|
+
}
|
|
258
|
+
if (command.withdraw && files.length !== 1) {
|
|
259
|
+
output.error("softure-blog publish: --withdraw takes exactly one article file, not a folder");
|
|
260
|
+
return EXIT_FAILED;
|
|
261
|
+
}
|
|
262
|
+
if (config.database === null) {
|
|
263
|
+
output.error("softure-blog publish: the config has no database; set database.url in softure.config");
|
|
264
|
+
return EXIT_FAILED;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const clock = options.clock ?? systemClock;
|
|
268
|
+
let gate = options.gate;
|
|
269
|
+
if (gate === undefined) {
|
|
270
|
+
const settings = getQualitySettings(config);
|
|
271
|
+
if (settings !== null) gate = createQualityGate(settings, clock);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let opened: CommandDatabase;
|
|
275
|
+
try {
|
|
276
|
+
opened = await openDatabase(config.database, options.openDatabase);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
output.error(`softure-blog publish: ${describeError(error)}`);
|
|
279
|
+
return EXIT_FAILED;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const run = await runBlogPublish(
|
|
283
|
+
{ db: opened.handle.db, clock, config },
|
|
284
|
+
files,
|
|
285
|
+
{
|
|
286
|
+
commit: command.commit,
|
|
287
|
+
withdraw: command.withdraw,
|
|
288
|
+
reservedSlugs: getBlogReservedSlugs(config),
|
|
289
|
+
...(blogOptions.fields === undefined ? {} : { fields: blogOptions.fields }),
|
|
290
|
+
...(gate === undefined ? {} : { gate }),
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
const code = reportRun(run, output);
|
|
294
|
+
if (run.status === "done") {
|
|
295
|
+
// Before the IndexNow submit: a crawler that answers the ping must find the new text.
|
|
296
|
+
const refresh = await requestBlogRefresh(config, run.changes, {
|
|
297
|
+
commit: run.committed,
|
|
298
|
+
...(command.appUrl === null ? {} : { appUrl: command.appUrl }),
|
|
299
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
300
|
+
...(options.refreshFetch === undefined ? {} : { fetchImpl: options.refreshFetch }),
|
|
301
|
+
});
|
|
302
|
+
reportRefresh(refresh, output);
|
|
303
|
+
}
|
|
304
|
+
if (run.status === "done" && command.indexNow) {
|
|
305
|
+
reportIndexNow(await submitBlogChanges(config, run.changes, { commit: run.committed, ...(options.indexNowFetch === undefined ? {} : { fetchImpl: options.indexNowFetch }) }), output);
|
|
306
|
+
}
|
|
307
|
+
return code;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
// Driver errors: the message only, never a stack or the database URL.
|
|
310
|
+
output.error(`softure-blog publish: ${describeError(error)} (did softure migrate run?)`);
|
|
311
|
+
return EXIT_FAILED;
|
|
312
|
+
} finally {
|
|
313
|
+
await opened.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function runCheck(command: Extract<BlogCommand, { kind: "check" }>, options: RunBlogCliOptions, output: CliOutput): Promise<number> {
|
|
318
|
+
const cwd = options.cwd ?? process.cwd();
|
|
319
|
+
let blogOptions: ReturnType<typeof getBlogOptions>;
|
|
320
|
+
let settings: ReturnType<typeof getQualitySettings>;
|
|
321
|
+
try {
|
|
322
|
+
blogOptions = getBlogOptions(options.config);
|
|
323
|
+
settings = getQualitySettings(options.config);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
output.error(`softure-blog check: ${describeError(error)}`);
|
|
326
|
+
return EXIT_FAILED;
|
|
327
|
+
}
|
|
328
|
+
if (settings === null) {
|
|
329
|
+
output.error("softure-blog check: the quality gate is off (blog({ quality: false })); nothing to check");
|
|
330
|
+
return EXIT_FAILED;
|
|
331
|
+
}
|
|
332
|
+
// The OG card's fonts, read as the card's route reads them (paths from the app's root), so CI
|
|
333
|
+
// reports a source the first card would fail on.
|
|
334
|
+
const fontProblem = await checkBrandFonts(blogOptions.brand?.fonts, cwd, options.fontFetch);
|
|
335
|
+
if (fontProblem !== null) output.error(`softure-blog check: ${fontProblem}`);
|
|
336
|
+
const fontErrors = fontProblem === null ? 0 : 1;
|
|
337
|
+
const contentDir = resolve(cwd, blogOptions.contentDir);
|
|
338
|
+
const paths = command.paths.length > 0 ? command.paths.map((path) => resolve(cwd, path)) : [contentDir];
|
|
339
|
+
const files = await readArticleFiles(paths);
|
|
340
|
+
if (typeof files === "string") {
|
|
341
|
+
output.error(`softure-blog check: ${files}`);
|
|
342
|
+
return EXIT_FAILED;
|
|
343
|
+
}
|
|
344
|
+
if (files.length === 0) {
|
|
345
|
+
output.log("check: no article files");
|
|
346
|
+
return fontErrors > 0 ? EXIT_FAILED : EXIT_OK;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Link targets and glossary forms: the published texts of the content folder and of the checked files, and the app's routes.
|
|
350
|
+
const siteFiles = [...readContentFolder(contentDir), ...files];
|
|
351
|
+
const content = readPublishedContent(siteFiles);
|
|
352
|
+
const parse = { reservedSlugs: getBlogReservedSlugs(options.config), ...(blogOptions.fields === undefined ? {} : { fields: blogOptions.fields }) };
|
|
353
|
+
const resolveInternalLink = createInternalLinkResolver({
|
|
354
|
+
appDir: findAppDir(cwd, settings.options.appDir),
|
|
355
|
+
privateRouteSegments: settings.options.privateRouteSegments,
|
|
356
|
+
paths: settings.options.paths,
|
|
357
|
+
content,
|
|
358
|
+
});
|
|
359
|
+
const results = await checkArticleFiles(files, {
|
|
360
|
+
settings,
|
|
361
|
+
today: command.today ?? getLocalDate((options.clock ?? systemClock).now(), settings.timeZone),
|
|
362
|
+
resolveInternalLink,
|
|
363
|
+
parse,
|
|
364
|
+
glossary: readGlossaryTerms(siteFiles, parse),
|
|
365
|
+
...(command.external ? { fetch: options.fetch ?? fetch } : {}),
|
|
366
|
+
});
|
|
367
|
+
return reportCheck(results, files, cwd, output, fontErrors);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The loader's message for a `brand.fonts` source the card cannot read; `null` when every one reads or none is set. */
|
|
371
|
+
async function checkBrandFonts(fonts: readonly OgFontSource[] | undefined, root: string, fetchImpl: typeof fetch | undefined): Promise<string | null> {
|
|
372
|
+
if (fonts === undefined) return null;
|
|
373
|
+
const loaded = await createOgFontLoader({ root, ...(fetchImpl === undefined ? {} : { fetchImpl }) })(fonts);
|
|
374
|
+
return loaded.ok ? null : loaded.error;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function runSkillInstall(command: Extract<BlogCommand, { kind: "skill-install" }>, options: RunBlogCliOptions, output: CliOutput): Promise<number> {
|
|
378
|
+
const cwd = options.cwd ?? process.cwd();
|
|
379
|
+
const dir = resolve(cwd, command.dir);
|
|
380
|
+
const shown = relative(cwd, dir) || ".";
|
|
381
|
+
let files: SkillFile[];
|
|
382
|
+
try {
|
|
383
|
+
files = renderBlogSkill(options.config, { command: command.command });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
output.error(`softure-blog skill install: ${describeError(error)}`);
|
|
386
|
+
return EXIT_FAILED;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let extra: string[];
|
|
390
|
+
try {
|
|
391
|
+
const rendered = new Set(files.map((file) => file.path));
|
|
392
|
+
extra = (await listMarkdownFiles(dir)).filter((path) => !rendered.has(path));
|
|
393
|
+
} catch (error) {
|
|
394
|
+
output.error(`softure-blog skill install: cannot read ${shown}: ${describeError(error)}`);
|
|
395
|
+
return EXIT_FAILED;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (command.check) {
|
|
399
|
+
const stale: string[] = [];
|
|
400
|
+
for (const file of files) {
|
|
401
|
+
if ((await readText(join(dir, file.path))) !== file.text) stale.push(file.path);
|
|
402
|
+
}
|
|
403
|
+
if (stale.length === 0 && extra.length === 0) {
|
|
404
|
+
output.log(`skill: ${shown} is up to date`);
|
|
405
|
+
return EXIT_OK;
|
|
406
|
+
}
|
|
407
|
+
for (const path of stale) output.error(`skill: ${join(shown, path)} differs from the blog config`);
|
|
408
|
+
for (const path of extra) output.error(`skill: ${join(shown, path)} is not part of the skill`);
|
|
409
|
+
output.error("skill: run softure-blog skill install with the same options and commit the folder");
|
|
410
|
+
return EXIT_FAILED;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const existing = await readText(join(dir, "SKILL.md"));
|
|
414
|
+
if (existing !== null && !existing.includes(SKILL_MARKER)) {
|
|
415
|
+
output.error(`softure-blog skill install: ${join(shown, "SKILL.md")} was not generated by this command; refusing to overwrite it (choose another --dir)`);
|
|
416
|
+
return EXIT_FAILED;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
for (const file of files) {
|
|
420
|
+
const path = join(dir, file.path);
|
|
421
|
+
await mkdir(dirname(path), { recursive: true });
|
|
422
|
+
await writeFile(path, file.text, "utf8");
|
|
423
|
+
}
|
|
424
|
+
// The folder is the command's own (its SKILL.md carries the marker): a Markdown file it no longer renders goes.
|
|
425
|
+
for (const path of extra) await rm(join(dir, path));
|
|
426
|
+
} catch (error) {
|
|
427
|
+
output.error(`softure-blog skill install: cannot write ${shown}: ${describeError(error)}`);
|
|
428
|
+
return EXIT_FAILED;
|
|
429
|
+
}
|
|
430
|
+
for (const file of files) output.log(`wrote ${join(shown, file.path)}`);
|
|
431
|
+
for (const path of extra) output.log(`removed ${join(shown, path)}`);
|
|
432
|
+
output.log(`skill: installed into ${shown}; commit it, and run skill install --check in CI`);
|
|
433
|
+
return EXIT_OK;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** `configErrors`: problems of the config itself (the brand's fonts), already printed. */
|
|
437
|
+
function reportCheck(results: readonly FileCheckResult[], files: readonly ReadArticleFile[], cwd: string, output: CliOutput, configErrors: number): number {
|
|
438
|
+
let errors = configErrors;
|
|
439
|
+
let warnings = 0;
|
|
440
|
+
for (const [index, result] of results.entries()) {
|
|
441
|
+
const shown = relative(cwd, files[index]?.path ?? result.file) || result.file;
|
|
442
|
+
if (result.findings.length === 0) output.log(`${shown}: OK`);
|
|
443
|
+
for (const finding of result.findings) {
|
|
444
|
+
output.log(`${finding.line === undefined ? shown : `${shown}:${String(finding.line)}`}: ${finding.severity} [${finding.rule}] ${finding.message}`);
|
|
445
|
+
if (finding.severity === "error") errors += 1;
|
|
446
|
+
else warnings += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
output.log(`check: ${String(results.length)} file(s), ${String(errors)} error(s), ${String(warnings)} warning(s): ${errors > 0 ? "red, do not publish" : "green"}`);
|
|
450
|
+
return errors > 0 ? EXIT_FAILED : EXIT_OK;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function reportRun(run: BlogPublishRun, output: CliOutput): number {
|
|
454
|
+
for (const warning of run.warnings) output.error(`warning ${formatProblem(warning)}`);
|
|
455
|
+
if (run.status === "refused") {
|
|
456
|
+
for (const problem of run.problems) output.error(`error ${formatProblem(problem)}`);
|
|
457
|
+
output.error("refused: nothing written; fix the problems above");
|
|
458
|
+
return EXIT_FAILED;
|
|
459
|
+
}
|
|
460
|
+
for (const change of run.changes) {
|
|
461
|
+
output.log(formatChange(change));
|
|
462
|
+
if (change.previousSlug !== null) output.log(`moved ${change.id} ${change.previousSlug} -> ${change.slug}`);
|
|
463
|
+
}
|
|
464
|
+
const count = (action: PublishedChange["action"]) => String(run.changes.filter((change) => change.action === action).length);
|
|
465
|
+
output.log(`summary: added ${count("added")}, changed ${count("changed")}, unchanged ${count("unchanged")}`);
|
|
466
|
+
output.log(run.committed ? "written" : "dry run: nothing written; pass --commit to write");
|
|
467
|
+
return EXIT_OK;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** One line about the cache refresh; a failure is a warning and never changes the exit code. */
|
|
471
|
+
function reportRefresh(outcome: BlogRefreshOutcome, output: CliOutput): void {
|
|
472
|
+
switch (outcome.kind) {
|
|
473
|
+
case "not_configured":
|
|
474
|
+
output.log(`cache: the running app shows the change within revalidateSeconds (${String(outcome.revalidateSeconds)} s); set ${BLOG_REFRESH_SECRET_ENV} to refresh it now`);
|
|
475
|
+
return;
|
|
476
|
+
case "skipped":
|
|
477
|
+
output.log("cache: no text changed, nothing to refresh");
|
|
478
|
+
return;
|
|
479
|
+
case "dry_run":
|
|
480
|
+
output.log(`cache: dry run, a commit would refresh ${outcome.url}`);
|
|
481
|
+
return;
|
|
482
|
+
case "refreshed":
|
|
483
|
+
output.log(`cache: refreshed ${outcome.url}`);
|
|
484
|
+
return;
|
|
485
|
+
case "failed":
|
|
486
|
+
output.error(`warning cache: ${outcome.reason} (${outcome.code}); the publish is written, the app shows it within ${String(outcome.revalidateSeconds)} s`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** One line about the IndexNow submit; a failure is a warning and never changes the exit code. */
|
|
492
|
+
function reportIndexNow({ paths, outcome }: BlogIndexNowSubmit, output: CliOutput): void {
|
|
493
|
+
switch (outcome.kind) {
|
|
494
|
+
case "not_configured":
|
|
495
|
+
output.log(`indexnow: off, ${outcome.reason}`);
|
|
496
|
+
return;
|
|
497
|
+
case "skipped":
|
|
498
|
+
output.log("indexnow: no public address changed, nothing to submit");
|
|
499
|
+
return;
|
|
500
|
+
case "dry_run":
|
|
501
|
+
output.log(`indexnow: dry run, a commit would submit ${String(outcome.urls.length)} URL(s): ${outcome.urls.join(" ")}`);
|
|
502
|
+
return;
|
|
503
|
+
case "submitted":
|
|
504
|
+
output.log(`indexnow: submitted ${String(outcome.count)} URL(s) (${String(outcome.status)}): ${paths.join(" ")}`);
|
|
505
|
+
return;
|
|
506
|
+
case "failed":
|
|
507
|
+
output.error(`warning indexnow: ${outcome.reason} (${outcome.code}); the publish is written, submit the addresses later: ${paths.join(" ")}`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function formatChange(change: PublishedChange): string {
|
|
513
|
+
const before = change.statusBefore === null || change.slugBefore === null ? "none" : `${change.statusBefore}/${change.slugBefore}`;
|
|
514
|
+
return `${change.action} ${change.id} ${before} -> ${change.statusAfter}/${change.slug}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function formatProblem(problem: PublishProblem): string {
|
|
518
|
+
return `${problem.subject}: ${problem.message}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
interface ReadArticleFile extends ArticleFile {
|
|
522
|
+
readonly path: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The files the paths name, sorted by name within a folder, or why they cannot be read. */
|
|
526
|
+
async function readArticleFiles(paths: readonly string[]): Promise<ReadArticleFile[] | string> {
|
|
527
|
+
const files: ReadArticleFile[] = [];
|
|
528
|
+
for (const path of paths) {
|
|
529
|
+
const kind = await getPathKind(path);
|
|
530
|
+
if (kind === null) return `cannot read ${path}`;
|
|
531
|
+
const filePaths =
|
|
532
|
+
kind === "file"
|
|
533
|
+
? [path]
|
|
534
|
+
: (await readdir(path))
|
|
535
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
|
536
|
+
.sort()
|
|
537
|
+
.map((name) => join(path, name));
|
|
538
|
+
for (const filePath of filePaths) {
|
|
539
|
+
const text = await readText(filePath);
|
|
540
|
+
if (text === null) return `cannot read ${filePath}`;
|
|
541
|
+
files.push({ name: basename(filePath), text, path: filePath });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return files;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function getPathKind(path: string): Promise<"file" | "folder" | null> {
|
|
548
|
+
try {
|
|
549
|
+
const stats = await stat(path);
|
|
550
|
+
return stats.isDirectory() ? "folder" : "file";
|
|
551
|
+
} catch {
|
|
552
|
+
// The caller names the path; the error code adds nothing an editor can act on.
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The `.md` files under `dir`, relative to it with `/`; none when the folder does not exist. */
|
|
558
|
+
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
|
559
|
+
let entries: string[];
|
|
560
|
+
try {
|
|
561
|
+
entries = await readdir(dir, { recursive: true });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
return entries
|
|
567
|
+
.map((entry) => entry.split("\\").join("/"))
|
|
568
|
+
.filter((entry) => entry.endsWith(".md"))
|
|
569
|
+
.sort();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function readText(path: string): Promise<string | null> {
|
|
573
|
+
try {
|
|
574
|
+
return await readFile(path, "utf8");
|
|
575
|
+
} catch {
|
|
576
|
+
// The caller names the path.
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function openDatabase(database: SoftureDatabaseConfig, open: ((url: string) => Promise<DatabaseHandle>) | undefined): Promise<CommandDatabase> {
|
|
582
|
+
if (open === undefined) return openCommandDatabase(database, { max: 1 });
|
|
583
|
+
const handle = await open(database.url);
|
|
584
|
+
return { handle, close: handle.close };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The driver's own message; drizzle's "Failed query: … params: …" wrapper is dropped. */
|
|
588
|
+
function describeError(error: unknown): string {
|
|
589
|
+
if (!(error instanceof Error)) return String(error);
|
|
590
|
+
return error.cause instanceof Error ? error.cause.message : error.message;
|
|
591
|
+
}
|