@hraness/design-kit 0.31.1
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/ARTICLE_COPY.md +208 -0
- package/HERO_FIELDS.md +55 -0
- package/ICONS.md +56 -0
- package/LANTERN_MATERIAL.md +60 -0
- package/LICENSE +21 -0
- package/MARKETING_COPY.md +49 -0
- package/MARKETING_PRESET.md +108 -0
- package/PALETTES.md +90 -0
- package/PAPER_THEME.md +128 -0
- package/README.md +745 -0
- package/STATUS_PAGES.md +67 -0
- package/STYLE.md +245 -0
- package/WRITING.md +72 -0
- package/dist/browser/index.js +2345 -0
- package/dist/chunk-44djxk16.js +6872 -0
- package/dist/chunk-5gtx3pza.js +9 -0
- package/dist/chunk-77391vmq.js +454 -0
- package/dist/chunk-7b1586eb.js +1209 -0
- package/dist/chunk-8834fh4n.js +6 -0
- package/dist/chunk-9t8xyqte.js +870 -0
- package/dist/chunk-cejpzyfh.js +446 -0
- package/dist/chunk-eh71jz57.js +429 -0
- package/dist/chunk-evb02bc1.js +516 -0
- package/dist/chunk-h4k7yv6x.js +368 -0
- package/dist/chunk-he8eznb1.js +760 -0
- package/dist/chunk-mr1vdcjq.js +404 -0
- package/dist/chunk-wzvdn8ey.js +116 -0
- package/dist/fonts/nebula-sans/social-fonts.generated.js +53 -0
- package/dist/icons.js +48 -0
- package/dist/index.js +237 -0
- package/dist/launch.js +41 -0
- package/dist/mockups/client.js +422 -0
- package/dist/mockups/index.js +1850 -0
- package/dist/portfolio.js +1549 -0
- package/dist/provider-marks.js +19 -0
- package/dist/react/charts.js +11 -0
- package/dist/react/hero-backdrop.js +8 -0
- package/dist/react/index.js +7252 -0
- package/dist/react/platform-install.js +10 -0
- package/dist/react/server.js +137 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +5392 -0
- package/dist/syntax-highlighting.js +15 -0
- package/dist/testing.js +250 -0
- package/package.json +377 -0
- package/portfolio-inventory.json +31 -0
- package/scripts/check-lantern-material-snapshot.d.mts +26 -0
- package/scripts/check-lantern-material-snapshot.mjs +126 -0
- package/scripts/check-marketing-snapshot.d.mts +12 -0
- package/scripts/check-marketing-snapshot.mjs +58 -0
- package/scripts/lantern-material-snapshot.ts +101 -0
- package/scripts/marketing-textures.ts +48 -0
- package/scripts/paper-theme-snapshot.ts +97 -0
- package/scripts/product-marketing-snapshot.ts +149 -0
- package/src/appearance-menu.css +266 -0
- package/src/appearance.ts +32 -0
- package/src/article-html.ts +269 -0
- package/src/article.ts +686 -0
- package/src/browser/appearance-menu.ts +604 -0
- package/src/browser/artifact-share.ts +164 -0
- package/src/browser/design-palette.ts +197 -0
- package/src/browser/foil.ts +124 -0
- package/src/browser/hero-light.ts +11 -0
- package/src/browser/index.ts +29 -0
- package/src/browser/status-field.ts +432 -0
- package/src/browser/status-page.ts +100 -0
- package/src/browser/sticky-offset.ts +82 -0
- package/src/browser/theme-color-sync.ts +213 -0
- package/src/charts.css +7 -0
- package/src/compiler-components.css +34 -0
- package/src/compiler-foundation.css +18 -0
- package/src/compiler-palettes.css +7 -0
- package/src/compiler-tokens.css +342 -0
- package/src/components.css +3 -0
- package/src/design-gallery.css +431 -0
- package/src/effects.css +14 -0
- package/src/fonts/geist-mono/GeistMono[wght].woff2 +0 -0
- package/src/fonts/geist-mono/OFL.txt +93 -0
- package/src/fonts/geist-mono/PROVENANCE.md +12 -0
- package/src/fonts/instrument-serif/OFL.txt +93 -0
- package/src/fonts/instrument-serif/UPSTREAM.md +14 -0
- package/src/fonts/instrument-serif/instrument-serif-latin-400.woff2 +0 -0
- package/src/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/fonts/nebula-sans/NebulaSans-Black.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BlackItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BoldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BookItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Light.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-LightItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Medium.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-MediumItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Semibold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-SemiboldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/fonts/nebula-sans/social-fonts.generated.ts +3873 -0
- package/src/fonts.css +106 -0
- package/src/icons/act60/act60.svg +5 -0
- package/src/icons/act60/application.svg +9 -0
- package/src/icons/act60/chapter-compare.svg +3 -0
- package/src/icons/act60/decree-residence.svg +6 -0
- package/src/icons/act60/estimate.svg +7 -0
- package/src/icons/act60/export-services.svg +4 -0
- package/src/icons/act60/investor.svg +13 -0
- package/src/icons/act60/presence.svg +7 -0
- package/src/icons/aicharts/aicharts.svg +6 -0
- package/src/icons/aicharts/cost-compare.svg +4 -0
- package/src/icons/aicharts/task-audio.svg +7 -0
- package/src/icons/aicharts/task-coding.svg +6 -0
- package/src/icons/aicharts/task-images.svg +3 -0
- package/src/icons/aicharts/task-reasoning.svg +9 -0
- package/src/icons/aicharts/task-research.svg +5 -0
- package/src/icons/aicharts/task-video.svg +4 -0
- package/src/icons/manifest.json +1371 -0
- package/src/icons/platonik/conversation.svg +3 -0
- package/src/icons/platonik/creature.svg +7 -0
- package/src/icons/platonik/economy.svg +4 -0
- package/src/icons/platonik/keep-light.svg +6 -0
- package/src/icons/platonik/platonik.svg +7 -0
- package/src/icons/platonik/spark-route.svg +15 -0
- package/src/icons/receipts/act60-retry.receipt.json +888 -0
- package/src/icons/receipts/act60.receipt.json +1366 -0
- package/src/icons/receipts/aicharts.receipt.json +1546 -0
- package/src/icons/receipts/platonik.receipt.json +1011 -0
- package/src/icons/receipts/roughday.receipt.json +770 -0
- package/src/icons/receipts/shared.receipt.json +1296 -0
- package/src/icons/receipts/slopcamera.receipt.json +1495 -0
- package/src/icons/receipts/soundfish-retry.receipt.json +460 -0
- package/src/icons/receipts/soundfish.receipt.json +1709 -0
- package/src/icons/receipts/sponge.receipt.json +1743 -0
- package/src/icons/receipts/stripe-history-retry.receipt.json +145 -0
- package/src/icons/receipts/stripe-history.receipt.json +1202 -0
- package/src/icons/receipts/wordcell.receipt.json +1924 -0
- package/src/icons/roughday/finance.svg +7 -0
- package/src/icons/roughday/roughday.svg +6 -0
- package/src/icons/roughday/technology.svg +4 -0
- package/src/icons/roughday/world.svg +4 -0
- package/src/icons/sets/act60.json +63 -0
- package/src/icons/sets/aicharts.json +63 -0
- package/src/icons/sets/platonik.json +39 -0
- package/src/icons/sets/roughday.json +25 -0
- package/src/icons/sets/shared.json +12 -0
- package/src/icons/sets/slopcamera.json +47 -0
- package/src/icons/sets/soundfish.json +43 -0
- package/src/icons/sets/sponge.json +51 -0
- package/src/icons/sets/stripe-history.json +39 -0
- package/src/icons/sets/wordcell.json +51 -0
- package/src/icons/shared/agent-skill.svg +4 -0
- package/src/icons/shared/cli.svg +5 -0
- package/src/icons/shared/privacy.svg +3 -0
- package/src/icons/shared/research.svg +7 -0
- package/src/icons/shared/sdk.svg +3 -0
- package/src/icons/slopcamera/diagram.svg +3 -0
- package/src/icons/slopcamera/direct-scene.svg +5 -0
- package/src/icons/slopcamera/edit-video.svg +17 -0
- package/src/icons/slopcamera/mcp.svg +3 -0
- package/src/icons/slopcamera/native-world.svg +3 -0
- package/src/icons/slopcamera/slopcamera.svg +11 -0
- package/src/icons/soundfish/agent-ui.svg +7 -0
- package/src/icons/soundfish/collaborate.svg +5 -0
- package/src/icons/soundfish/compose.svg +3 -0
- package/src/icons/soundfish/hear.svg +6 -0
- package/src/icons/soundfish/human-ui.svg +6 -0
- package/src/icons/soundfish/share.svg +3 -0
- package/src/icons/soundfish/soundfish.svg +6 -0
- package/src/icons/sponge/api.svg +3 -0
- package/src/icons/sponge/documents.svg +3 -0
- package/src/icons/sponge/library.svg +3 -0
- package/src/icons/sponge/one-question.svg +5 -0
- package/src/icons/sponge/people-direct.svg +5 -0
- package/src/icons/sponge/read.svg +3 -0
- package/src/icons/sponge/save.svg +4 -0
- package/src/icons/sponge/sponge.svg +7 -0
- package/src/icons/stripe-history/company-history.svg +3 -0
- package/src/icons/stripe-history/evidence.svg +4 -0
- package/src/icons/stripe-history/independence.svg +7 -0
- package/src/icons/stripe-history/publications.svg +11 -0
- package/src/icons/stripe-history/sources.svg +6 -0
- package/src/icons/stripe-history/stripe-history.svg +3 -0
- package/src/icons/wordcell/backlinks.svg +6 -0
- package/src/icons/wordcell/capture.svg +6 -0
- package/src/icons/wordcell/git-provenance.svg +4 -0
- package/src/icons/wordcell/kb.svg +4 -0
- package/src/icons/wordcell/markdown.svg +4 -0
- package/src/icons/wordcell/scopes.svg +3 -0
- package/src/icons/wordcell/search.svg +7 -0
- package/src/icons.generated.ts +734 -0
- package/src/icons.ts +51 -0
- package/src/index.ts +275 -0
- package/src/lantern-material.css +213 -0
- package/src/launch.ts +624 -0
- package/src/marketing-assets/UPSTREAM.md +18 -0
- package/src/marketing-assets/cells.svg +1 -0
- package/src/marketing-assets/grain.svg +1 -0
- package/src/marketing-forced-colors.css +12 -0
- package/src/mockups/client.tsx +415 -0
- package/src/mockups/core.tsx +318 -0
- package/src/mockups/frames.tsx +368 -0
- package/src/mockups/index.ts +14 -0
- package/src/mockups/surfaces.tsx +681 -0
- package/src/mockups.css +2607 -0
- package/src/palette-appearance.ts +45 -0
- package/src/palette-bridge.css +124 -0
- package/src/palette-color.ts +41 -0
- package/src/palette-system.css +721 -0
- package/src/palette-themes.ts +23 -0
- package/src/palette-tokens.stylex.ts +519 -0
- package/src/palettes.css +3 -0
- package/src/palettes.ts +161 -0
- package/src/paper-theme.css +244 -0
- package/src/plain-publication.css +1241 -0
- package/src/plain-site.css +397 -0
- package/src/platforms.ts +151 -0
- package/src/portfolio.generated.json +1595 -0
- package/src/portfolio.generated.ts +1596 -0
- package/src/portfolio.ts +226 -0
- package/src/product-marketing-foundation.css +433 -0
- package/src/product-marketing-preset.css +261 -0
- package/src/product-marketing.css +2186 -0
- package/src/provider-marks.generated.ts +112 -0
- package/src/provider-marks.ts +143 -0
- package/src/react/animated-rail-stage.stylex.ts +15 -0
- package/src/react/animated-rail-stage.tsx +51 -0
- package/src/react/app-shell.stylex.ts +104 -0
- package/src/react/app-shell.tsx +121 -0
- package/src/react/article.tsx +602 -0
- package/src/react/aurora-dots-background.tsx +34 -0
- package/src/react/charts.stylex.ts +563 -0
- package/src/react/charts.tsx +610 -0
- package/src/react/chat.stylex.ts +34 -0
- package/src/react/chat.tsx +155 -0
- package/src/react/design-gallery.tsx +943 -0
- package/src/react/design-palette.stylex.ts +124 -0
- package/src/react/design-palette.tsx +196 -0
- package/src/react/design-theme-context.tsx +41 -0
- package/src/react/effects.stylex.ts +325 -0
- package/src/react/fader.stylex.ts +71 -0
- package/src/react/fader.tsx +151 -0
- package/src/react/foil-card-math.ts +100 -0
- package/src/react/foil-card-surface.stylex.ts +326 -0
- package/src/react/foil-card-surface.tsx +742 -0
- package/src/react/foil-mark.tsx +35 -0
- package/src/react/foil.stylex.ts +163 -0
- package/src/react/haptics.ts +228 -0
- package/src/react/hero-backdrop.tsx +22 -0
- package/src/react/index.ts +38 -0
- package/src/react/keyboard-shortcuts.ts +228 -0
- package/src/react/lantern-material-gallery.tsx +166 -0
- package/src/react/lantern-material.stylex.ts +39 -0
- package/src/react/launch-beats.tsx +73 -0
- package/src/react/navigation-rail.stylex.ts +130 -0
- package/src/react/navigation-rail.tsx +228 -0
- package/src/react/particle-halo.tsx +115 -0
- package/src/react/phaser-dots.tsx +501 -0
- package/src/react/platform-icons.tsx +94 -0
- package/src/react/platform-install.stylex.ts +385 -0
- package/src/react/platform-install.tsx +389 -0
- package/src/react/playback-transport.stylex.ts +14 -0
- package/src/react/playback-transport.tsx +112 -0
- package/src/react/procedural-backdrop.tsx +192 -0
- package/src/react/procedural-recipe.ts +323 -0
- package/src/react/product-marketing.stylex.ts +4469 -0
- package/src/react/product-marketing.tsx +1611 -0
- package/src/react/production-data-preview-notice.stylex.ts +40 -0
- package/src/react/production-data-preview-notice.tsx +34 -0
- package/src/react/provider-mark.stylex.ts +132 -0
- package/src/react/provider-mark.tsx +120 -0
- package/src/react/relative-time.tsx +117 -0
- package/src/react/route-state.stylex.ts +39 -0
- package/src/react/route-state.tsx +350 -0
- package/src/react/server.ts +11 -0
- package/src/react/social-kit-panel.tsx +103 -0
- package/src/react/sticky-offset.tsx +33 -0
- package/src/react/surfaces.stylex.ts +179 -0
- package/src/react/surfaces.tsx +330 -0
- package/src/react/syntax-code.tsx +33 -0
- package/src/react/theme-color-sync.ts +1 -0
- package/src/react/theme-resolution.ts +11 -0
- package/src/react/theme.stylex.ts +276 -0
- package/src/react/theme.tsx +402 -0
- package/src/reading.css +72 -0
- package/src/relative-time.ts +185 -0
- package/src/reset.css +2 -0
- package/src/site-shell.css +16 -0
- package/src/status-page-html.ts +63 -0
- package/src/status-page.css +276 -0
- package/src/status-page.ts +323 -0
- package/src/styles.css +21 -0
- package/src/syntax-code-lexers.ts +303 -0
- package/src/syntax-highlighting.css +78 -0
- package/src/syntax-highlighting.ts +477 -0
- package/src/syntax-tokens.ts +33 -0
- package/src/testing.ts +307 -0
- package/src/tokens.css +3 -0
- package/src/typography.css +86 -0
- package/vendor/evilcharts/LICENSE +21 -0
- package/vendor/evilcharts/UPSTREAM.md +14 -0
- package/vendor/platform-marks/LICENSE +30 -0
- package/vendor/platform-marks/UPSTREAM.md +15 -0
- package/vendor/platform-marks/apple.svg +1 -0
- package/vendor/platform-marks/linux.svg +1 -0
- package/vendor/provider-marks/CRUSH-LICENSE.md +134 -0
- package/vendor/provider-marks/LICENSE +21 -0
- package/vendor/provider-marks/UPSTREAM.md +27 -0
- package/vendor/provider-marks/aider.svg +3 -0
- package/vendor/provider-marks/crush-heartbit.svg +8 -0
- package/vendor/provider-marks/lobehub/alibabacloud-color.svg +1 -0
- package/vendor/provider-marks/lobehub/alibabacloud.svg +1 -0
- package/vendor/provider-marks/lobehub/amp-color.svg +1 -0
- package/vendor/provider-marks/lobehub/amp.svg +1 -0
- package/vendor/provider-marks/lobehub/anthropic.svg +1 -0
- package/vendor/provider-marks/lobehub/claude-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claude.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode.svg +1 -0
- package/vendor/provider-marks/lobehub/codex-color.svg +1 -0
- package/vendor/provider-marks/lobehub/codex.svg +1 -0
- package/vendor/provider-marks/lobehub/cursor.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek-color.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek.svg +1 -0
- package/vendor/provider-marks/lobehub/devin-color.svg +1 -0
- package/vendor/provider-marks/lobehub/devin.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini-color.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli-color.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli.svg +1 -0
- package/vendor/provider-marks/lobehub/goose.svg +1 -0
- package/vendor/provider-marks/lobehub/meta-color.svg +1 -0
- package/vendor/provider-marks/lobehub/meta.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral-color.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral.svg +1 -0
- package/vendor/provider-marks/lobehub/moonshot.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia-color.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia.svg +1 -0
- package/vendor/provider-marks/lobehub/openai.svg +1 -0
- package/vendor/provider-marks/lobehub/opencode.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity-color.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen-color.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen.svg +1 -0
- package/vendor/provider-marks/lobehub/xai.svg +1 -0
- package/vendor/provider-marks/lobehub/zai.svg +1 -0
package/src/article.ts
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-neutral article records shared by the React article components,
|
|
3
|
+
* the static HTML renderer, and each site's editorial admission registry.
|
|
4
|
+
*
|
|
5
|
+
* Products own their article content and admission data. This module owns the
|
|
6
|
+
* shared shapes, the admission rubric, deterministic date text, and the
|
|
7
|
+
* provenance sentence so every host states drafting and review the same way.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A calendar date written as `YYYY-MM-DD`. */
|
|
11
|
+
export type ArticleIsoDate = `${number}-${number}-${number}`;
|
|
12
|
+
|
|
13
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/u;
|
|
14
|
+
const DAY_MS = 86_400_000;
|
|
15
|
+
const MONTHS = [
|
|
16
|
+
"January",
|
|
17
|
+
"February",
|
|
18
|
+
"March",
|
|
19
|
+
"April",
|
|
20
|
+
"May",
|
|
21
|
+
"June",
|
|
22
|
+
"July",
|
|
23
|
+
"August",
|
|
24
|
+
"September",
|
|
25
|
+
"October",
|
|
26
|
+
"November",
|
|
27
|
+
"December",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/** Parse a real calendar date. Returns days since 1970-01-01 in UTC, or null. */
|
|
31
|
+
function isoDateDay(value: string): number | null {
|
|
32
|
+
const match = ISO_DATE.exec(value);
|
|
33
|
+
if (match === null) return null;
|
|
34
|
+
const year = Number(match[1]);
|
|
35
|
+
const month = Number(match[2]);
|
|
36
|
+
const day = Number(match[3]);
|
|
37
|
+
if (year < 1 || month < 1 || month > 12 || day < 1) return null;
|
|
38
|
+
const time = Date.UTC(year, month - 1, day);
|
|
39
|
+
const date = new Date(time);
|
|
40
|
+
if (
|
|
41
|
+
date.getUTCFullYear() !== year
|
|
42
|
+
|| date.getUTCMonth() !== month - 1
|
|
43
|
+
|| date.getUTCDate() !== day
|
|
44
|
+
) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return Math.round(time / DAY_MS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isArticleIsoDate(value: unknown): value is ArticleIsoDate {
|
|
51
|
+
return typeof value === "string" && isoDateDay(value) !== null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requireDay(value: string, label: string): number {
|
|
55
|
+
const day = isoDateDay(value);
|
|
56
|
+
if (day === null) throw new RangeError(`${label} must be a real calendar date written as YYYY-MM-DD.`);
|
|
57
|
+
return day;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whole days from `from` to `to`; negative when `to` is earlier. */
|
|
61
|
+
export function articleDaysBetween(from: ArticleIsoDate, to: ArticleIsoDate): number {
|
|
62
|
+
return requireDay(to, "The end date") - requireDay(from, "The start date");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Deterministic English date text, such as "23 September 2026", independent of time zone and locale. */
|
|
66
|
+
export function formatArticleDate(value: ArticleIsoDate): string {
|
|
67
|
+
requireDay(value, "An article date");
|
|
68
|
+
const [year, month, day] = value.split("-").map(Number) as [number, number, number];
|
|
69
|
+
return `${day} ${MONTHS[month - 1]} ${year}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ArticleDates {
|
|
73
|
+
readonly published: ArticleIsoDate;
|
|
74
|
+
/** Omit unless the content changed after publication. */
|
|
75
|
+
readonly updated?: ArticleIsoDate;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reject impossible dates and an update that precedes publication. */
|
|
79
|
+
export function assertArticleDates(dates: ArticleDates): void {
|
|
80
|
+
const published = requireDay(dates.published, "The published date");
|
|
81
|
+
if (dates.updated === undefined) return;
|
|
82
|
+
const updated = requireDay(dates.updated, "The updated date");
|
|
83
|
+
if (updated < published) throw new RangeError("The updated date cannot precede the published date.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ------------------------------------------------------------------------ */
|
|
87
|
+
/* Links, bylines, and provenance */
|
|
88
|
+
/* ------------------------------------------------------------------------ */
|
|
89
|
+
|
|
90
|
+
const SAFE_HREF = /^(?:https?:\/\/|mailto:|\/(?![/\\])|#|\.{1,2}\/|\?)/iu;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True when `href` holds a control character (Unicode category Cc: U+0000 to
|
|
94
|
+
* U+001F and U+007F to U+009F) or whitespace. Code points are compared
|
|
95
|
+
* directly because Next.js compiles dependencies with a Babel build that
|
|
96
|
+
* cannot rewrite the `\p{Cc}` property escape.
|
|
97
|
+
*/
|
|
98
|
+
function hasControlOrSpace(href: string): boolean {
|
|
99
|
+
for (const character of href) {
|
|
100
|
+
const code = character.codePointAt(0) ?? 0;
|
|
101
|
+
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || /\s/u.test(character)) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Accept web, mail, and relative links. Reject script, data, and protocol-relative URLs. */
|
|
107
|
+
function isSafeHref(href: string): boolean {
|
|
108
|
+
return SAFE_HREF.test(href) && !hasControlOrSpace(href);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Throw a RangeError unless `href` is a web, mail, or relative link. */
|
|
112
|
+
export function assertArticleHref(href: string): void {
|
|
113
|
+
if (!isSafeHref(href)) {
|
|
114
|
+
throw new RangeError(`Unsupported article link: ${JSON.stringify(href)}.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ArticleTocItem = Readonly<{
|
|
119
|
+
/** A fragment link to a heading in the article body. */
|
|
120
|
+
href: `#${string}`;
|
|
121
|
+
label: string;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
export type ArticleSourceItem = Readonly<{
|
|
125
|
+
title: string;
|
|
126
|
+
href: string;
|
|
127
|
+
publisher?: string;
|
|
128
|
+
/** The date someone last opened the source and confirmed the cited claim. */
|
|
129
|
+
checkedOn: ArticleIsoDate;
|
|
130
|
+
}>;
|
|
131
|
+
|
|
132
|
+
export type ArticleIndexItem = Readonly<{
|
|
133
|
+
href: string;
|
|
134
|
+
title: string;
|
|
135
|
+
/** One concrete claim, as a complete sentence. */
|
|
136
|
+
dek: string;
|
|
137
|
+
published: ArticleIsoDate;
|
|
138
|
+
updated?: ArticleIsoDate;
|
|
139
|
+
/** A short category label, such as a series name. */
|
|
140
|
+
eyebrow?: string;
|
|
141
|
+
}>;
|
|
142
|
+
|
|
143
|
+
export const articleCalloutTones = ["note", "limit", "warning"] as const;
|
|
144
|
+
export type ArticleCalloutTone = (typeof articleCalloutTones)[number];
|
|
145
|
+
|
|
146
|
+
export function assertArticleCalloutTone(tone: ArticleCalloutTone): void {
|
|
147
|
+
if (!(articleCalloutTones as readonly string[]).includes(tone)) throw new RangeError("Unknown article callout tone.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The byline text before the linked name. */
|
|
151
|
+
export const ARTICLE_BYLINE_PREFIX = "By";
|
|
152
|
+
export const ARTICLE_TOC_LABEL = "On this page";
|
|
153
|
+
export const ARTICLE_SOURCES_HEADING = "Sources";
|
|
154
|
+
|
|
155
|
+
export function assertArticleAuthor(author: ArticleAuthor): void {
|
|
156
|
+
if (author.kind !== "organization" && author.kind !== "person") throw new RangeError("An article author is an organization or a person.");
|
|
157
|
+
if (!nonBlank(author.name)) throw new RangeError("An article author needs a name.");
|
|
158
|
+
if (author.href !== undefined) assertArticleHref(author.href);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type ArticleAuthor = Readonly<{
|
|
162
|
+
/** An organization byline such as "Hraness", or a person who wrote or adopted the post. */
|
|
163
|
+
kind: "organization" | "person";
|
|
164
|
+
name: string;
|
|
165
|
+
href?: string;
|
|
166
|
+
}>;
|
|
167
|
+
|
|
168
|
+
export const articleReviewerTypes = ["author", "human-editor", "subject-expert", "ai"] as const;
|
|
169
|
+
export type ArticleReviewerType = (typeof articleReviewerTypes)[number];
|
|
170
|
+
|
|
171
|
+
export const articleDraftingKinds = ["ai-from-source", "ai", "ai-assisted", "author"] as const;
|
|
172
|
+
export type ArticleDraftingKind = (typeof articleDraftingKinds)[number];
|
|
173
|
+
|
|
174
|
+
export type ArticleReviewCredit = Readonly<{
|
|
175
|
+
/** Who reviewed, by name. An AI reviewer names the model, for example "Claude Opus 5.5 (claude-opus-5-5) editorial review". */
|
|
176
|
+
reviewer: string;
|
|
177
|
+
reviewerType: ArticleReviewerType;
|
|
178
|
+
}>;
|
|
179
|
+
|
|
180
|
+
export type ArticleProvenanceRecord = Readonly<{
|
|
181
|
+
drafting: ArticleDraftingKind;
|
|
182
|
+
/** The review on record, or null when no review has happened yet. A null review makes no review claim. */
|
|
183
|
+
review: ArticleReviewCredit | null;
|
|
184
|
+
}>;
|
|
185
|
+
|
|
186
|
+
const DRAFTING_PHRASES = {
|
|
187
|
+
"ai-from-source": "Drafted with AI from the source code",
|
|
188
|
+
ai: "Drafted with AI",
|
|
189
|
+
"ai-assisted": "Written with AI assistance",
|
|
190
|
+
author: "Written by the author",
|
|
191
|
+
} as const satisfies Record<ArticleDraftingKind, string>;
|
|
192
|
+
|
|
193
|
+
const REVIEWER_SUFFIXES = {
|
|
194
|
+
ai: "",
|
|
195
|
+
author: ", the author",
|
|
196
|
+
"human-editor": ", a human editor",
|
|
197
|
+
"subject-expert": ", a subject expert",
|
|
198
|
+
} as const satisfies Record<ArticleReviewerType, string>;
|
|
199
|
+
|
|
200
|
+
/** The word the provenance sentence reserves for human-editor reviews. */
|
|
201
|
+
const HUMAN_WORD = /human/iu;
|
|
202
|
+
/**
|
|
203
|
+
* An AI reviewer's name must tell the reader it is AI, because the sentence
|
|
204
|
+
* adds no suffix for it: "AI", "LLM", "model", or a model family name.
|
|
205
|
+
*/
|
|
206
|
+
const AI_WORD = /\b(?:ai|llm|model|claude|gpt|gemini|codex)\b/iu;
|
|
207
|
+
|
|
208
|
+
/** True when an AI reviewer's name says it is AI. */
|
|
209
|
+
export function articleReviewerNameDisclosesAi(reviewer: string): boolean {
|
|
210
|
+
return AI_WORD.test(reviewer);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isOneOf<const T extends readonly string[]>(values: T, value: unknown): value is T[number] {
|
|
214
|
+
return typeof value === "string" && (values as readonly string[]).includes(value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Linear-time trailing-period trim; a `/[.]+$/` replace is quadratic on long runs of periods. */
|
|
218
|
+
function withoutTrailingPeriods(value: string): string {
|
|
219
|
+
let end = value.length;
|
|
220
|
+
while (end > 0 && value.charCodeAt(end - 1) === 46) end -= 1;
|
|
221
|
+
return value.slice(0, end);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function nonBlank(value: unknown): value is string {
|
|
225
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The visible provenance note. For example: "Drafted with AI from the source
|
|
230
|
+
* code and reviewed by Claude Opus 5.5 (claude-opus-5-5) editorial review."
|
|
231
|
+
* The word "human" appears only for a human-editor review.
|
|
232
|
+
*/
|
|
233
|
+
export function articleProvenanceSentence(provenance: ArticleProvenanceRecord): string {
|
|
234
|
+
if (!isOneOf(articleDraftingKinds, provenance.drafting)) throw new RangeError("Unknown article drafting kind.");
|
|
235
|
+
const drafted = DRAFTING_PHRASES[provenance.drafting];
|
|
236
|
+
const { review } = provenance;
|
|
237
|
+
if (review === null) return `${drafted}. It has not been reviewed yet.`;
|
|
238
|
+
if (!isOneOf(articleReviewerTypes, review.reviewerType)) throw new RangeError("Unknown article reviewer type.");
|
|
239
|
+
if (!nonBlank(review.reviewer)) throw new RangeError("An article review must name its reviewer.");
|
|
240
|
+
if (review.reviewerType !== "human-editor" && HUMAN_WORD.test(review.reviewer)) {
|
|
241
|
+
throw new RangeError("Only a human-editor review may use the word \"human\" in its reviewer name.");
|
|
242
|
+
}
|
|
243
|
+
if (review.reviewerType === "ai" && !AI_WORD.test(review.reviewer)) {
|
|
244
|
+
throw new RangeError("An AI review must name a reviewer that says it is AI, for example \"Claude Opus 5.5 (claude-opus-5-5) editorial review\".");
|
|
245
|
+
}
|
|
246
|
+
const reviewer = withoutTrailingPeriods(review.reviewer.trim());
|
|
247
|
+
return `${drafted} and reviewed by ${reviewer}${REVIEWER_SUFFIXES[review.reviewerType]}.`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ------------------------------------------------------------------------ */
|
|
251
|
+
/* Admission rubric */
|
|
252
|
+
/* ------------------------------------------------------------------------ */
|
|
253
|
+
|
|
254
|
+
export type ArticleScore = 0 | 1 | 2;
|
|
255
|
+
|
|
256
|
+
export const articleScoreKeys = [
|
|
257
|
+
"readerUtility",
|
|
258
|
+
"originalEvidence",
|
|
259
|
+
"factualConfidence",
|
|
260
|
+
"hostFit",
|
|
261
|
+
"voiceIntegrity",
|
|
262
|
+
"maintenanceValue",
|
|
263
|
+
] as const;
|
|
264
|
+
export type ArticleScoreKey = (typeof articleScoreKeys)[number];
|
|
265
|
+
export type ArticleScores = Readonly<Record<ArticleScoreKey, ArticleScore>>;
|
|
266
|
+
|
|
267
|
+
/** Minimum total of the six scores for an indexable article; any zero also fails. */
|
|
268
|
+
export const ARTICLE_ADMISSION_MINIMUM = 9;
|
|
269
|
+
/** Reassessment falls this many days after the review, inclusive. */
|
|
270
|
+
export const ARTICLE_REASSESS_WINDOW = { minimumDays: 28, maximumDays: 56 } as const;
|
|
271
|
+
|
|
272
|
+
export const articleLifecycles = ["quarantined", "indexable", "archived"] as const;
|
|
273
|
+
/**
|
|
274
|
+
* `quarantined`: readable, noindex, and absent from sitemaps, feeds, llms.txt, and index lists.
|
|
275
|
+
* `indexable`: passed admission and may enter every discovery surface.
|
|
276
|
+
* `archived`: kept for existing links, noindex, and absent from discovery.
|
|
277
|
+
*/
|
|
278
|
+
export type ArticleLifecycle = (typeof articleLifecycles)[number];
|
|
279
|
+
|
|
280
|
+
export type ArticleReview = ArticleReviewCredit & Readonly<{ reviewedOn: ArticleIsoDate }>;
|
|
281
|
+
export type ArticleHumanReview = Readonly<{
|
|
282
|
+
reviewer: string;
|
|
283
|
+
reviewerType: Exclude<ArticleReviewerType, "ai">;
|
|
284
|
+
reviewedOn: ArticleIsoDate;
|
|
285
|
+
}>;
|
|
286
|
+
|
|
287
|
+
export type ArticleSourceRecord = Readonly<{
|
|
288
|
+
title: string;
|
|
289
|
+
url: string;
|
|
290
|
+
/** The date someone last opened the source and confirmed the cited claim. */
|
|
291
|
+
checkedOn: ArticleIsoDate;
|
|
292
|
+
}>;
|
|
293
|
+
|
|
294
|
+
export type ArticleNearestUrl = Readonly<{
|
|
295
|
+
url: string;
|
|
296
|
+
/** Why this page stays separate from the neighbor, or why it should merge. */
|
|
297
|
+
distinction: string;
|
|
298
|
+
}>;
|
|
299
|
+
|
|
300
|
+
export type ArticleAdmission = Readonly<{
|
|
301
|
+
/** The canonical path or URL this record admits. */
|
|
302
|
+
href: string;
|
|
303
|
+
lifecycle: ArticleLifecycle;
|
|
304
|
+
/** The question a reader arrives with, in their words. */
|
|
305
|
+
readerJob: string;
|
|
306
|
+
/** The answer a reader could not get from the obvious first result. */
|
|
307
|
+
nonObviousAnswer: string;
|
|
308
|
+
originalContribution: string;
|
|
309
|
+
hostFit: string;
|
|
310
|
+
/** Up to three closest pages, each with a keep-or-merge rationale. */
|
|
311
|
+
nearestUrls: readonly ArticleNearestUrl[];
|
|
312
|
+
sources: readonly ArticleSourceRecord[];
|
|
313
|
+
/** Observations that are not paraphrases of the sources. Indexable records need two. */
|
|
314
|
+
observations: readonly string[];
|
|
315
|
+
scores: ArticleScores;
|
|
316
|
+
owner: string;
|
|
317
|
+
drafting: ArticleDraftingKind;
|
|
318
|
+
/** The editorial review on record. Required before indexing. */
|
|
319
|
+
review: ArticleReview | null;
|
|
320
|
+
/** A separate human review. Stays null unless a person reviewed the page. */
|
|
321
|
+
humanReview: ArticleHumanReview | null;
|
|
322
|
+
reassessOn: ArticleIsoDate;
|
|
323
|
+
harmIfWrong: string;
|
|
324
|
+
/** Events that force a refresh, such as a release tag bump, a relation change, or a rename. */
|
|
325
|
+
refreshTriggers: readonly string[];
|
|
326
|
+
}>;
|
|
327
|
+
|
|
328
|
+
export function articleAdmissionScore(scores: ArticleScores): number {
|
|
329
|
+
return articleScoreKeys.reduce((total, key) => total + scores[key], 0);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** True when the six scores total at least nine and none is zero. */
|
|
333
|
+
export function articleAdmissionPasses(scores: ArticleScores): boolean {
|
|
334
|
+
return articleScoreKeys.every((key) => scores[key] > 0)
|
|
335
|
+
&& articleAdmissionScore(scores) >= ARTICLE_ADMISSION_MINIMUM;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function isArticleIndexable(admission: Pick<ArticleAdmission, "lifecycle">): boolean {
|
|
339
|
+
return admission.lifecycle === "indexable";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The provenance a page renders, derived from its admission record. */
|
|
343
|
+
export function articleProvenanceFromAdmission(
|
|
344
|
+
admission: Pick<ArticleAdmission, "drafting" | "review">,
|
|
345
|
+
): ArticleProvenanceRecord {
|
|
346
|
+
return {
|
|
347
|
+
drafting: admission.drafting,
|
|
348
|
+
review: admission.review === null
|
|
349
|
+
? null
|
|
350
|
+
: { reviewer: admission.review.reviewer, reviewerType: admission.review.reviewerType },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Records whose reassessment date is on or before `today`. */
|
|
355
|
+
export function articleAdmissionsDue(
|
|
356
|
+
admissions: readonly ArticleAdmission[],
|
|
357
|
+
today: ArticleIsoDate,
|
|
358
|
+
): readonly ArticleAdmission[] {
|
|
359
|
+
const day = requireDay(today, "Today");
|
|
360
|
+
return admissions.filter((admission) => admission.lifecycle !== "archived"
|
|
361
|
+
&& requireDay(admission.reassessOn, "reassessOn") <= day);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export class ArticleAdmissionError extends Error {
|
|
365
|
+
readonly issues: readonly string[];
|
|
366
|
+
|
|
367
|
+
constructor(issues: readonly string[]) {
|
|
368
|
+
super(`Article admission failed:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
369
|
+
this.name = "ArticleAdmissionError";
|
|
370
|
+
this.issues = issues;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
type Issues = string[];
|
|
375
|
+
|
|
376
|
+
function record(value: unknown): Readonly<Record<string, unknown>> | null {
|
|
377
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
378
|
+
? value as Readonly<Record<string, unknown>>
|
|
379
|
+
: null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function text(issues: Issues, where: string, value: unknown, field: string): string {
|
|
383
|
+
if (!nonBlank(value)) {
|
|
384
|
+
issues.push(`${where}: ${field} must be nonblank text.`);
|
|
385
|
+
return "";
|
|
386
|
+
}
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function date(issues: Issues, where: string, value: unknown, field: string): ArticleIsoDate | null {
|
|
391
|
+
if (!isArticleIsoDate(value)) {
|
|
392
|
+
issues.push(`${where}: ${field} must be a real calendar date written as YYYY-MM-DD.`);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
return value;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function list(issues: Issues, where: string, value: unknown, field: string): readonly unknown[] {
|
|
399
|
+
if (!Array.isArray(value)) {
|
|
400
|
+
issues.push(`${where}: ${field} must be a list.`);
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseReview(issues: Issues, where: string, value: unknown, field: string): ArticleReview | null {
|
|
407
|
+
if (value === null) return null;
|
|
408
|
+
const review = record(value);
|
|
409
|
+
if (review === null) {
|
|
410
|
+
issues.push(`${where}: ${field} must be a review record or null.`);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const reviewer = text(issues, where, review.reviewer, `${field}.reviewer`);
|
|
414
|
+
if (!isOneOf(articleReviewerTypes, review.reviewerType)) {
|
|
415
|
+
issues.push(`${where}: ${field}.reviewerType must be one of ${articleReviewerTypes.join(", ")}.`);
|
|
416
|
+
}
|
|
417
|
+
const reviewedOn = date(issues, where, review.reviewedOn, `${field}.reviewedOn`);
|
|
418
|
+
if (!isOneOf(articleReviewerTypes, review.reviewerType) || reviewedOn === null) return null;
|
|
419
|
+
if (review.reviewerType !== "human-editor" && HUMAN_WORD.test(reviewer)) {
|
|
420
|
+
issues.push(`${where}: ${field}.reviewer may use the word "human" only when reviewerType is human-editor.`);
|
|
421
|
+
}
|
|
422
|
+
if (review.reviewerType === "ai" && !AI_WORD.test(reviewer)) {
|
|
423
|
+
issues.push(`${where}: ${field}.reviewer must say it is AI when reviewerType is ai, for example by naming the model.`);
|
|
424
|
+
}
|
|
425
|
+
return { reviewer, reviewerType: review.reviewerType, reviewedOn };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseScores(issues: Issues, where: string, value: unknown): ArticleScores | null {
|
|
429
|
+
const scores = record(value);
|
|
430
|
+
if (scores === null) {
|
|
431
|
+
issues.push(`${where}: scores must be a record of six 0-2 scores.`);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
const extra = Object.keys(scores).filter((key) => !isOneOf(articleScoreKeys, key));
|
|
435
|
+
if (extra.length > 0) issues.push(`${where}: unknown score ${extra.sort().join(", ")}.`);
|
|
436
|
+
let complete = true;
|
|
437
|
+
for (const key of articleScoreKeys) {
|
|
438
|
+
const score = scores[key];
|
|
439
|
+
if (score !== 0 && score !== 1 && score !== 2) {
|
|
440
|
+
issues.push(`${where}: scores.${key} must be 0, 1, or 2.`);
|
|
441
|
+
complete = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return complete ? scores as ArticleScores : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Parse one admission record from untrusted data. Collects every problem
|
|
449
|
+
* into `issues` and returns the typed record only when the shape is complete.
|
|
450
|
+
*/
|
|
451
|
+
function parseArticleAdmission(value: unknown, index: number, issues: Issues): ArticleAdmission | null {
|
|
452
|
+
const start = issues.length;
|
|
453
|
+
const input = record(value);
|
|
454
|
+
if (input === null) {
|
|
455
|
+
issues.push(`admission ${index}: must be a record.`);
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const where = nonBlank(input.href) ? input.href : `admission ${index}`;
|
|
459
|
+
const href = text(issues, where, input.href, "href");
|
|
460
|
+
if (!isOneOf(articleLifecycles, input.lifecycle)) {
|
|
461
|
+
issues.push(`${where}: lifecycle must be one of ${articleLifecycles.join(", ")}.`);
|
|
462
|
+
}
|
|
463
|
+
const lifecycle = input.lifecycle as ArticleLifecycle;
|
|
464
|
+
const readerJob = text(issues, where, input.readerJob, "readerJob");
|
|
465
|
+
const nonObviousAnswer = text(issues, where, input.nonObviousAnswer, "nonObviousAnswer");
|
|
466
|
+
const originalContribution = text(issues, where, input.originalContribution, "originalContribution");
|
|
467
|
+
const hostFit = text(issues, where, input.hostFit, "hostFit");
|
|
468
|
+
const owner = text(issues, where, input.owner, "owner");
|
|
469
|
+
const harmIfWrong = text(issues, where, input.harmIfWrong, "harmIfWrong");
|
|
470
|
+
if (!isOneOf(articleDraftingKinds, input.drafting)) {
|
|
471
|
+
issues.push(`${where}: drafting must be one of ${articleDraftingKinds.join(", ")}.`);
|
|
472
|
+
}
|
|
473
|
+
const drafting = input.drafting as ArticleDraftingKind;
|
|
474
|
+
|
|
475
|
+
const nearestUrls = list(issues, where, input.nearestUrls, "nearestUrls").map((item, position) => {
|
|
476
|
+
const neighbor = record(item);
|
|
477
|
+
if (neighbor === null) {
|
|
478
|
+
issues.push(`${where}: nearestUrls[${position}] must be a record.`);
|
|
479
|
+
return { url: "", distinction: "" };
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
url: text(issues, where, neighbor.url, `nearestUrls[${position}].url`),
|
|
483
|
+
distinction: text(issues, where, neighbor.distinction, `nearestUrls[${position}].distinction`),
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
if (nearestUrls.length > 3) issues.push(`${where}: nearestUrls lists at most three pages.`);
|
|
487
|
+
|
|
488
|
+
const sources = list(issues, where, input.sources, "sources").map((item, position) => {
|
|
489
|
+
const source = record(item);
|
|
490
|
+
if (source === null) {
|
|
491
|
+
issues.push(`${where}: sources[${position}] must be a record.`);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const checkedOn = date(issues, where, source.checkedOn, `sources[${position}].checkedOn`);
|
|
495
|
+
const url = text(issues, where, source.url, `sources[${position}].url`);
|
|
496
|
+
if (url !== "" && !isSafeHref(url)) issues.push(`${where}: sources[${position}].url must be a web, mail, or relative link.`);
|
|
497
|
+
return {
|
|
498
|
+
title: text(issues, where, source.title, `sources[${position}].title`),
|
|
499
|
+
url,
|
|
500
|
+
checkedOn: checkedOn ?? "0000-00-00" as ArticleIsoDate,
|
|
501
|
+
};
|
|
502
|
+
}).filter((source) => source !== null);
|
|
503
|
+
|
|
504
|
+
const observations = list(issues, where, input.observations, "observations").map((item, position) => (
|
|
505
|
+
text(issues, where, item, `observations[${position}]`)
|
|
506
|
+
));
|
|
507
|
+
const refreshTriggers = list(issues, where, input.refreshTriggers, "refreshTriggers").map((item, position) => (
|
|
508
|
+
text(issues, where, item, `refreshTriggers[${position}]`)
|
|
509
|
+
));
|
|
510
|
+
const scores = parseScores(issues, where, input.scores);
|
|
511
|
+
const review = parseReview(issues, where, input.review, "review");
|
|
512
|
+
const humanReviewValue = parseReview(issues, where, input.humanReview, "humanReview");
|
|
513
|
+
if (humanReviewValue !== null && humanReviewValue.reviewerType === "ai") {
|
|
514
|
+
issues.push(`${where}: humanReview cannot record an AI reviewer; keep AI review in review.`);
|
|
515
|
+
}
|
|
516
|
+
const reassessOn = date(issues, where, input.reassessOn, "reassessOn");
|
|
517
|
+
|
|
518
|
+
if (issues.length > start || scores === null || reassessOn === null) return null;
|
|
519
|
+
return {
|
|
520
|
+
href,
|
|
521
|
+
lifecycle,
|
|
522
|
+
readerJob,
|
|
523
|
+
nonObviousAnswer,
|
|
524
|
+
originalContribution,
|
|
525
|
+
hostFit,
|
|
526
|
+
nearestUrls,
|
|
527
|
+
sources,
|
|
528
|
+
observations,
|
|
529
|
+
scores,
|
|
530
|
+
owner,
|
|
531
|
+
drafting,
|
|
532
|
+
review,
|
|
533
|
+
humanReview: humanReviewValue as ArticleHumanReview | null,
|
|
534
|
+
reassessOn,
|
|
535
|
+
harmIfWrong,
|
|
536
|
+
refreshTriggers,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Rules that depend on dates and lifecycle, applied to a well-formed record. */
|
|
541
|
+
function admissionRuleIssues(admission: ArticleAdmission): string[] {
|
|
542
|
+
const issues: string[] = [];
|
|
543
|
+
const where = admission.href;
|
|
544
|
+
const { review, humanReview } = admission;
|
|
545
|
+
if (review !== null) {
|
|
546
|
+
const days = articleDaysBetween(review.reviewedOn, admission.reassessOn);
|
|
547
|
+
if (days < ARTICLE_REASSESS_WINDOW.minimumDays || days > ARTICLE_REASSESS_WINDOW.maximumDays) {
|
|
548
|
+
issues.push(
|
|
549
|
+
`${where}: reassessOn must fall ${ARTICLE_REASSESS_WINDOW.minimumDays} to ${ARTICLE_REASSESS_WINDOW.maximumDays} days after review.reviewedOn (found ${days}).`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
for (const source of admission.sources) {
|
|
553
|
+
if (articleDaysBetween(source.checkedOn, review.reviewedOn) < 0) {
|
|
554
|
+
issues.push(`${where}: source "${source.title}" was checked after the review; review it again.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (humanReview !== null && review === null) {
|
|
559
|
+
issues.push(`${where}: record the editorial review in review before adding humanReview.`);
|
|
560
|
+
}
|
|
561
|
+
if (admission.lifecycle === "indexable") {
|
|
562
|
+
if (!articleAdmissionPasses(admission.scores)) {
|
|
563
|
+
issues.push(
|
|
564
|
+
`${where}: indexable articles need a score of at least ${ARTICLE_ADMISSION_MINIMUM}/12 with no zero (found ${articleAdmissionScore(admission.scores)}).`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (review === null) issues.push(`${where}: indexable articles need a review with reviewer, reviewerType, and reviewedOn.`);
|
|
568
|
+
else if (review.reviewerType === "author") issues.push(`${where}: indexable articles need an independent review; the author cannot admit their own post.`);
|
|
569
|
+
if (admission.sources.length === 0) issues.push(`${where}: indexable articles need at least one source with a check date.`);
|
|
570
|
+
if (admission.observations.length < 2) issues.push(`${where}: indexable articles need two observations that are not paraphrases of the sources.`);
|
|
571
|
+
if (admission.refreshTriggers.length === 0) issues.push(`${where}: indexable articles need at least one refresh trigger.`);
|
|
572
|
+
}
|
|
573
|
+
return issues;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Validate a site's whole admission registry from untrusted data. Hrefs must
|
|
578
|
+
* be unique, every record must be complete, and each indexable record must
|
|
579
|
+
* pass the rubric: six 0-2 scores totalling at least nine with no zero, a
|
|
580
|
+
* reviewer identity and type, sources with check dates, and a reassessment
|
|
581
|
+
* date 28 to 56 days after review. Throws one error listing every problem.
|
|
582
|
+
*/
|
|
583
|
+
export function assertArticleAdmissions(value: unknown): asserts value is readonly ArticleAdmission[] {
|
|
584
|
+
const issues: string[] = [];
|
|
585
|
+
if (!Array.isArray(value)) throw new ArticleAdmissionError(["The admission registry must be a list."]);
|
|
586
|
+
const seen = new Set<string>();
|
|
587
|
+
value.forEach((item: unknown, index) => {
|
|
588
|
+
const admission = parseArticleAdmission(item, index, issues);
|
|
589
|
+
if (admission === null) return;
|
|
590
|
+
if (seen.has(admission.href)) issues.push(`${admission.href}: appears more than once.`);
|
|
591
|
+
seen.add(admission.href);
|
|
592
|
+
issues.push(...admissionRuleIssues(admission));
|
|
593
|
+
});
|
|
594
|
+
if (issues.length > 0) throw new ArticleAdmissionError(issues);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Validate and return the typed registry. */
|
|
598
|
+
export function parseArticleAdmissions(value: unknown): readonly ArticleAdmission[] {
|
|
599
|
+
assertArticleAdmissions(value);
|
|
600
|
+
return value;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** One encoding of an article video. List WebM before MP4 so browsers that play both take the smaller file. */
|
|
604
|
+
export type ArticleVideoSource = Readonly<{ src: string; type: "video/webm" | "video/mp4" }>;
|
|
605
|
+
|
|
606
|
+
export type ArticleVideoRecord = Readonly<{
|
|
607
|
+
/** Plain name of the recording, used as the VideoObject name. */
|
|
608
|
+
name: string;
|
|
609
|
+
/** One or two sentences on what the recording shows. */
|
|
610
|
+
description: string;
|
|
611
|
+
sources: readonly ArticleVideoSource[];
|
|
612
|
+
poster: string;
|
|
613
|
+
/** A WebVTT captions file. Every article video has one. */
|
|
614
|
+
captions: string;
|
|
615
|
+
captionsLanguage?: string;
|
|
616
|
+
width: number;
|
|
617
|
+
height: number;
|
|
618
|
+
/** ISO 8601 duration, such as `PT48S`. */
|
|
619
|
+
duration: `PT${string}`;
|
|
620
|
+
uploadDate: ArticleIsoDate;
|
|
621
|
+
}>;
|
|
622
|
+
|
|
623
|
+
const VIDEO_TYPE_ORDER = { "video/webm": 0, "video/mp4": 1 } as const;
|
|
624
|
+
|
|
625
|
+
/** Throws unless the video has sources, a poster, captions, a size, and a duration. */
|
|
626
|
+
export function assertArticleVideo(video: ArticleVideoRecord): void {
|
|
627
|
+
if (video.name.trim() === "") throw new RangeError("Article video needs a name.");
|
|
628
|
+
if (video.description.trim() === "") throw new RangeError("Article video needs a description.");
|
|
629
|
+
if (video.sources.length === 0) throw new RangeError("Article video needs at least one source.");
|
|
630
|
+
const types = new Set<string>();
|
|
631
|
+
for (const source of video.sources) {
|
|
632
|
+
assertArticleHref(source.src);
|
|
633
|
+
if (!Object.hasOwn(VIDEO_TYPE_ORDER, source.type)) throw new RangeError(`Unsupported article video type: ${String(source.type)}.`);
|
|
634
|
+
if (types.has(source.type)) throw new RangeError(`Article video lists ${source.type} twice.`);
|
|
635
|
+
types.add(source.type);
|
|
636
|
+
}
|
|
637
|
+
assertArticleHref(video.poster);
|
|
638
|
+
assertArticleHref(video.captions);
|
|
639
|
+
if (!/\.vtt(?:[?#]|$)/u.test(video.captions)) throw new RangeError("Article video captions must be a WebVTT file.");
|
|
640
|
+
for (const [key, value] of [["width", video.width], ["height", video.height]] as const) {
|
|
641
|
+
if (!Number.isInteger(value) || value <= 0) throw new RangeError(`Article video ${key} must be a positive integer.`);
|
|
642
|
+
}
|
|
643
|
+
if (!/^PT(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?$/u.test(video.duration) || video.duration === "PT") {
|
|
644
|
+
throw new RangeError(`Article video duration must be an ISO 8601 time duration such as PT48S; received ${video.duration}.`);
|
|
645
|
+
}
|
|
646
|
+
if (!isArticleIsoDate(video.uploadDate)) throw new RangeError("Article video uploadDate must be an ISO date.");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Sources in playback preference order: WebM, then MP4. */
|
|
650
|
+
export function orderedArticleVideoSources(video: Pick<ArticleVideoRecord, "sources">): readonly ArticleVideoSource[] {
|
|
651
|
+
return video.sources.toSorted((a, b) => VIDEO_TYPE_ORDER[a.type] - VIDEO_TYPE_ORDER[b.type]);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const HTTP_ORIGIN = /^https?:\/\/[^/?#\s]+$/iu;
|
|
655
|
+
const ABSOLUTE_HTTP = /^https?:\/\/[^/?#\s]+/iu;
|
|
656
|
+
|
|
657
|
+
// String joins keep this module free of the DOM and Node URL globals.
|
|
658
|
+
function absoluteArticleUrl(href: string, origin: string): string {
|
|
659
|
+
if (ABSOLUTE_HTTP.test(href)) return href;
|
|
660
|
+
if (href.startsWith("//")) throw new RangeError(`Article video paths must not be protocol-relative: ${href}`);
|
|
661
|
+
return href.startsWith("/") ? `${origin}${href}` : `${origin}/${href}`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* A schema.org VideoObject for an article video. Nest it as the `video`
|
|
666
|
+
* property of the article's JSON-LD. `origin` makes relative media paths absolute.
|
|
667
|
+
*/
|
|
668
|
+
export function articleVideoJsonLd(video: ArticleVideoRecord, origin: string): Readonly<Record<string, unknown>> {
|
|
669
|
+
assertArticleVideo(video);
|
|
670
|
+
if (!HTTP_ORIGIN.test(origin.replace(/\/$/u, ""))) throw new RangeError("articleVideoJsonLd needs an http or https origin.");
|
|
671
|
+
origin = origin.replace(/\/$/u, "");
|
|
672
|
+
const sources = orderedArticleVideoSources(video);
|
|
673
|
+
return Object.freeze({
|
|
674
|
+
"@type": "VideoObject",
|
|
675
|
+
name: video.name,
|
|
676
|
+
description: video.description,
|
|
677
|
+
thumbnailUrl: absoluteArticleUrl(video.poster, origin),
|
|
678
|
+
contentUrl: absoluteArticleUrl(sources.at(-1)?.src ?? "", origin),
|
|
679
|
+
encodingFormat: sources.at(-1)?.type,
|
|
680
|
+
uploadDate: video.uploadDate,
|
|
681
|
+
duration: video.duration,
|
|
682
|
+
width: video.width,
|
|
683
|
+
height: video.height,
|
|
684
|
+
caption: absoluteArticleUrl(video.captions, origin),
|
|
685
|
+
});
|
|
686
|
+
}
|