@softure-ai/blog 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +604 -2
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +40 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +62 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +555 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/skill.d.ts +47 -0
- package/dist/cli/skill.d.ts.map +1 -0
- package/dist/cli/skill.js +221 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/content/article-file.d.ts +27 -0
- package/dist/content/article-file.d.ts.map +1 -0
- package/dist/content/article-file.js +179 -0
- package/dist/content/article-file.js.map +1 -0
- package/dist/contract.d.ts +77 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/db/articles.d.ts +30 -0
- package/dist/db/articles.d.ts.map +1 -0
- package/dist/db/articles.js +138 -0
- package/dist/db/articles.js.map +1 -0
- package/dist/db/publish-run.d.ts +50 -0
- package/dist/db/publish-run.d.ts.map +1 -0
- package/dist/db/publish-run.js +168 -0
- package/dist/db/publish-run.js.map +1 -0
- package/dist/db/schema.d.ts +403 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +33 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/discovery/dates.d.ts +9 -0
- package/dist/discovery/dates.d.ts.map +1 -0
- package/dist/discovery/dates.js +19 -0
- package/dist/discovery/dates.js.map +1 -0
- package/dist/discovery/index.d.ts +8 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +11 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/indexnow.d.ts +11 -0
- package/dist/discovery/indexnow.d.ts.map +1 -0
- package/dist/discovery/indexnow.js +23 -0
- package/dist/discovery/indexnow.js.map +1 -0
- package/dist/discovery/refresh.d.ts +49 -0
- package/dist/discovery/refresh.d.ts.map +1 -0
- package/dist/discovery/refresh.js +55 -0
- package/dist/discovery/refresh.js.map +1 -0
- package/dist/discovery/related.d.ts +17 -0
- package/dist/discovery/related.d.ts.map +1 -0
- package/dist/discovery/related.js +28 -0
- package/dist/discovery/related.js.map +1 -0
- package/dist/discovery/rss.d.ts +30 -0
- package/dist/discovery/rss.d.ts.map +1 -0
- package/dist/discovery/rss.js +48 -0
- package/dist/discovery/rss.js.map +1 -0
- package/dist/discovery/sitemap.d.ts +25 -0
- package/dist/discovery/sitemap.d.ts.map +1 -0
- package/dist/discovery/sitemap.js +31 -0
- package/dist/discovery/sitemap.js.map +1 -0
- package/dist/discovery/submit.d.ts +44 -0
- package/dist/discovery/submit.d.ts.map +1 -0
- package/dist/discovery/submit.js +46 -0
- package/dist/discovery/submit.js.map +1 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +83 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +83 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +158 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +5 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +78 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +78 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/context.d.ts +6 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +27 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/data.d.ts +11 -0
- package/dist/next/data.d.ts.map +1 -0
- package/dist/next/data.js +40 -0
- package/dist/next/data.js.map +1 -0
- package/dist/next/discovery.d.ts +6 -0
- package/dist/next/discovery.d.ts.map +1 -0
- package/dist/next/discovery.js +49 -0
- package/dist/next/discovery.js.map +1 -0
- package/dist/next/index.d.ts +9 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +10 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/og-fonts.d.ts +6 -0
- package/dist/next/og-fonts.d.ts.map +1 -0
- package/dist/next/og-fonts.js +8 -0
- package/dist/next/og-fonts.js.map +1 -0
- package/dist/next/og-image.d.ts +36 -0
- package/dist/next/og-image.d.ts.map +1 -0
- package/dist/next/og-image.js +77 -0
- package/dist/next/og-image.js.map +1 -0
- package/dist/next/pages.d.ts +45 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +189 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/refresh.d.ts +9 -0
- package/dist/next/refresh.d.ts.map +1 -0
- package/dist/next/refresh.js +81 -0
- package/dist/next/refresh.js.map +1 -0
- package/dist/options.d.ts +320 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +184 -0
- package/dist/options.js.map +1 -0
- package/dist/pages/body.d.ts +18 -0
- package/dist/pages/body.d.ts.map +1 -0
- package/dist/pages/body.js +21 -0
- package/dist/pages/body.js.map +1 -0
- package/dist/pages/dates.d.ts +21 -0
- package/dist/pages/dates.d.ts.map +1 -0
- package/dist/pages/dates.js +24 -0
- package/dist/pages/dates.js.map +1 -0
- package/dist/pages/index.d.ts +7 -0
- package/dist/pages/index.d.ts.map +1 -0
- package/dist/pages/index.js +9 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/json-ld.d.ts +33 -0
- package/dist/pages/json-ld.d.ts.map +1 -0
- package/dist/pages/json-ld.js +95 -0
- package/dist/pages/json-ld.js.map +1 -0
- package/dist/pages/listing.d.ts +46 -0
- package/dist/pages/listing.d.ts.map +1 -0
- package/dist/pages/listing.js +57 -0
- package/dist/pages/listing.js.map +1 -0
- package/dist/pages/paths.d.ts +37 -0
- package/dist/pages/paths.d.ts.map +1 -0
- package/dist/pages/paths.js +61 -0
- package/dist/pages/paths.js.map +1 -0
- package/dist/pages/redirects.d.ts +57 -0
- package/dist/pages/redirects.d.ts.map +1 -0
- package/dist/pages/redirects.js +78 -0
- package/dist/pages/redirects.js.map +1 -0
- package/dist/proxy/index.d.ts +15 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +60 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/quality/blocks.d.ts +21 -0
- package/dist/quality/blocks.d.ts.map +1 -0
- package/dist/quality/blocks.js +113 -0
- package/dist/quality/blocks.js.map +1 -0
- package/dist/quality/catalog.d.ts +10 -0
- package/dist/quality/catalog.d.ts.map +1 -0
- package/dist/quality/catalog.js +71 -0
- package/dist/quality/catalog.js.map +1 -0
- package/dist/quality/check-article.d.ts +34 -0
- package/dist/quality/check-article.d.ts.map +1 -0
- package/dist/quality/check-article.js +74 -0
- package/dist/quality/check-article.js.map +1 -0
- package/dist/quality/check-files.d.ts +23 -0
- package/dist/quality/check-files.d.ts.map +1 -0
- package/dist/quality/check-files.js +44 -0
- package/dist/quality/check-files.js.map +1 -0
- package/dist/quality/external-links.d.ts +19 -0
- package/dist/quality/external-links.d.ts.map +1 -0
- package/dist/quality/external-links.js +30 -0
- package/dist/quality/external-links.js.map +1 -0
- package/dist/quality/finding.d.ts +17 -0
- package/dist/quality/finding.d.ts.map +1 -0
- package/dist/quality/finding.js +15 -0
- package/dist/quality/finding.js.map +1 -0
- package/dist/quality/gate.d.ts +5 -0
- package/dist/quality/gate.d.ts.map +1 -0
- package/dist/quality/gate.js +10 -0
- package/dist/quality/gate.js.map +1 -0
- package/dist/quality/index.d.ts +15 -0
- package/dist/quality/index.d.ts.map +1 -0
- package/dist/quality/index.js +16 -0
- package/dist/quality/index.js.map +1 -0
- package/dist/quality/link-targets.d.ts +44 -0
- package/dist/quality/link-targets.d.ts.map +1 -0
- package/dist/quality/link-targets.js +91 -0
- package/dist/quality/link-targets.js.map +1 -0
- package/dist/quality/options.d.ts +113 -0
- package/dist/quality/options.d.ts.map +1 -0
- package/dist/quality/options.js +93 -0
- package/dist/quality/options.js.map +1 -0
- package/dist/quality/plugin.d.ts +34 -0
- package/dist/quality/plugin.d.ts.map +1 -0
- package/dist/quality/plugin.js +7 -0
- package/dist/quality/plugin.js.map +1 -0
- package/dist/quality/rules/blocks.d.ts +5 -0
- package/dist/quality/rules/blocks.d.ts.map +1 -0
- package/dist/quality/rules/blocks.js +35 -0
- package/dist/quality/rules/blocks.js.map +1 -0
- package/dist/quality/rules/images.d.ts +4 -0
- package/dist/quality/rules/images.d.ts.map +1 -0
- package/dist/quality/rules/images.js +33 -0
- package/dist/quality/rules/images.js.map +1 -0
- package/dist/quality/rules/input.d.ts +11 -0
- package/dist/quality/rules/input.d.ts.map +1 -0
- package/dist/quality/rules/input.js +2 -0
- package/dist/quality/rules/input.js.map +1 -0
- package/dist/quality/rules/links.d.ts +17 -0
- package/dist/quality/rules/links.d.ts.map +1 -0
- package/dist/quality/rules/links.js +46 -0
- package/dist/quality/rules/links.js.map +1 -0
- package/dist/quality/rules/structure.d.ts +13 -0
- package/dist/quality/rules/structure.d.ts.map +1 -0
- package/dist/quality/rules/structure.js +139 -0
- package/dist/quality/rules/structure.js.map +1 -0
- package/dist/quality/rules/style.d.ts +11 -0
- package/dist/quality/rules/style.d.ts.map +1 -0
- package/dist/quality/rules/style.js +129 -0
- package/dist/quality/rules/style.js.map +1 -0
- package/dist/quality/rules/ymyl.d.ts +5 -0
- package/dist/quality/rules/ymyl.d.ts.map +1 -0
- package/dist/quality/rules/ymyl.js +51 -0
- package/dist/quality/rules/ymyl.js.map +1 -0
- package/dist/quality/rulesets/en/ruleset.d.ts +3 -0
- package/dist/quality/rulesets/en/ruleset.d.ts.map +1 -0
- package/dist/quality/rulesets/en/ruleset.js +106 -0
- package/dist/quality/rulesets/en/ruleset.js.map +1 -0
- package/dist/quality/rulesets/index.d.ts +7 -0
- package/dist/quality/rulesets/index.d.ts.map +1 -0
- package/dist/quality/rulesets/index.js +7 -0
- package/dist/quality/rulesets/index.js.map +1 -0
- package/dist/quality/rulesets/pl/ruleset.d.ts +3 -0
- package/dist/quality/rulesets/pl/ruleset.d.ts.map +1 -0
- package/dist/quality/rulesets/pl/ruleset.js +114 -0
- package/dist/quality/rulesets/pl/ruleset.js.map +1 -0
- package/dist/quality/rulesets/types.d.ts +28 -0
- package/dist/quality/rulesets/types.d.ts.map +1 -0
- package/dist/quality/rulesets/types.js +2 -0
- package/dist/quality/rulesets/types.js.map +1 -0
- package/dist/quality/settings.d.ts +26 -0
- package/dist/quality/settings.d.ts.map +1 -0
- package/dist/quality/settings.js +32 -0
- package/dist/quality/settings.js.map +1 -0
- package/dist/quality/text.d.ts +43 -0
- package/dist/quality/text.d.ts.map +1 -0
- package/dist/quality/text.js +85 -0
- package/dist/quality/text.js.map +1 -0
- package/dist/render/glossary.d.ts +27 -0
- package/dist/render/glossary.d.ts.map +1 -0
- package/dist/render/glossary.js +71 -0
- package/dist/render/glossary.js.map +1 -0
- package/dist/render/images.d.ts +42 -0
- package/dist/render/images.d.ts.map +1 -0
- package/dist/render/images.js +84 -0
- package/dist/render/images.js.map +1 -0
- package/dist/render/index.d.ts +6 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/reading-time.d.ts +8 -0
- package/dist/render/reading-time.d.ts.map +1 -0
- package/dist/render/reading-time.js +17 -0
- package/dist/render/reading-time.js.map +1 -0
- package/dist/render/render-article.d.ts +97 -0
- package/dist/render/render-article.d.ts.map +1 -0
- package/dist/render/render-article.js +373 -0
- package/dist/render/render-article.js.map +1 -0
- package/dist/render/slugify-heading.d.ts +2 -0
- package/dist/render/slugify-heading.d.ts.map +1 -0
- package/dist/render/slugify-heading.js +21 -0
- package/dist/render/slugify-heading.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +11 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +13 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/og-fonts.d.ts +24 -0
- package/dist/server/og-fonts.d.ts.map +1 -0
- package/dist/server/og-fonts.js +85 -0
- package/dist/server/og-fonts.js.map +1 -0
- package/dist/server/options.d.ts +17 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +56 -0
- package/dist/server/options.js.map +1 -0
- package/dist/sitemap.d.ts +11 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +35 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/ui/blog-article.d.ts +49 -0
- package/dist/ui/blog-article.d.ts.map +1 -0
- package/dist/ui/blog-article.js +45 -0
- package/dist/ui/blog-article.js.map +1 -0
- package/dist/ui/blog-glossary.d.ts +27 -0
- package/dist/ui/blog-glossary.d.ts.map +1 -0
- package/dist/ui/blog-glossary.js +20 -0
- package/dist/ui/blog-glossary.js.map +1 -0
- package/dist/ui/blog-layout.d.ts +31 -0
- package/dist/ui/blog-layout.d.ts.map +1 -0
- package/dist/ui/blog-layout.js +25 -0
- package/dist/ui/blog-layout.js.map +1 -0
- package/dist/ui/blog-listing.d.ts +15 -0
- package/dist/ui/blog-listing.d.ts.map +1 -0
- package/dist/ui/blog-listing.js +29 -0
- package/dist/ui/blog-listing.js.map +1 -0
- package/dist/ui/blog-method.d.ts +5 -0
- package/dist/ui/blog-method.d.ts.map +1 -0
- package/dist/ui/blog-method.js +21 -0
- package/dist/ui/blog-method.js.map +1 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/page-context.d.ts +15 -0
- package/dist/ui/page-context.d.ts.map +1 -0
- package/dist/ui/page-context.js +2 -0
- package/dist/ui/page-context.js.map +1 -0
- package/migrations/0001_create_articles.sql +67 -0
- package/migrations/README.md +8 -0
- package/module.json +28 -0
- package/package.json +102 -4
- package/skill/SKILL.md +72 -0
- package/skill/references/reviewer.md +39 -0
- package/skill/references/rules.md +122 -0
- package/skill/references/structure.md +69 -0
- package/skill/references/template.md +86 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +49 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/run.ts +591 -0
- package/src/cli/skill.ts +242 -0
- package/src/content/article-file.ts +184 -0
- package/src/contract.ts +88 -0
- package/src/db/articles.ts +160 -0
- package/src/db/publish-run.ts +224 -0
- package/src/db/schema.ts +36 -0
- package/src/discovery/dates.ts +27 -0
- package/src/discovery/index.ts +18 -0
- package/src/discovery/indexnow.ts +25 -0
- package/src/discovery/refresh.ts +79 -0
- package/src/discovery/related.ts +35 -0
- package/src/discovery/rss.ts +77 -0
- package/src/discovery/sitemap.ts +59 -0
- package/src/discovery/submit.ts +69 -0
- package/src/index.ts +99 -0
- package/src/messages/en.ts +82 -0
- package/src/messages/index.ts +7 -0
- package/src/messages/pl.ts +77 -0
- package/src/next/context.ts +30 -0
- package/src/next/data.ts +55 -0
- package/src/next/discovery.ts +49 -0
- package/src/next/index.ts +25 -0
- package/src/next/next-modules.d.ts +15 -0
- package/src/next/og-fonts.ts +14 -0
- package/src/next/og-image.tsx +108 -0
- package/src/next/pages.tsx +251 -0
- package/src/next/refresh.ts +85 -0
- package/src/options.ts +224 -0
- package/src/pages/body.ts +38 -0
- package/src/pages/dates.ts +39 -0
- package/src/pages/index.ts +37 -0
- package/src/pages/json-ld.ts +120 -0
- package/src/pages/listing.ts +95 -0
- package/src/pages/paths.ts +84 -0
- package/src/pages/redirects.ts +120 -0
- package/src/proxy/index.ts +66 -0
- package/src/quality/blocks.ts +130 -0
- package/src/quality/catalog.ts +86 -0
- package/src/quality/check-article.ts +112 -0
- package/src/quality/check-files.ts +63 -0
- package/src/quality/external-links.ts +37 -0
- package/src/quality/finding.ts +29 -0
- package/src/quality/gate.ts +15 -0
- package/src/quality/index.ts +28 -0
- package/src/quality/link-targets.ts +107 -0
- package/src/quality/options.ts +105 -0
- package/src/quality/plugin.ts +43 -0
- package/src/quality/rules/blocks.ts +43 -0
- package/src/quality/rules/images.ts +34 -0
- package/src/quality/rules/input.ts +12 -0
- package/src/quality/rules/links.ts +56 -0
- package/src/quality/rules/structure.ts +137 -0
- package/src/quality/rules/style.ts +137 -0
- package/src/quality/rules/ymyl.ts +57 -0
- package/src/quality/rulesets/en/ruleset.ts +117 -0
- package/src/quality/rulesets/index.ts +9 -0
- package/src/quality/rulesets/pl/ruleset.ts +126 -0
- package/src/quality/rulesets/types.ts +31 -0
- package/src/quality/settings.ts +60 -0
- package/src/quality/text.ts +113 -0
- package/src/render/glossary.ts +103 -0
- package/src/render/images.ts +110 -0
- package/src/render/index.ts +29 -0
- package/src/render/reading-time.ts +18 -0
- package/src/render/render-article.ts +487 -0
- package/src/render/slugify-heading.ts +22 -0
- package/src/server/health.ts +12 -0
- package/src/server/index.ts +28 -0
- package/src/server/og-fonts.ts +102 -0
- package/src/server/options.ts +62 -0
- package/src/sitemap.ts +36 -0
- package/src/ui/blog-article.tsx +186 -0
- package/src/ui/blog-glossary.tsx +104 -0
- package/src/ui/blog-layout.tsx +89 -0
- package/src/ui/blog-listing.tsx +95 -0
- package/src/ui/blog-method.tsx +34 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/page-context.ts +17 -0
- package/styles.css +498 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// Article Markdown → HTML on the server. The output is a security boundary (stored XSS): whatever an
|
|
2
|
+
// author writes, the page injects the result as HTML.
|
|
3
|
+
//
|
|
4
|
+
// ## Allowlist by construction
|
|
5
|
+
//
|
|
6
|
+
// `html: false` escapes raw HTML in the text instead of passing it through, so the output holds only
|
|
7
|
+
// the elements markdown-it itself emits (headings, paragraphs, lists, quotes, tables, code, links,
|
|
8
|
+
// emphasis, footnotes, images). Links keep only `http(s)`, `mailto`, relative and `#` targets;
|
|
9
|
+
// `javascript:`, `data:` and every other scheme stay text.
|
|
10
|
+
//
|
|
11
|
+
// ## Images
|
|
12
|
+
//
|
|
13
|
+
// An image is emitted only when it follows the app's image policy (`images`, see `images.ts`): a site
|
|
14
|
+
// path or an https source on an allowed host, a non-empty alt and dimensions the app knows. It gets its
|
|
15
|
+
// width and height and loads lazily. Any other image renders as its alt text; without a policy, every one.
|
|
16
|
+
//
|
|
17
|
+
// ## Links
|
|
18
|
+
//
|
|
19
|
+
// A link that leaves the site (`siteHosts`, subdomains included) gets `rel="noopener noreferrer"`,
|
|
20
|
+
// opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab".
|
|
21
|
+
//
|
|
22
|
+
// ## Headings and the table of contents
|
|
23
|
+
//
|
|
24
|
+
// Every heading gets an id from its text, unique within the text (`-2`, `-3`), for a table of contents
|
|
25
|
+
// and for links to a section. `toc` renders that table as a `<nav>`.
|
|
26
|
+
//
|
|
27
|
+
// ## Glossary
|
|
28
|
+
//
|
|
29
|
+
// With `glossary`, the first mention of each term in prose links to its definition (`termHref`).
|
|
30
|
+
// Headings, existing links, code and footnotes are skipped: a link in a source would hide its address.
|
|
31
|
+
// A term does not link to itself (`selfSlug`). `linkedTerms` lists the terms the text links,
|
|
32
|
+
// automatically or by hand, so a term page knows which texts expand on it.
|
|
33
|
+
//
|
|
34
|
+
// ## Block plugins
|
|
35
|
+
//
|
|
36
|
+
// A fenced block whose type an app registers (```` ```chart ````) is rendered by the app's plugin, as
|
|
37
|
+
// HTML or as a node (e.g. a React server component). Plugin output is the app's own code and is
|
|
38
|
+
// trusted as is. Only top-level fences are plugin blocks; a fence inside a list or a quote stays code.
|
|
39
|
+
import MarkdownIt, { type MarkdownIt as Markdown, type StateCore, type Token } from "markdown-it";
|
|
40
|
+
import footnote from "markdown-it-footnote";
|
|
41
|
+
import type { BlogFields } from "../contract.js";
|
|
42
|
+
import { en } from "../messages/en.js";
|
|
43
|
+
import { createTermMatcher, type GlossaryTerm } from "./glossary.js";
|
|
44
|
+
import { checkArticleImage, type ArticleImagePolicy } from "./images.js";
|
|
45
|
+
import { getReadingMinutes } from "./reading-time.js";
|
|
46
|
+
import { slugifyHeading } from "./slugify-heading.js";
|
|
47
|
+
|
|
48
|
+
export type BlogRenderMessages = (typeof en)["render"];
|
|
49
|
+
|
|
50
|
+
export interface ArticleHeading {
|
|
51
|
+
readonly level: number;
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly text: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a block plugin may read from its article; the page passes it in `options.article`. */
|
|
57
|
+
export interface BlockArticle {
|
|
58
|
+
/** `current_as_of` of the article, `YYYY-MM-DD`. */
|
|
59
|
+
readonly currentAsOf?: string;
|
|
60
|
+
/** The app's own frontmatter fields (`blog({ fields })`). */
|
|
61
|
+
readonly fields?: BlogFields;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ArticleBlock {
|
|
65
|
+
/** The block type, the first word of the fence's info string. */
|
|
66
|
+
readonly type: string;
|
|
67
|
+
/** The rest of the info string, trimmed: ```` ```chart wealth ```` → `"wealth"`. */
|
|
68
|
+
readonly info: string;
|
|
69
|
+
/** The body of the fence, as written. */
|
|
70
|
+
readonly content: string;
|
|
71
|
+
readonly article: BlockArticle;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type BlockOutput<TNode = unknown> =
|
|
75
|
+
| { readonly kind: "html"; readonly html: string }
|
|
76
|
+
| { readonly kind: "node"; readonly node: TNode };
|
|
77
|
+
|
|
78
|
+
export interface BlockPlugin<TNode = unknown> {
|
|
79
|
+
/** Lower-case kebab-case, e.g. `chart`. */
|
|
80
|
+
readonly type: string;
|
|
81
|
+
/**
|
|
82
|
+
* The frontmatter keys the block reads (`current_as_of` or keys of the app's `fields`), so the
|
|
83
|
+
* quality gate can report a block whose article lacks them.
|
|
84
|
+
*/
|
|
85
|
+
readonly requires?: readonly string[];
|
|
86
|
+
/** Throws only on a bug; a block that cannot render returns its own error markup. */
|
|
87
|
+
readonly render: (block: ArticleBlock) => BlockOutput<TNode>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ArticleSegment<TNode = unknown> =
|
|
91
|
+
| { readonly kind: "html"; readonly html: string }
|
|
92
|
+
| { readonly kind: "node"; readonly type: string; readonly node: TNode };
|
|
93
|
+
|
|
94
|
+
export interface RenderArticleOptions<TNode = unknown> {
|
|
95
|
+
/** Terms for automatic links; without it no text is linked. */
|
|
96
|
+
readonly glossary?: readonly GlossaryTerm[];
|
|
97
|
+
/** The slug of the term whose page is rendered: it does not link to itself. */
|
|
98
|
+
readonly selfSlug?: string;
|
|
99
|
+
/** The path of a term's definition; `/blog/glossary/<slug>` by default. */
|
|
100
|
+
readonly termHref?: (slug: string) => string;
|
|
101
|
+
/** Host names of the site (`example.com` covers its subdomains); other hosts are external. */
|
|
102
|
+
readonly siteHosts?: readonly string[];
|
|
103
|
+
/** Which images the body may show; without it every image renders as its alt text. */
|
|
104
|
+
readonly images?: ArticleImagePolicy;
|
|
105
|
+
readonly blocks?: readonly BlockPlugin<TNode>[];
|
|
106
|
+
/** What block plugins may read from the article. */
|
|
107
|
+
readonly article?: BlockArticle;
|
|
108
|
+
/** Render a table of contents of `h2` down to `maxLevel` (3 by default). */
|
|
109
|
+
readonly toc?: boolean | { readonly maxLevel: number };
|
|
110
|
+
/** Copy for footnotes, external links and the table of contents; English by default. */
|
|
111
|
+
readonly messages?: BlogRenderMessages;
|
|
112
|
+
readonly wordsPerMinute?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RenderedArticle<TNode = unknown> {
|
|
116
|
+
/** The whole body as one HTML string; `null` when a block plugin returned a node. */
|
|
117
|
+
readonly html: string | null;
|
|
118
|
+
/** The body in document order: HTML and the nodes of block plugins. */
|
|
119
|
+
readonly segments: readonly ArticleSegment<TNode>[];
|
|
120
|
+
readonly headings: readonly ArticleHeading[];
|
|
121
|
+
/** The table of contents (`options.toc`), `null` when not asked for or without headings. */
|
|
122
|
+
readonly toc: string | null;
|
|
123
|
+
/** Slugs of the terms the text links, automatically or by hand, in order of the first link. */
|
|
124
|
+
readonly linkedTerms: readonly string[];
|
|
125
|
+
readonly readingMinutes: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface FoundBlock {
|
|
129
|
+
readonly type: string;
|
|
130
|
+
readonly info: string;
|
|
131
|
+
/** 1-based line of the opening fence. */
|
|
132
|
+
readonly line: number;
|
|
133
|
+
readonly requires: readonly string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const DEFAULT_TOC_MAX_LEVEL = 3;
|
|
137
|
+
const SAFE_SCHEME = /^(?:https?|mailto):/;
|
|
138
|
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/;
|
|
139
|
+
const BLOCK_TYPE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
140
|
+
const FOOTNOTES_HEADING_ID = "footnotes";
|
|
141
|
+
// Ids the renderer gives footnotes; a heading whose slug equals one of them gets a suffix.
|
|
142
|
+
const FOOTNOTE_ID = /^(?:footnotes|fn(?:ref)?-\d+(?:-\d+)?)$/;
|
|
143
|
+
const BLOCK_TOKEN = "blog_block";
|
|
144
|
+
|
|
145
|
+
function getDefaultTermHref(slug: string): string {
|
|
146
|
+
return `/blog/glossary/${slug}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Percent-decoded where possible: markdown-it validates links after encoding them (`%09` for a tab). */
|
|
150
|
+
function decodeLink(url: string): string {
|
|
151
|
+
try {
|
|
152
|
+
return decodeURIComponent(url);
|
|
153
|
+
} catch {
|
|
154
|
+
return url;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Without ASCII whitespace and control characters: browsers drop them inside a scheme (`java\tscript:`). */
|
|
159
|
+
function removeUrlNoise(url: string): string {
|
|
160
|
+
return [...url].filter((char) => char > " " && char !== "\u007f").join("");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isSafeLink(url: string): boolean {
|
|
164
|
+
const value = removeUrlNoise(decodeLink(url)).toLowerCase();
|
|
165
|
+
return SAFE_SCHEME.test(value) || !HAS_SCHEME.test(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isExternalLink(href: string, siteHosts: readonly string[]): boolean {
|
|
169
|
+
const absolute = href.startsWith("//") ? `https:${href}` : href;
|
|
170
|
+
if (!/^https?:\/\//i.test(absolute)) return false;
|
|
171
|
+
let host: string;
|
|
172
|
+
try {
|
|
173
|
+
host = new URL(absolute).hostname.toLowerCase();
|
|
174
|
+
} catch {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return !siteHosts.some((site) => {
|
|
178
|
+
const own = site.toLowerCase();
|
|
179
|
+
return host === own || host.endsWith(`.${own}`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A link target without its query, hash and trailing slash, for comparing paths. */
|
|
184
|
+
function getPathOnly(href: string): string {
|
|
185
|
+
return href.replace(/[?#].*$/, "").replace(/(.)\/$/, "$1");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `[1, 0]` → `1`; later references to the same footnote → `1-2`, `1-3`. */
|
|
189
|
+
function getFootnoteRefId(meta: { id: number; subId: number }): string {
|
|
190
|
+
const number = String(meta.id + 1);
|
|
191
|
+
return meta.subId > 0 ? `${number}-${meta.subId + 1}` : number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface RenderState {
|
|
195
|
+
readonly headings: ArticleHeading[];
|
|
196
|
+
readonly linkedTerms: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function addFootnoteMarkup(md: Markdown, messages: BlogRenderMessages): void {
|
|
200
|
+
const escape = md.utils.escapeHtml;
|
|
201
|
+
const rules = md.renderer.rules;
|
|
202
|
+
rules.footnote_ref = (tokens, index) => {
|
|
203
|
+
const meta = tokens[index]?.meta as { id: number; subId: number };
|
|
204
|
+
const number = String(meta.id + 1);
|
|
205
|
+
const label = escape(messages.footnoteLabel.replace("{number}", number));
|
|
206
|
+
return `<sup class="blog-footnote-ref"><a href="#fn-${number}" id="fnref-${getFootnoteRefId(meta)}" aria-label="${label}">${number}</a></sup>`;
|
|
207
|
+
};
|
|
208
|
+
rules.footnote_block_open = () =>
|
|
209
|
+
`<section class="blog-footnotes" aria-labelledby="${FOOTNOTES_HEADING_ID}">\n` +
|
|
210
|
+
`<h2 id="${FOOTNOTES_HEADING_ID}">${escape(messages.footnotesHeading)}</h2>\n<ol>\n`;
|
|
211
|
+
rules.footnote_block_close = () => "</ol>\n</section>\n";
|
|
212
|
+
rules.footnote_open = (tokens, index) => `<li id="fn-${(tokens[index]?.meta as { id: number }).id + 1}">`;
|
|
213
|
+
rules.footnote_close = () => "</li>\n";
|
|
214
|
+
rules.footnote_anchor = (tokens, index) => {
|
|
215
|
+
const meta = tokens[index]?.meta as { id: number; subId: number };
|
|
216
|
+
return ` <a href="#fnref-${getFootnoteRefId(meta)}" class="blog-footnote-back" aria-label="${escape(messages.backToText)}">↩</a>`;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages: BlogRenderMessages): void {
|
|
221
|
+
// Markdown links do not nest, but a stack keeps open and close paired whatever the token stream.
|
|
222
|
+
const externalStack: boolean[] = [];
|
|
223
|
+
md.renderer.rules.link_open = (tokens, index, options, _env, self) => {
|
|
224
|
+
const token = tokens[index];
|
|
225
|
+
const isExternal = token !== undefined && isExternalLink(String(token.attrGet("href") ?? ""), siteHosts);
|
|
226
|
+
externalStack.push(isExternal);
|
|
227
|
+
if (token !== undefined && isExternal) {
|
|
228
|
+
token.attrSet("rel", "noopener noreferrer");
|
|
229
|
+
token.attrSet("target", "_blank");
|
|
230
|
+
token.attrJoin("class", "blog-external");
|
|
231
|
+
}
|
|
232
|
+
return self.renderToken(tokens, index, options);
|
|
233
|
+
};
|
|
234
|
+
md.renderer.rules.link_close = (tokens, index, options, _env, self) => {
|
|
235
|
+
const marker = externalStack.pop() === true
|
|
236
|
+
? `<span class="blog-external-marker" aria-hidden="true">↗</span><span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`
|
|
237
|
+
: "";
|
|
238
|
+
return marker + self.renderToken(tokens, index, options);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Emits an image that follows the policy with its size and lazy loading; any other as its alt text. */
|
|
243
|
+
function addImages(md: Markdown, policy: ArticleImagePolicy | undefined): void {
|
|
244
|
+
md.renderer.rules.image = (tokens, index, options, env, self) => {
|
|
245
|
+
const token = tokens[index];
|
|
246
|
+
if (token === undefined) return "";
|
|
247
|
+
const alt = self.renderInlineAsText(token.children ?? [], options, env);
|
|
248
|
+
const verdict = checkArticleImage({ src: String(token.attrGet("src") ?? ""), alt }, policy);
|
|
249
|
+
if (!verdict.ok) return md.utils.escapeHtml(alt);
|
|
250
|
+
token.attrSet("alt", alt);
|
|
251
|
+
token.attrSet("width", String(verdict.width));
|
|
252
|
+
token.attrSet("height", String(verdict.height));
|
|
253
|
+
token.attrSet("loading", "lazy");
|
|
254
|
+
token.attrSet("decoding", "async");
|
|
255
|
+
token.attrJoin("class", "blog-image");
|
|
256
|
+
return self.renderToken(tokens, index, options);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function addHeadingIds(md: Markdown, state: RenderState): void {
|
|
261
|
+
md.core.ruler.push("blog_heading_ids", (core) => {
|
|
262
|
+
const used = new Map<string, number>();
|
|
263
|
+
core.tokens.forEach((token, index) => {
|
|
264
|
+
if (token.type !== "heading_open") return;
|
|
265
|
+
const text =
|
|
266
|
+
core.tokens[index + 1]?.children
|
|
267
|
+
?.filter((child) => child.type === "text" || child.type === "code_inline")
|
|
268
|
+
.map((child) => child.content)
|
|
269
|
+
.join("") ?? "";
|
|
270
|
+
const base = slugifyHeading(text) || "section";
|
|
271
|
+
const seen = used.get(base) ?? (FOOTNOTE_ID.test(base) ? 1 : 0);
|
|
272
|
+
used.set(base, seen + 1);
|
|
273
|
+
const id = seen === 0 ? base : `${base}-${seen + 1}`;
|
|
274
|
+
token.attrSet("id", id);
|
|
275
|
+
state.headings.push({ level: Number(token.tag.slice(1)), id, text });
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface GlossaryLinkOptions {
|
|
281
|
+
readonly glossary: readonly GlossaryTerm[];
|
|
282
|
+
readonly selfSlug: string | undefined;
|
|
283
|
+
readonly termHref: (slug: string) => string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function addGlossaryLinks(md: Markdown, options: GlossaryLinkOptions, state: RenderState): void {
|
|
287
|
+
const terms = options.glossary.filter((term) => term.slug !== options.selfSlug);
|
|
288
|
+
const match = createTermMatcher(terms);
|
|
289
|
+
const slugByPath = new Map(options.glossary.map((term) => [getPathOnly(md.normalizeLink(options.termHref(term.slug))), term.slug]));
|
|
290
|
+
const remember = (slug: string): void => {
|
|
291
|
+
if (!state.linkedTerms.includes(slug)) state.linkedTerms.push(slug);
|
|
292
|
+
};
|
|
293
|
+
const newText = (core: StateCore, content: string): Token => {
|
|
294
|
+
const token = new core.Token("text", "", 0);
|
|
295
|
+
token.content = content;
|
|
296
|
+
return token;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Pushed after the footnote plugin's `footnote_tail`, so footnote bodies already sit between
|
|
300
|
+
// `footnote_open` and `footnote_close` and can be skipped.
|
|
301
|
+
md.core.ruler.push("blog_glossary_links", (core) => {
|
|
302
|
+
let skipDepth = 0;
|
|
303
|
+
for (const token of core.tokens) {
|
|
304
|
+
if (token.type === "heading_open" || token.type === "footnote_open") {
|
|
305
|
+
skipDepth += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (token.type === "heading_close" || token.type === "footnote_close") {
|
|
309
|
+
skipDepth -= 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (token.type !== "inline" || token.children === null) continue;
|
|
313
|
+
let linkDepth = 0;
|
|
314
|
+
const children: Token[] = [];
|
|
315
|
+
for (const child of token.children) {
|
|
316
|
+
if (child.type === "link_open") {
|
|
317
|
+
linkDepth += 1;
|
|
318
|
+
const slug = slugByPath.get(getPathOnly(String(child.attrGet("href") ?? "")));
|
|
319
|
+
if (slug !== undefined && skipDepth === 0) remember(slug);
|
|
320
|
+
} else if (child.type === "link_close") {
|
|
321
|
+
linkDepth -= 1;
|
|
322
|
+
}
|
|
323
|
+
if (child.type !== "text" || linkDepth > 0 || skipDepth > 0) {
|
|
324
|
+
children.push(child);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
let cursor = 0;
|
|
328
|
+
for (const found of match(child.content)) {
|
|
329
|
+
if (state.linkedTerms.includes(found.slug)) continue;
|
|
330
|
+
remember(found.slug);
|
|
331
|
+
if (found.index > cursor) children.push(newText(core, child.content.slice(cursor, found.index)));
|
|
332
|
+
const open = new core.Token("link_open", "a", 1);
|
|
333
|
+
open.attrSet("href", md.normalizeLink(options.termHref(found.slug)));
|
|
334
|
+
open.attrSet("class", "blog-term");
|
|
335
|
+
children.push(open, newText(core, found.text), new core.Token("link_close", "a", -1));
|
|
336
|
+
cursor = found.index + found.text.length;
|
|
337
|
+
}
|
|
338
|
+
if (cursor === 0) {
|
|
339
|
+
children.push(child);
|
|
340
|
+
} else if (cursor < child.content.length) {
|
|
341
|
+
children.push(newText(core, child.content.slice(cursor)));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
token.children = children;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Turns top-level fences of registered types into `blog_block` tokens. */
|
|
350
|
+
function addBlockTokens(md: Markdown, types: ReadonlySet<string>): void {
|
|
351
|
+
md.core.ruler.push("blog_blocks", (core) => {
|
|
352
|
+
for (const token of core.tokens) {
|
|
353
|
+
if (token.type !== "fence" || token.level !== 0) continue;
|
|
354
|
+
const [type = "", ...rest] = token.info.trim().split(/\s+/);
|
|
355
|
+
if (!types.has(type)) continue;
|
|
356
|
+
token.type = BLOCK_TOKEN;
|
|
357
|
+
token.meta = { type, info: rest.join(" ") };
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getPluginsByType<TNode>(plugins: readonly BlockPlugin<TNode>[]): Map<string, BlockPlugin<TNode>> {
|
|
363
|
+
const byType = new Map<string, BlockPlugin<TNode>>();
|
|
364
|
+
for (const plugin of plugins) {
|
|
365
|
+
if (!BLOCK_TYPE.test(plugin.type)) {
|
|
366
|
+
throw new Error(`Block plugin type "${plugin.type}" must be lower-case kebab-case, e.g. "chart".`);
|
|
367
|
+
}
|
|
368
|
+
if (byType.has(plugin.type)) throw new Error(`Block plugin type "${plugin.type}" is registered twice.`);
|
|
369
|
+
byType.set(plugin.type, plugin);
|
|
370
|
+
}
|
|
371
|
+
return byType;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function createMarkdown<TNode>(
|
|
375
|
+
options: RenderArticleOptions<TNode>,
|
|
376
|
+
plugins: ReadonlyMap<string, BlockPlugin<TNode>>,
|
|
377
|
+
state: RenderState,
|
|
378
|
+
): Markdown {
|
|
379
|
+
const messages = options.messages ?? en.render;
|
|
380
|
+
const md = new MarkdownIt({ html: false, linkify: true, typographer: false });
|
|
381
|
+
md.use(footnote);
|
|
382
|
+
md.validateLink = isSafeLink;
|
|
383
|
+
addImages(md, options.images);
|
|
384
|
+
addFootnoteMarkup(md, messages);
|
|
385
|
+
addExternalLinks(md, options.siteHosts ?? [], messages);
|
|
386
|
+
addBlockTokens(md, new Set(plugins.keys()));
|
|
387
|
+
addHeadingIds(md, state);
|
|
388
|
+
addGlossaryLinks(
|
|
389
|
+
md,
|
|
390
|
+
{ glossary: options.glossary ?? [], selfSlug: options.selfSlug, termHref: options.termHref ?? getDefaultTermHref },
|
|
391
|
+
state,
|
|
392
|
+
);
|
|
393
|
+
return md;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderTableOfContents(md: Markdown, headings: readonly ArticleHeading[], maxLevel: number, label: string): string | null {
|
|
397
|
+
const items = headings.filter((heading) => heading.level >= 2 && heading.level <= maxLevel);
|
|
398
|
+
const first = items[0];
|
|
399
|
+
if (first === undefined) return null;
|
|
400
|
+
const escape = md.utils.escapeHtml;
|
|
401
|
+
const levels = [first.level];
|
|
402
|
+
let html = "";
|
|
403
|
+
items.forEach((heading, index) => {
|
|
404
|
+
if (index > 0) {
|
|
405
|
+
if (heading.level > (levels.at(-1) ?? heading.level)) {
|
|
406
|
+
html += "<ol>";
|
|
407
|
+
levels.push(heading.level);
|
|
408
|
+
} else {
|
|
409
|
+
html += "</li>";
|
|
410
|
+
while (levels.length > 1 && heading.level < (levels.at(-1) ?? heading.level)) {
|
|
411
|
+
html += "</ol></li>";
|
|
412
|
+
levels.pop();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
html += `<li><a href="#${escape(heading.id)}">${escape(heading.text)}</a>`;
|
|
417
|
+
});
|
|
418
|
+
html += "</li>" + "</ol></li>".repeat(levels.length - 1);
|
|
419
|
+
return `<nav class="blog-toc" aria-label="${escape(label)}"><ol>${html}</ol></nav>\n`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function addHtmlSegment<TNode>(segments: ArticleSegment<TNode>[], html: string): void {
|
|
423
|
+
if (html === "") return;
|
|
424
|
+
const last = segments.at(-1);
|
|
425
|
+
if (last?.kind === "html") {
|
|
426
|
+
segments[segments.length - 1] = { kind: "html", html: last.html + html };
|
|
427
|
+
} else {
|
|
428
|
+
segments.push({ kind: "html", html });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function renderArticle<TNode = unknown>(markdown: string, options: RenderArticleOptions<TNode> = {}): RenderedArticle<TNode> {
|
|
433
|
+
const plugins = getPluginsByType(options.blocks ?? []);
|
|
434
|
+
const state: RenderState = { headings: [], linkedTerms: [] };
|
|
435
|
+
const md = createMarkdown(options, plugins, state);
|
|
436
|
+
const env = {};
|
|
437
|
+
const tokens = md.parse(markdown, env);
|
|
438
|
+
|
|
439
|
+
const segments: ArticleSegment<TNode>[] = [];
|
|
440
|
+
let start = 0;
|
|
441
|
+
tokens.forEach((token, index) => {
|
|
442
|
+
if (token.type !== BLOCK_TOKEN) return;
|
|
443
|
+
addHtmlSegment(segments, md.renderer.render(tokens.slice(start, index), md.options, env));
|
|
444
|
+
start = index + 1;
|
|
445
|
+
const meta = token.meta as { type: string; info: string };
|
|
446
|
+
const plugin = plugins.get(meta.type);
|
|
447
|
+
if (plugin === undefined) return;
|
|
448
|
+
const output = plugin.render({ type: meta.type, info: meta.info, content: token.content, article: options.article ?? {} });
|
|
449
|
+
if (output.kind === "html") {
|
|
450
|
+
addHtmlSegment(segments, output.html);
|
|
451
|
+
} else {
|
|
452
|
+
segments.push({ kind: "node", type: meta.type, node: output.node });
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
addHtmlSegment(segments, md.renderer.render(tokens.slice(start), md.options, env));
|
|
456
|
+
|
|
457
|
+
const hasNodes = segments.some((segment) => segment.kind === "node");
|
|
458
|
+
const html = hasNodes ? null : segments.map((segment) => (segment.kind === "html" ? segment.html : "")).join("");
|
|
459
|
+
const toc =
|
|
460
|
+
options.toc === undefined || options.toc === false
|
|
461
|
+
? null
|
|
462
|
+
: renderTableOfContents(
|
|
463
|
+
md,
|
|
464
|
+
state.headings,
|
|
465
|
+
options.toc === true ? DEFAULT_TOC_MAX_LEVEL : options.toc.maxLevel,
|
|
466
|
+
(options.messages ?? en.render).tableOfContents,
|
|
467
|
+
);
|
|
468
|
+
return {
|
|
469
|
+
html,
|
|
470
|
+
segments,
|
|
471
|
+
headings: state.headings,
|
|
472
|
+
toc,
|
|
473
|
+
linkedTerms: state.linkedTerms,
|
|
474
|
+
readingMinutes: getReadingMinutes(markdown, options.wordsPerMinute),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The plugin blocks a text uses, without rendering it: for the quality gate (BL-6). */
|
|
479
|
+
export function findArticleBlocks(markdown: string, plugins: readonly BlockPlugin[]): FoundBlock[] {
|
|
480
|
+
const byType = getPluginsByType(plugins);
|
|
481
|
+
const md = createMarkdown({ blocks: plugins }, byType, { headings: [], linkedTerms: [] });
|
|
482
|
+
return md.parse(markdown, {}).flatMap((token) => {
|
|
483
|
+
if (token.type !== BLOCK_TOKEN) return [];
|
|
484
|
+
const meta = token.meta as { type: string; info: string };
|
|
485
|
+
return [{ type: meta.type, info: meta.info, line: (token.map?.[0] ?? 0) + 1, requires: byType.get(meta.type)?.requires ?? [] }];
|
|
486
|
+
});
|
|
487
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Heading text → anchor id: lower-case ASCII letters, digits and hyphens.
|
|
2
|
+
|
|
3
|
+
/** Letters that Unicode does not decompose into a base letter plus a mark. */
|
|
4
|
+
const FOLDED_LETTERS: Readonly<Record<string, string>> = {
|
|
5
|
+
"\u0142": "l", // l with stroke
|
|
6
|
+
"\u0111": "d", // d with stroke
|
|
7
|
+
"\u00f8": "o", // o with stroke
|
|
8
|
+
"\u00e6": "ae",
|
|
9
|
+
"\u0153": "oe",
|
|
10
|
+
"\u00df": "ss",
|
|
11
|
+
"\u00fe": "th",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function slugifyHeading(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[\u0142\u0111\u00f8\u00e6\u0153\u00df\u00fe]/g, (letter) => FOLDED_LETTERS[letter] ?? letter)
|
|
18
|
+
.normalize("NFD")
|
|
19
|
+
.replace(/\p{M}/gu, "")
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-+|-+$/g, "");
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the articles table exists
|
|
2
|
+
// and answers, i.e. `softure migrate` ran. It reads no rows.
|
|
3
|
+
import { ok, type HealthCheck } from "@softure-ai/core";
|
|
4
|
+
import type { Queryable } from "@softure-ai/db";
|
|
5
|
+
import { sql } from "drizzle-orm";
|
|
6
|
+
|
|
7
|
+
export const checkArticlesTable: HealthCheck = async (context) => {
|
|
8
|
+
// Core types `db` as unknown; the health route passes the @softure-ai/db handle.
|
|
9
|
+
const db = context.db as Queryable;
|
|
10
|
+
await db.execute(sql`select 1 from blog.articles limit 0`);
|
|
11
|
+
return ok();
|
|
12
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Server-only API of @softure-ai/blog. Store functions receive the module context
|
|
2
|
+
// (`{ db, clock, config }`) and never read request scope; the renderer is pure. `next/*` imports are
|
|
3
|
+
// not allowed here.
|
|
4
|
+
export { computeContentHash, parseArticleFile, type ArticleFileResult, type ParseArticleFileOptions } from "../content/article-file.js";
|
|
5
|
+
export {
|
|
6
|
+
findArticleBySlug,
|
|
7
|
+
findSlugRedirect,
|
|
8
|
+
getPublishedArticle,
|
|
9
|
+
listArticles,
|
|
10
|
+
publishArticle,
|
|
11
|
+
type BlogContext,
|
|
12
|
+
type ListArticlesFilter,
|
|
13
|
+
} from "../db/articles.js";
|
|
14
|
+
export {
|
|
15
|
+
runBlogPublish,
|
|
16
|
+
type ArticleFile,
|
|
17
|
+
type BlogPublishRun,
|
|
18
|
+
type PublishedChange,
|
|
19
|
+
type PublishGate,
|
|
20
|
+
type PublishProblem,
|
|
21
|
+
type RunBlogPublishOptions,
|
|
22
|
+
} from "../db/publish-run.js";
|
|
23
|
+
export { checkArticlesTable } from "./health.js";
|
|
24
|
+
export { getBlogMessages, getBlogOptions, getBlogRefreshPath, getBlogReservedSlugs, getBlogRoutes, getQualitySettings } from "./options.js";
|
|
25
|
+
export * from "../discovery/index.js";
|
|
26
|
+
export * from "../pages/index.js";
|
|
27
|
+
export * from "../quality/index.js";
|
|
28
|
+
export * from "../render/index.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The loader of the brand's fonts for the article OG card (`blog({ brand: { fonts } })`): each source
|
|
2
|
+
// is read once, checked to be a font Satori draws (.ttf, .otf, .woff; not .woff2), and kept for the
|
|
3
|
+
// life of the loader. A failed read is not kept, so a fixed file is picked up by the next call. It
|
|
4
|
+
// imports no Next code: the card's route (`@softure-ai/blog/next`) and `softure-blog check` share it.
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
7
|
+
import type { OgFontSource, OgFontWeight } from "../options.js";
|
|
8
|
+
|
|
9
|
+
/** A font for the card, as `ImageResponse` takes it. */
|
|
10
|
+
export interface OgFont {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly data: ArrayBuffer;
|
|
13
|
+
readonly weight?: OgFontWeight;
|
|
14
|
+
readonly style?: "normal" | "italic";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type OgFontsResult = { readonly ok: true; readonly fonts: readonly OgFont[] } | { readonly ok: false; readonly error: string };
|
|
18
|
+
|
|
19
|
+
type BytesResult = { readonly ok: true; readonly data: ArrayBuffer } | { readonly ok: false; readonly reason: string };
|
|
20
|
+
|
|
21
|
+
export interface OgFontLoaderOptions {
|
|
22
|
+
/** Where a relative path starts: the app's root. */
|
|
23
|
+
readonly root: string;
|
|
24
|
+
readonly readFile?: (path: string) => Promise<Uint8Array>;
|
|
25
|
+
readonly fetchImpl?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The first four bytes of each font format Satori parses. */
|
|
29
|
+
const FONT_SIGNATURES = ["wOFF", "OTTO", "\u0000\u0001\u0000\u0000", "true"];
|
|
30
|
+
const WOFF2_SIGNATURE = "wOF2";
|
|
31
|
+
|
|
32
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
33
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
34
|
+
copy.set(bytes);
|
|
35
|
+
return copy.buffer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkFontBytes(bytes: Uint8Array): BytesResult {
|
|
39
|
+
const signature = String.fromCharCode(...bytes.subarray(0, 4));
|
|
40
|
+
if (signature === WOFF2_SIGNATURE) return { ok: false, reason: "a .woff2 font, which the card cannot read; use the .woff or .ttf file" };
|
|
41
|
+
if (!FONT_SIGNATURES.includes(signature)) return { ok: false, reason: "not a .ttf, .otf or .woff font" };
|
|
42
|
+
return { ok: true, data: toArrayBuffer(bytes) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getErrorCode(error: unknown): string {
|
|
46
|
+
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
|
|
47
|
+
return error instanceof Error ? error.message : String(error);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A loader with its own cache, keyed by the resolved path or URL. */
|
|
51
|
+
export function createOgFontLoader(options: OgFontLoaderOptions): (fonts: readonly OgFontSource[]) => Promise<OgFontsResult> {
|
|
52
|
+
const read = options.readFile ?? readFile;
|
|
53
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
54
|
+
const cache = new Map<string, Promise<BytesResult>>();
|
|
55
|
+
|
|
56
|
+
async function readSource(location: string, isUrl: boolean): Promise<BytesResult> {
|
|
57
|
+
if (!isUrl) {
|
|
58
|
+
try {
|
|
59
|
+
return checkFontBytes(await read(location));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { ok: false, reason: `the file cannot be read (${getErrorCode(error)})` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let response: Response;
|
|
65
|
+
try {
|
|
66
|
+
response = await fetchImpl(location);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { ok: false, reason: `the request failed (${getErrorCode(error)})` };
|
|
69
|
+
}
|
|
70
|
+
if (!response.ok) return { ok: false, reason: `the server answered ${response.status}` };
|
|
71
|
+
try {
|
|
72
|
+
return checkFontBytes(new Uint8Array(await response.arrayBuffer()));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { ok: false, reason: `the answer could not be read (${getErrorCode(error)})` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getBytes(location: string, isUrl: boolean): Promise<BytesResult> {
|
|
79
|
+
const cached = cache.get(location);
|
|
80
|
+
if (cached !== undefined) return cached;
|
|
81
|
+
const pending = readSource(location, isUrl).then((result) => {
|
|
82
|
+
if (!result.ok) cache.delete(location);
|
|
83
|
+
return result;
|
|
84
|
+
});
|
|
85
|
+
cache.set(location, pending);
|
|
86
|
+
return pending;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return async (fonts) => {
|
|
90
|
+
const located = fonts.map((font) => {
|
|
91
|
+
const isUrl = font.src.startsWith("https://");
|
|
92
|
+
return { font, isUrl, location: isUrl || isAbsolute(font.src) ? font.src : resolve(options.root, font.src) };
|
|
93
|
+
});
|
|
94
|
+
const results = await Promise.all(located.map(async (entry) => ({ ...entry, result: await getBytes(entry.location, entry.isUrl) })));
|
|
95
|
+
const loaded: OgFont[] = [];
|
|
96
|
+
for (const [index, { font, location, result }] of results.entries()) {
|
|
97
|
+
if (!result.ok) return { ok: false, error: `Blog OG card: brand.fonts[${index}] "${font.src}": ${result.reason} (${location}).` };
|
|
98
|
+
loaded.push({ name: font.name, data: result.data, weight: font.weight, style: font.style });
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, fonts: loaded };
|
|
101
|
+
};
|
|
102
|
+
}
|