react-cheminfo 0.8.0 → 0.9.0
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/README.md +60 -2
- package/bin/check-tokens.mjs +105 -0
- package/lib/about/core/about.d.ts +95 -0
- package/lib/about/core/about.d.ts.map +1 -0
- package/lib/about/core/about.js +78 -0
- package/lib/about/core/about.js.map +1 -0
- package/lib/about/core/index.d.ts +3 -0
- package/lib/about/core/index.d.ts.map +1 -0
- package/lib/about/core/index.js +2 -0
- package/lib/about/core/index.js.map +1 -0
- package/lib/about/ui/AboutCitations.d.ts +14 -0
- package/lib/about/ui/AboutCitations.d.ts.map +1 -0
- package/lib/about/ui/AboutCitations.js +40 -0
- package/lib/about/ui/AboutCitations.js.map +1 -0
- package/lib/about/ui/AboutPage.d.ts +32 -0
- package/lib/about/ui/AboutPage.d.ts.map +1 -0
- package/lib/about/ui/AboutPage.js +70 -0
- package/lib/about/ui/AboutPage.js.map +1 -0
- package/lib/about/ui/AboutSection.d.ts +23 -0
- package/lib/about/ui/AboutSection.d.ts.map +1 -0
- package/lib/about/ui/AboutSection.js +28 -0
- package/lib/about/ui/AboutSection.js.map +1 -0
- package/lib/about/ui/index.d.ts +7 -0
- package/lib/about/ui/index.d.ts.map +1 -0
- package/lib/about/ui/index.js +4 -0
- package/lib/about/ui/index.js.map +1 -0
- package/lib/chrome/ui/index.d.ts +1 -0
- package/lib/chrome/ui/index.d.ts.map +1 -1
- package/lib/chrome/ui/index.js +3 -0
- package/lib/chrome/ui/index.js.map +1 -1
- package/lib/clipboard/ui/CodeBlock.d.ts.map +1 -1
- package/lib/clipboard/ui/CodeBlock.js +4 -1
- package/lib/clipboard/ui/CodeBlock.js.map +1 -1
- package/lib/color/ui/ColorScaleLegend.js +1 -1
- package/lib/color/ui/ColorScaleLegend.js.map +1 -1
- package/lib/core.d.ts +3 -0
- package/lib/core.d.ts.map +1 -1
- package/lib/core.js +5 -0
- package/lib/core.js.map +1 -1
- package/lib/credits/core/credits.d.ts +36 -0
- package/lib/credits/core/credits.d.ts.map +1 -1
- package/lib/credits/core/credits.js +42 -0
- package/lib/credits/core/credits.js.map +1 -1
- package/lib/credits/ui/CreditsList.js +1 -1
- package/lib/credits/ui/CreditsList.js.map +1 -1
- package/lib/delimited/ui/DelimitedTextPanel.js +1 -1
- package/lib/delimited/ui/DelimitedTextPanel.js.map +1 -1
- package/lib/ecosystem/core/sites.d.ts +1 -1
- package/lib/ecosystem/core/sites.d.ts.map +1 -1
- package/lib/ecosystem/core/sites.js +10 -0
- package/lib/ecosystem/core/sites.js.map +1 -1
- package/lib/ecosystem/core/tokens.d.ts.map +1 -1
- package/lib/ecosystem/core/tokens.js +1 -0
- package/lib/ecosystem/core/tokens.js.map +1 -1
- package/lib/ecosystem/ui/EcosystemLinks.js +2 -2
- package/lib/ecosystem/ui/EcosystemLinks.js.map +1 -1
- package/lib/ecosystem/ui/EcosystemMenu.js +1 -1
- package/lib/ecosystem/ui/EcosystemMenu.js.map +1 -1
- package/lib/ecosystem/ui/SiteTile.d.ts.map +1 -1
- package/lib/ecosystem/ui/SiteTile.js +5 -5
- package/lib/ecosystem/ui/SiteTile.js.map +1 -1
- package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
- package/lib/ecosystem/ui/glyphs.js +3 -0
- package/lib/ecosystem/ui/glyphs.js.map +1 -1
- package/lib/orbital/core/index.d.ts +1 -1
- package/lib/orbital/core/index.d.ts.map +1 -1
- package/lib/orbital/core/index.js +1 -1
- package/lib/orbital/core/index.js.map +1 -1
- package/lib/orbital/core/realHarmonics.d.ts +5 -2
- package/lib/orbital/core/realHarmonics.d.ts.map +1 -1
- package/lib/orbital/core/realHarmonics.js.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts +18 -0
- package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalCanvas.js +84 -7
- package/lib/orbital/ui/AtomicOrbitalCanvas.js.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalViewer.d.ts +13 -0
- package/lib/orbital/ui/AtomicOrbitalViewer.d.ts.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalViewer.js +1 -1
- package/lib/orbital/ui/AtomicOrbitalViewer.js.map +1 -1
- package/lib/orbital/ui/camera.d.ts +15 -0
- package/lib/orbital/ui/camera.d.ts.map +1 -1
- package/lib/orbital/ui/camera.js +68 -0
- package/lib/orbital/ui/camera.js.map +1 -1
- package/lib/orbital/ui/renderVolume.d.ts.map +1 -1
- package/lib/orbital/ui/renderVolume.js +8 -1
- package/lib/orbital/ui/renderVolume.js.map +1 -1
- package/lib/orbital/ui/serial.d.ts +25 -0
- package/lib/orbital/ui/serial.d.ts.map +1 -0
- package/lib/orbital/ui/serial.js +25 -0
- package/lib/orbital/ui/serial.js.map +1 -0
- package/lib/orbital/ui/viewer.d.ts +23 -0
- package/lib/orbital/ui/viewer.d.ts.map +1 -1
- package/lib/orbital/ui/viewer.js +70 -17
- package/lib/orbital/ui/viewer.js.map +1 -1
- package/lib/pedagogy/ui/ExerciseProgressHeader.js +1 -1
- package/lib/pedagogy/ui/ExerciseProgressHeader.js.map +1 -1
- package/lib/pedagogy/ui/GlossaryText.js +2 -2
- package/lib/pedagogy/ui/GlossaryText.js.map +1 -1
- package/lib/pedagogy/ui/ReferenceSectionBlock.js +5 -2
- package/lib/pedagogy/ui/ReferenceSectionBlock.js.map +1 -1
- package/lib/pedagogy/ui/SyntaxTooltip.js +2 -2
- package/lib/pedagogy/ui/SyntaxTooltip.js.map +1 -1
- package/lib/pedagogy/ui/TutorialStepStrip.js +4 -1
- package/lib/pedagogy/ui/TutorialStepStrip.js.map +1 -1
- package/lib/periodic/ui/CategoryLegend.js +1 -1
- package/lib/periodic/ui/CategoryLegend.js.map +1 -1
- package/lib/periodic/ui/PeriodicTableChrome.js +2 -2
- package/lib/periodic/ui/PeriodicTableChrome.js.map +1 -1
- package/lib/seo/vite/ogCard.d.ts.map +1 -1
- package/lib/seo/vite/ogCard.js +1 -0
- package/lib/seo/vite/ogCard.js.map +1 -1
- package/lib/share/ui/ShareDialogContent.d.ts.map +1 -1
- package/lib/share/ui/ShareDialogContent.js +5 -2
- package/lib/share/ui/ShareDialogContent.js.map +1 -1
- package/lib/share/ui/SharePartOptions.js +1 -1
- package/lib/share/ui/SharePartOptions.js.map +1 -1
- package/lib/slides/core/demoLinks.d.ts +43 -0
- package/lib/slides/core/demoLinks.d.ts.map +1 -0
- package/lib/slides/core/demoLinks.js +104 -0
- package/lib/slides/core/demoLinks.js.map +1 -0
- package/lib/slides/core/index.d.ts +13 -0
- package/lib/slides/core/index.d.ts.map +1 -0
- package/lib/slides/core/index.js +7 -0
- package/lib/slides/core/index.js.map +1 -0
- package/lib/slides/core/sectionHeading.d.ts +19 -0
- package/lib/slides/core/sectionHeading.d.ts.map +1 -0
- package/lib/slides/core/sectionHeading.js +19 -0
- package/lib/slides/core/sectionHeading.js.map +1 -0
- package/lib/slides/core/slideNavigation.d.ts +38 -0
- package/lib/slides/core/slideNavigation.d.ts.map +1 -0
- package/lib/slides/core/slideNavigation.js +65 -0
- package/lib/slides/core/slideNavigation.js.map +1 -0
- package/lib/slides/core/talk.d.ts +82 -0
- package/lib/slides/core/talk.d.ts.map +1 -0
- package/lib/slides/core/talk.js +134 -0
- package/lib/slides/core/talk.js.map +1 -0
- package/lib/slides/core/talkManifest.d.ts +90 -0
- package/lib/slides/core/talkManifest.d.ts.map +1 -0
- package/lib/slides/core/talkManifest.js +109 -0
- package/lib/slides/core/talkManifest.js.map +1 -0
- package/lib/slides/core/talkOrigin.d.ts +50 -0
- package/lib/slides/core/talkOrigin.d.ts.map +1 -0
- package/lib/slides/core/talkOrigin.js +62 -0
- package/lib/slides/core/talkOrigin.js.map +1 -0
- package/lib/slides/ui/BackToSlides.d.ts +26 -0
- package/lib/slides/ui/BackToSlides.d.ts.map +1 -0
- package/lib/slides/ui/BackToSlides.js +24 -0
- package/lib/slides/ui/BackToSlides.js.map +1 -0
- package/lib/slides/ui/DemoLink.d.ts +42 -0
- package/lib/slides/ui/DemoLink.d.ts.map +1 -0
- package/lib/slides/ui/DemoLink.js +17 -0
- package/lib/slides/ui/DemoLink.js.map +1 -0
- package/lib/slides/ui/EmbedSlide.d.ts +19 -0
- package/lib/slides/ui/EmbedSlide.d.ts.map +1 -0
- package/lib/slides/ui/EmbedSlide.js +56 -0
- package/lib/slides/ui/EmbedSlide.js.map +1 -0
- package/lib/slides/ui/SectionSlide.d.ts +18 -0
- package/lib/slides/ui/SectionSlide.d.ts.map +1 -0
- package/lib/slides/ui/SectionSlide.js +18 -0
- package/lib/slides/ui/SectionSlide.js.map +1 -0
- package/lib/slides/ui/Slide.d.ts +52 -0
- package/lib/slides/ui/Slide.d.ts.map +1 -0
- package/lib/slides/ui/Slide.js +58 -0
- package/lib/slides/ui/Slide.js.map +1 -0
- package/lib/slides/ui/SlideBody.d.ts +32 -0
- package/lib/slides/ui/SlideBody.d.ts.map +1 -0
- package/lib/slides/ui/SlideBody.js +27 -0
- package/lib/slides/ui/SlideBody.js.map +1 -0
- package/lib/slides/ui/Slideshow.d.ts +93 -0
- package/lib/slides/ui/Slideshow.d.ts.map +1 -0
- package/lib/slides/ui/Slideshow.js +122 -0
- package/lib/slides/ui/Slideshow.js.map +1 -0
- package/lib/slides/ui/SlideshowBar.d.ts +33 -0
- package/lib/slides/ui/SlideshowBar.d.ts.map +1 -0
- package/lib/slides/ui/SlideshowBar.js +20 -0
- package/lib/slides/ui/SlideshowBar.js.map +1 -0
- package/lib/slides/ui/TalkList.d.ts +37 -0
- package/lib/slides/ui/TalkList.d.ts.map +1 -0
- package/lib/slides/ui/TalkList.js +68 -0
- package/lib/slides/ui/TalkList.js.map +1 -0
- package/lib/slides/ui/index.d.ts +19 -0
- package/lib/slides/ui/index.d.ts.map +1 -0
- package/lib/slides/ui/index.js +10 -0
- package/lib/slides/ui/index.js.map +1 -0
- package/lib/slides/vite/index.d.ts +3 -0
- package/lib/slides/vite/index.d.ts.map +1 -0
- package/lib/slides/vite/index.js +2 -0
- package/lib/slides/vite/index.js.map +1 -0
- package/lib/slides/vite/talks.d.ts +50 -0
- package/lib/slides/vite/talks.d.ts.map +1 -0
- package/lib/slides/vite/talks.js +87 -0
- package/lib/slides/vite/talks.js.map +1 -0
- package/lib/slides.d.ts +3 -0
- package/lib/slides.d.ts.map +1 -0
- package/lib/slides.js +3 -0
- package/lib/slides.js.map +1 -0
- package/lib/structure/ui/StructureEditor.js +2 -2
- package/lib/structure/ui/StructureEditor.js.map +1 -1
- package/lib/structure/ui/StructurePlaceholder.js +1 -1
- package/lib/structure/ui/StructurePlaceholder.js.map +1 -1
- package/lib/tokens/core/checkTokens.d.ts +51 -0
- package/lib/tokens/core/checkTokens.d.ts.map +1 -0
- package/lib/tokens/core/checkTokens.js +188 -0
- package/lib/tokens/core/checkTokens.js.map +1 -0
- package/lib/tokens/core/colors.d.ts +42 -0
- package/lib/tokens/core/colors.d.ts.map +1 -0
- package/lib/tokens/core/colors.js +119 -0
- package/lib/tokens/core/colors.js.map +1 -0
- package/lib/tokens/core/index.d.ts +3 -0
- package/lib/tokens/core/index.d.ts.map +1 -0
- package/lib/tokens/core/index.js +2 -0
- package/lib/tokens/core/index.js.map +1 -0
- package/lib/tokens/core/rules.d.ts +23 -0
- package/lib/tokens/core/rules.d.ts.map +1 -0
- package/lib/tokens/core/rules.js +90 -0
- package/lib/tokens/core/rules.js.map +1 -0
- package/lib/ui.d.ts +1 -0
- package/lib/ui.d.ts.map +1 -1
- package/lib/ui.js +1 -0
- package/lib/ui.js.map +1 -1
- package/lib/vite.d.ts +1 -0
- package/lib/vite.d.ts.map +1 -1
- package/lib/vite.js +1 -0
- package/lib/vite.js.map +1 -1
- package/package.json +11 -3
- package/src/about/core/about.ts +176 -0
- package/src/about/core/index.ts +2 -0
- package/src/about/ui/AboutCitations.tsx +79 -0
- package/src/about/ui/AboutPage.tsx +177 -0
- package/src/about/ui/AboutSection.tsx +56 -0
- package/src/about/ui/index.ts +6 -0
- package/src/chrome/ui/index.ts +3 -0
- package/src/clipboard/ui/CodeBlock.tsx +4 -1
- package/src/color/ui/ColorScaleLegend.tsx +1 -1
- package/src/core.ts +5 -0
- package/src/credits/core/credits.ts +42 -0
- package/src/credits/ui/CreditsList.tsx +1 -1
- package/src/delimited/ui/DelimitedTextPanel.tsx +1 -1
- package/src/ecosystem/core/sites.ts +12 -0
- package/src/ecosystem/core/tokens.ts +1 -0
- package/src/ecosystem/ui/EcosystemLinks.tsx +2 -2
- package/src/ecosystem/ui/EcosystemMenu.tsx +1 -1
- package/src/ecosystem/ui/SiteTile.tsx +7 -5
- package/src/ecosystem/ui/glyphs.tsx +40 -0
- package/src/orbital/core/index.ts +4 -1
- package/src/orbital/core/realHarmonics.ts +5 -2
- package/src/orbital/ui/AtomicOrbitalCanvas.tsx +126 -4
- package/src/orbital/ui/AtomicOrbitalViewer.tsx +14 -1
- package/src/orbital/ui/camera.ts +81 -0
- package/src/orbital/ui/renderVolume.ts +8 -1
- package/src/orbital/ui/serial.ts +35 -0
- package/src/orbital/ui/viewer.ts +72 -13
- package/src/pedagogy/ui/ExerciseProgressHeader.tsx +1 -1
- package/src/pedagogy/ui/GlossaryText.tsx +2 -2
- package/src/pedagogy/ui/ReferenceSectionBlock.tsx +5 -2
- package/src/pedagogy/ui/SyntaxTooltip.tsx +2 -2
- package/src/pedagogy/ui/TutorialStepStrip.tsx +4 -1
- package/src/periodic/ui/CategoryLegend.tsx +1 -1
- package/src/periodic/ui/PeriodicTableChrome.tsx +2 -2
- package/src/seo/vite/ogCard.ts +1 -0
- package/src/share/ui/ShareDialogContent.tsx +5 -2
- package/src/share/ui/SharePartOptions.tsx +1 -1
- package/src/slides/core/demoLinks.ts +113 -0
- package/src/slides/core/index.ts +22 -0
- package/src/slides/core/sectionHeading.ts +29 -0
- package/src/slides/core/slideNavigation.ts +77 -0
- package/src/slides/core/talk.ts +203 -0
- package/src/slides/core/talkManifest.ts +158 -0
- package/src/slides/core/talkOrigin.ts +83 -0
- package/src/slides/ui/BackToSlides.tsx +49 -0
- package/src/slides/ui/DemoLink.tsx +67 -0
- package/src/slides/ui/EmbedSlide.tsx +85 -0
- package/src/slides/ui/SectionSlide.tsx +44 -0
- package/src/slides/ui/Slide.tsx +143 -0
- package/src/slides/ui/SlideBody.tsx +64 -0
- package/src/slides/ui/Slideshow.tsx +282 -0
- package/src/slides/ui/SlideshowBar.tsx +83 -0
- package/src/slides/ui/TalkList.tsx +146 -0
- package/src/slides/ui/index.ts +18 -0
- package/src/slides/vite/index.ts +2 -0
- package/src/slides/vite/talks.ts +126 -0
- package/src/slides.ts +2 -0
- package/src/structure/ui/StructureEditor.tsx +2 -2
- package/src/structure/ui/StructurePlaceholder.tsx +1 -1
- package/src/tokens/core/checkTokens.ts +242 -0
- package/src/tokens/core/colors.ts +122 -0
- package/src/tokens/core/index.ts +6 -0
- package/src/tokens/core/rules.ts +103 -0
- package/src/ui.ts +1 -0
- package/src/vite.ts +1 -0
- package/styles/chrome.css +1 -0
- package/styles/slides.css +667 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import { isModifiedClick } from '../../chrome/ui/navItem.ts';
|
|
4
|
+
import { pluralize } from '../../format/core/index.ts';
|
|
5
|
+
import type { TalkManifest, TalkSummary } from '../core/index.ts';
|
|
6
|
+
|
|
7
|
+
/** The talks to list, and what opening one means. */
|
|
8
|
+
export interface TalkListProps {
|
|
9
|
+
/** One manifest per publishing site. */
|
|
10
|
+
manifests: readonly TalkManifest[];
|
|
11
|
+
/** Called when a talk is picked. */
|
|
12
|
+
onOpen: (manifest: TalkManifest, talk: TalkSummary) => void;
|
|
13
|
+
/**
|
|
14
|
+
* The address a talk sits at, which keeps the card a real link — a middle
|
|
15
|
+
* click opens a tab of its own, and a crawler walks the list.
|
|
16
|
+
* @default undefined — the card is a link with no address
|
|
17
|
+
*/
|
|
18
|
+
renderHref?: (manifest: TalkManifest, talk: TalkSummary) => string;
|
|
19
|
+
/**
|
|
20
|
+
* How a site id is written above its talks.
|
|
21
|
+
* @default the id itself
|
|
22
|
+
*/
|
|
23
|
+
siteName?: (site: string) => string;
|
|
24
|
+
/**
|
|
25
|
+
* What an empty list says. A list that draws nothing at all reads as a
|
|
26
|
+
* broken page rather than as a site that has published no deck yet.
|
|
27
|
+
* @default 'No talk yet.'
|
|
28
|
+
*/
|
|
29
|
+
emptyNote?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The talks on offer: a site's own, or the whole family's.
|
|
34
|
+
*
|
|
35
|
+
* The site heading appears only once there is more than one site to tell
|
|
36
|
+
* apart, so the same component draws a single site's `/talks` page and the
|
|
37
|
+
* family-wide listing without either looking like the other one's leftovers.
|
|
38
|
+
* @param props - See {@link TalkListProps}.
|
|
39
|
+
* @returns The listing.
|
|
40
|
+
*/
|
|
41
|
+
export function TalkList(props: TalkListProps): ReactElement {
|
|
42
|
+
const {
|
|
43
|
+
manifests,
|
|
44
|
+
onOpen,
|
|
45
|
+
renderHref,
|
|
46
|
+
siteName,
|
|
47
|
+
emptyNote = 'No talk yet.',
|
|
48
|
+
} = props;
|
|
49
|
+
const groups = groupBySite(manifests);
|
|
50
|
+
const showSite = groups.length > 1;
|
|
51
|
+
|
|
52
|
+
if (groups.length === 0) {
|
|
53
|
+
return <p className="talk-list-empty">{emptyNote}</p>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="talk-list">
|
|
58
|
+
{groups.map((group) => (
|
|
59
|
+
<section key={group.site} className="talk-list-group">
|
|
60
|
+
{showSite ? (
|
|
61
|
+
<h2 className="talk-list-site">
|
|
62
|
+
{siteName === undefined ? group.site : siteName(group.site)}
|
|
63
|
+
</h2>
|
|
64
|
+
) : null}
|
|
65
|
+
<div className="talk-grid">
|
|
66
|
+
{group.entries.map(({ manifest, talk }) => (
|
|
67
|
+
<a
|
|
68
|
+
key={`${manifest.site}:${talk.id}`}
|
|
69
|
+
className="talk-card"
|
|
70
|
+
href={renderHref?.(manifest, talk)}
|
|
71
|
+
onClick={(event) => {
|
|
72
|
+
if (isModifiedClick(event)) return;
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
onOpen(manifest, talk);
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div className="talk-card-title">{talk.title}</div>
|
|
78
|
+
{talk.subtitle === undefined ? null : (
|
|
79
|
+
<div className="talk-card-subtitle">{talk.subtitle}</div>
|
|
80
|
+
)}
|
|
81
|
+
<div className="talk-card-meta">{occasion(talk)}</div>
|
|
82
|
+
<div className="talk-card-count">
|
|
83
|
+
{`${talk.slideCount} ${pluralize(talk.slideCount, 'slide')}`}
|
|
84
|
+
</div>
|
|
85
|
+
</a>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One talk, together with the manifest that published it. */
|
|
95
|
+
interface TalkEntry {
|
|
96
|
+
/** The manifest the talk was listed in. */
|
|
97
|
+
manifest: TalkManifest;
|
|
98
|
+
/** The talk itself. */
|
|
99
|
+
talk: TalkSummary;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every talk one site published, most recent first. */
|
|
103
|
+
interface TalkGroup {
|
|
104
|
+
/** The `ECOSYSTEM_SITES` id of the publishing site. */
|
|
105
|
+
site: string;
|
|
106
|
+
/** Its talks. */
|
|
107
|
+
entries: TalkEntry[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function groupBySite(manifests: readonly TalkManifest[]): TalkGroup[] {
|
|
111
|
+
const bySite = new Map<string, TalkEntry[]>();
|
|
112
|
+
for (const manifest of manifests) {
|
|
113
|
+
let entries = bySite.get(manifest.site);
|
|
114
|
+
if (entries === undefined) {
|
|
115
|
+
entries = [];
|
|
116
|
+
bySite.set(manifest.site, entries);
|
|
117
|
+
}
|
|
118
|
+
for (const talk of manifest.talks) entries.push({ manifest, talk });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const groups: TalkGroup[] = [];
|
|
122
|
+
for (const [site, entries] of bySite) {
|
|
123
|
+
// A site that has published nothing is not a heading over an empty row.
|
|
124
|
+
if (entries.length === 0) continue;
|
|
125
|
+
groups.push({ site, entries: entries.toSorted(byMostRecent) });
|
|
126
|
+
}
|
|
127
|
+
return groups;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The dates are ISO, so they order as text; a talk with no date sorts last
|
|
131
|
+
// rather than ahead of every dated one.
|
|
132
|
+
function byMostRecent(left: TalkEntry, right: TalkEntry): number {
|
|
133
|
+
const before = left.talk.date ?? '';
|
|
134
|
+
const after = right.talk.date ?? '';
|
|
135
|
+
if (before === after) return 0;
|
|
136
|
+
if (before === '') return 1;
|
|
137
|
+
if (after === '') return -1;
|
|
138
|
+
return before > after ? -1 : 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function occasion(talk: TalkSummary): string {
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (talk.event !== undefined) parts.push(talk.event);
|
|
144
|
+
if (talk.date !== undefined) parts.push(talk.date);
|
|
145
|
+
return parts.join(' · ');
|
|
146
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type { BackToSlidesProps } from './BackToSlides.tsx';
|
|
2
|
+
export { BackToSlides } from './BackToSlides.tsx';
|
|
3
|
+
export type { DemoLinkProps, RenderSlideLink } from './DemoLink.tsx';
|
|
4
|
+
export { DemoLink } from './DemoLink.tsx';
|
|
5
|
+
export type { EmbedSlideProps } from './EmbedSlide.tsx';
|
|
6
|
+
export { EmbedSlide } from './EmbedSlide.tsx';
|
|
7
|
+
export type { SectionSlideProps } from './SectionSlide.tsx';
|
|
8
|
+
export { SectionSlide } from './SectionSlide.tsx';
|
|
9
|
+
export type { SlideLayoutProps, SlideViewProps } from './Slide.tsx';
|
|
10
|
+
export { SlideView } from './Slide.tsx';
|
|
11
|
+
export type { SlideBodyProps } from './SlideBody.tsx';
|
|
12
|
+
export { SlideBody } from './SlideBody.tsx';
|
|
13
|
+
export type { SlideshowProps } from './Slideshow.tsx';
|
|
14
|
+
export { Slideshow } from './Slideshow.tsx';
|
|
15
|
+
export type { SlideshowBarProps } from './SlideshowBar.tsx';
|
|
16
|
+
export { SlideshowBar } from './SlideshowBar.tsx';
|
|
17
|
+
export type { TalkListProps } from './TalkList.tsx';
|
|
18
|
+
export { TalkList } from './TalkList.tsx';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish a site's talks so another site can list and play them.
|
|
3
|
+
*
|
|
4
|
+
* A deck is Markdown in the repository of the site whose subject it teaches,
|
|
5
|
+
* which is what keeps a talk reviewable. For learn.cheminfo.org to gather the
|
|
6
|
+
* family's talks it needs two static files per site and no API: a manifest of
|
|
7
|
+
* what exists, and the source of each deck. Both are written at build time and
|
|
8
|
+
* served with permissive CORS, because public teaching material is fetched from
|
|
9
|
+
* a page that is not ours.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import type { Plugin } from 'vite';
|
|
16
|
+
|
|
17
|
+
import { siteById, siteUrl } from '../../ecosystem/core/index.ts';
|
|
18
|
+
import type { SiteId } from '../../ecosystem/core/sites.ts';
|
|
19
|
+
import type { Talk } from '../core/talk.ts';
|
|
20
|
+
import { parseTalk } from '../core/talk.ts';
|
|
21
|
+
import { buildTalkManifest } from '../core/talkManifest.ts';
|
|
22
|
+
|
|
23
|
+
/** What the build needs to know to publish the decks. */
|
|
24
|
+
export interface TalksOptions {
|
|
25
|
+
/** The site the talks belong to. */
|
|
26
|
+
site: SiteId;
|
|
27
|
+
/**
|
|
28
|
+
* Where the sources live, relative to the project root.
|
|
29
|
+
* @default 'src/talks'
|
|
30
|
+
*/
|
|
31
|
+
dir?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Where the site is served, used as the manifest's origin.
|
|
34
|
+
* @default `https://<the site's host>`
|
|
35
|
+
*/
|
|
36
|
+
origin?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One talk on disk: its id, its source, and what the source parses into. */
|
|
40
|
+
export interface LoadedTalk {
|
|
41
|
+
/** The file name without extension, or the folder name for `<id>/index.md`. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** The Markdown as written, which is what other sites fetch. */
|
|
44
|
+
source: string;
|
|
45
|
+
/** The parsed talk, for the manifest. */
|
|
46
|
+
talk: Talk;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write `talks.json` and `talks/<id>.md` into the build.
|
|
51
|
+
* @param options - Which site, and where its decks are.
|
|
52
|
+
* @returns The Vite plugin.
|
|
53
|
+
*/
|
|
54
|
+
export function cheminfoTalks(options: TalksOptions): Plugin {
|
|
55
|
+
const { site, dir = 'src/talks' } = options;
|
|
56
|
+
let root = process.cwd();
|
|
57
|
+
let outDir = 'dist';
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name: 'cheminfo-talks',
|
|
61
|
+
apply: 'build',
|
|
62
|
+
configResolved(config) {
|
|
63
|
+
root = config.root;
|
|
64
|
+
outDir = config.build.outDir;
|
|
65
|
+
},
|
|
66
|
+
closeBundle() {
|
|
67
|
+
const talks = loadTalksFromDisk(join(root, dir));
|
|
68
|
+
const origin =
|
|
69
|
+
options.origin ?? siteUrl(siteById(site)).replace(/\/$/, '');
|
|
70
|
+
const manifest = buildTalkManifest({
|
|
71
|
+
site,
|
|
72
|
+
origin,
|
|
73
|
+
talks: talks.map((entry) => ({ id: entry.id, ...entry.talk })),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const target = resolve(root, outDir);
|
|
77
|
+
mkdirSync(join(target, 'talks'), { recursive: true });
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(target, 'talks.json'),
|
|
80
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
81
|
+
);
|
|
82
|
+
for (const entry of talks) {
|
|
83
|
+
writeFileSync(join(target, 'talks', `${entry.id}.md`), entry.source);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read every deck of a directory, in either of the two shapes a talk takes.
|
|
91
|
+
* @param directory - Absolute path of the talks directory.
|
|
92
|
+
* @returns The talks, sorted by id so a build is reproducible.
|
|
93
|
+
*/
|
|
94
|
+
export function loadTalksFromDisk(directory: string): LoadedTalk[] {
|
|
95
|
+
const loaded: LoadedTalk[] = [];
|
|
96
|
+
let entries: string[] = [];
|
|
97
|
+
try {
|
|
98
|
+
entries = readdirSync(directory);
|
|
99
|
+
} catch {
|
|
100
|
+
// A site with no talks yet still builds; it simply publishes an empty list.
|
|
101
|
+
return loaded;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const path = join(directory, entry);
|
|
106
|
+
if (entry.endsWith('.md')) {
|
|
107
|
+
const source = readFileSync(path, 'utf8');
|
|
108
|
+
loaded.push({
|
|
109
|
+
id: entry.slice(0, -3),
|
|
110
|
+
source,
|
|
111
|
+
talk: parseTalk(source),
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const index = join(path, 'index.md');
|
|
116
|
+
let source: string;
|
|
117
|
+
try {
|
|
118
|
+
source = readFileSync(index, 'utf8');
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
loaded.push({ id: entry, source, talk: parseTalk(source) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return loaded.toSorted((left, right) => left.id.localeCompare(right.id));
|
|
126
|
+
}
|
package/src/slides.ts
ADDED
|
@@ -179,7 +179,7 @@ const ROOT_STYLE: CSSProperties = {
|
|
|
179
179
|
position: 'relative',
|
|
180
180
|
overflow: 'hidden',
|
|
181
181
|
boxSizing: 'border-box',
|
|
182
|
-
border: '1px solid #d3d8de',
|
|
182
|
+
border: '1px solid var(--border, #d3d8de)',
|
|
183
183
|
borderRadius: 6,
|
|
184
184
|
background: '#fff',
|
|
185
185
|
};
|
|
@@ -191,6 +191,6 @@ const LOADING_STYLE: CSSProperties = {
|
|
|
191
191
|
display: 'flex',
|
|
192
192
|
alignItems: 'center',
|
|
193
193
|
justifyContent: 'center',
|
|
194
|
-
color: '#8a96a3',
|
|
194
|
+
color: 'var(--text-faint, #8a96a3)',
|
|
195
195
|
fontSize: 13,
|
|
196
196
|
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// tokens-ok: file — the checker names the colours it looks for.
|
|
2
|
+
import { normalizeColor, readHexColor, readRgbColor } from './colors.ts';
|
|
3
|
+
import { BANNED_COLORS } from './rules.ts';
|
|
4
|
+
|
|
5
|
+
/** What a site got wrong where a token was called for. */
|
|
6
|
+
export type TokenViolationKind =
|
|
7
|
+
'blueprint-grey' | 'blueprint-blue' | 'retyped-token' | 'redeclared-brand';
|
|
8
|
+
|
|
9
|
+
/** One place a site writes a colour that is not its own to write. */
|
|
10
|
+
export interface TokenViolation {
|
|
11
|
+
/** The file it is written in, named as the caller named it. */
|
|
12
|
+
file: string;
|
|
13
|
+
/** Which line, counting from 1. */
|
|
14
|
+
line: number;
|
|
15
|
+
/** Which column, counting from 1. */
|
|
16
|
+
column: number;
|
|
17
|
+
/** What kind of mistake it is. */
|
|
18
|
+
kind: TokenViolationKind;
|
|
19
|
+
/** The text as written — the hex, the `rgb()` call, the property name. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** The token to use instead, in one line. */
|
|
22
|
+
hint: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How forgiving a scan is. */
|
|
26
|
+
export interface CheckTokensOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Colours the project legitimately owns — a subject-matter palette, a logo
|
|
29
|
+
* of somebody else.
|
|
30
|
+
* @default []
|
|
31
|
+
*/
|
|
32
|
+
allow?: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* Lines carrying this marker are skipped, for the rare deliberate literal.
|
|
35
|
+
* @default 'tokens-ok'
|
|
36
|
+
*/
|
|
37
|
+
ignoreMarker?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every colour a set of files writes that belongs to the family rather than to
|
|
42
|
+
* the site.
|
|
43
|
+
*
|
|
44
|
+
* The neutrals, the geometry and the type are shared, and the two colours a
|
|
45
|
+
* site owns come from `<SiteTheme siteId>`; a site that re-types either — a
|
|
46
|
+
* Blueprint grey because a component used it, `#f5f7fa` because that is what
|
|
47
|
+
* the token holds today, `--brand` because the palette was declared by hand —
|
|
48
|
+
* leaves its page unable to follow the family. Both spellings of one colour
|
|
49
|
+
* are caught, `#5f6b7c` and `rgb(95 107 124)` alike, and reading a token
|
|
50
|
+
* through `var()` is never a violation.
|
|
51
|
+
* @param files - The files to read, each with the path a report names it by.
|
|
52
|
+
* @param options - The colours the project owns, and the marker waiving a line.
|
|
53
|
+
* @returns Every violation, in file order and then in reading order.
|
|
54
|
+
*/
|
|
55
|
+
export function findTokenViolations(
|
|
56
|
+
files: ReadonlyArray<{ path: string; text: string }>,
|
|
57
|
+
options: CheckTokensOptions = {},
|
|
58
|
+
): TokenViolation[] {
|
|
59
|
+
const marker = (options.ignoreMarker ?? DEFAULT_IGNORE_MARKER).toLowerCase();
|
|
60
|
+
const allowed = new Set<string>();
|
|
61
|
+
for (const entry of options.allow ?? []) {
|
|
62
|
+
const color = normalizeColor(entry);
|
|
63
|
+
if (color !== null) allowed.add(color);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const violations: TokenViolation[] = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
// A file whose opening lines carry the marker defines the palette rather
|
|
69
|
+
// than consuming it — the token block itself, the registry of banned
|
|
70
|
+
// colours, an image generated outside any page.
|
|
71
|
+
if (waivesWholeFile(file.text, marker)) continue;
|
|
72
|
+
const scan = scanFile(file.text, marker);
|
|
73
|
+
for (const candidate of scan.candidates) {
|
|
74
|
+
if (scan.markedLines.has(candidate.line)) continue;
|
|
75
|
+
if (candidate.color !== null && allowed.has(candidate.color)) continue;
|
|
76
|
+
violations.push({
|
|
77
|
+
file: file.path,
|
|
78
|
+
line: candidate.line,
|
|
79
|
+
column: candidate.column,
|
|
80
|
+
kind: candidate.kind,
|
|
81
|
+
text: candidate.text,
|
|
82
|
+
hint: candidate.hint,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return violations;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_IGNORE_MARKER = 'tokens-ok';
|
|
90
|
+
|
|
91
|
+
// How far into a file the whole-file waiver may sit: a licence header, an
|
|
92
|
+
// import or two, and the comment saying why this file writes colours.
|
|
93
|
+
const WAIVER_LINES = 6;
|
|
94
|
+
|
|
95
|
+
function waivesWholeFile(text: string, marker: string): boolean {
|
|
96
|
+
if (marker.length === 0) return false;
|
|
97
|
+
const head = text.split('\n', WAIVER_LINES).join('\n').toLowerCase();
|
|
98
|
+
return head.includes(`${marker}: file`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Longest first, so `--brand-alt` is never read as `--brand`.
|
|
102
|
+
const BRAND_PROPERTIES = ['--brand-alt', '--brand', '--accent'] as const;
|
|
103
|
+
|
|
104
|
+
interface Candidate extends Omit<TokenViolation, 'file'> {
|
|
105
|
+
/** The colour written, or `null` for a property that should not be declared. */
|
|
106
|
+
color: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface FileScan {
|
|
110
|
+
candidates: Candidate[];
|
|
111
|
+
markedLines: Set<number>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// One pass over the file: the marker, the block structure and every colour are
|
|
115
|
+
// all read from the same walk, so nothing is matched a second time per line.
|
|
116
|
+
function scanFile(text: string, marker: string): FileScan {
|
|
117
|
+
const lower = text.toLowerCase();
|
|
118
|
+
const candidates: Candidate[] = [];
|
|
119
|
+
const markedLines = new Set<number>();
|
|
120
|
+
const rootBlocks: boolean[] = [];
|
|
121
|
+
// Which parentheses are open, and whether each is a `var(` past its comma —
|
|
122
|
+
// a colour there is a fallback for the token named beside it.
|
|
123
|
+
const parens: Array<{ isVar: boolean; sawComma: boolean }> = [];
|
|
124
|
+
let selectorStart = 0;
|
|
125
|
+
let line = 1;
|
|
126
|
+
let lineStart = 0;
|
|
127
|
+
|
|
128
|
+
for (let index = 0; index < lower.length; index++) {
|
|
129
|
+
const char = lower[index];
|
|
130
|
+
if (char === '\n') {
|
|
131
|
+
line++;
|
|
132
|
+
lineStart = index + 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
marker.length > 0 &&
|
|
137
|
+
char === marker[0] &&
|
|
138
|
+
lower.startsWith(marker, index)
|
|
139
|
+
) {
|
|
140
|
+
markedLines.add(line);
|
|
141
|
+
index += marker.length - 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (char === '(') {
|
|
145
|
+
parens.push({
|
|
146
|
+
isVar: lower.startsWith('var(', index - 3),
|
|
147
|
+
sawComma: false,
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (char === ')') {
|
|
152
|
+
parens.pop();
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (char === ',') {
|
|
156
|
+
const open = parens.at(-1);
|
|
157
|
+
if (open !== undefined) open.sawComma = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (char === '{') {
|
|
161
|
+
rootBlocks.push(lower.slice(selectorStart, index).includes(':root'));
|
|
162
|
+
selectorStart = index + 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (char === '}' || char === ';') {
|
|
166
|
+
if (char === '}') rootBlocks.pop();
|
|
167
|
+
selectorStart = index + 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (char === '#' || char === 'r') {
|
|
172
|
+
const match =
|
|
173
|
+
char === '#' ? readHexColor(lower, index) : readRgbColor(lower, index);
|
|
174
|
+
if (match === null) continue;
|
|
175
|
+
const rule = BANNED_COLORS.get(match.color);
|
|
176
|
+
const open = parens.at(-1);
|
|
177
|
+
const isFallback = open !== undefined && open.isVar && open.sawComma;
|
|
178
|
+
if (rule !== undefined && !isFallback) {
|
|
179
|
+
candidates.push({
|
|
180
|
+
line,
|
|
181
|
+
column: index - lineStart + 1,
|
|
182
|
+
kind: rule.kind,
|
|
183
|
+
text: text.slice(index, match.end),
|
|
184
|
+
hint: rule.hint,
|
|
185
|
+
color: match.color,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
index = match.end - 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (char === '-') {
|
|
193
|
+
const property = readBrandDeclaration(lower, index);
|
|
194
|
+
if (property === null) continue;
|
|
195
|
+
if (rootBlocks.includes(true)) {
|
|
196
|
+
candidates.push({
|
|
197
|
+
line,
|
|
198
|
+
column: index - lineStart + 1,
|
|
199
|
+
kind: 'redeclared-brand',
|
|
200
|
+
text: property.name,
|
|
201
|
+
hint: `render <SiteTheme siteId> instead of declaring ${property.name} in :root.`,
|
|
202
|
+
color: null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
index = property.end - 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { candidates, markedLines };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A declaration of one of the palette properties. Reading one — `var(--brand)`
|
|
212
|
+
// — has no colon after the name, so it never lands here.
|
|
213
|
+
function readBrandDeclaration(
|
|
214
|
+
lower: string,
|
|
215
|
+
start: number,
|
|
216
|
+
): { name: string; end: number } | null {
|
|
217
|
+
for (const name of BRAND_PROPERTIES) {
|
|
218
|
+
if (!lower.startsWith(name, start)) continue;
|
|
219
|
+
const after = start + name.length;
|
|
220
|
+
// `--brand-alt-text` is its own property, not `--brand-alt`.
|
|
221
|
+
if (isNameChar(lower[after])) return null;
|
|
222
|
+
let index = after;
|
|
223
|
+
while (isSpace(lower[index])) index++;
|
|
224
|
+
if (lower[index] !== ':') return null;
|
|
225
|
+
return { name, end: after };
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isNameChar(char: string | undefined): boolean {
|
|
231
|
+
if (char === undefined) return false;
|
|
232
|
+
return (
|
|
233
|
+
(char >= '0' && char <= '9') ||
|
|
234
|
+
(char >= 'a' && char <= 'z') ||
|
|
235
|
+
char === '-' ||
|
|
236
|
+
char === '_'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isSpace(char: string | undefined): boolean {
|
|
241
|
+
return char === ' ' || char === '\t';
|
|
242
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tokens-ok: file — the colour reader is tested against literals.
|
|
2
|
+
/**
|
|
3
|
+
* Reading a colour out of source text, in both spellings the same value is
|
|
4
|
+
* written in: `#5f6b7c` and `rgb(95 107 124)`. Both come back as the same six
|
|
5
|
+
* lowercase hex digits, so a table of banned colours holds one entry per
|
|
6
|
+
* colour rather than one per way of typing it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A colour found in the text, and where it ends. */
|
|
10
|
+
export interface ColorMatch {
|
|
11
|
+
/** The value, as six lowercase hex digits, without its `#`. */
|
|
12
|
+
color: string;
|
|
13
|
+
/** The index just past the last character the colour is written with. */
|
|
14
|
+
end: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The colour a hex literal starts at, in `#rgb`, `#rgba`, `#rrggbb` or
|
|
19
|
+
* `#rrggbbaa` form. The alpha, when there is one, says nothing about the
|
|
20
|
+
* colour and is dropped.
|
|
21
|
+
* @param lower - The text being scanned, already lowercased.
|
|
22
|
+
* @param start - The index of the `#`.
|
|
23
|
+
* @returns The colour and its end, or `null` when it is not a hex colour.
|
|
24
|
+
*/
|
|
25
|
+
export function readHexColor(lower: string, start: number): ColorMatch | null {
|
|
26
|
+
let end = start + 1;
|
|
27
|
+
while (isHexDigit(lower[end])) end++;
|
|
28
|
+
|
|
29
|
+
// A run of hex digits running straight into a word is an identifier — a
|
|
30
|
+
// private field, an anchor — rather than a colour.
|
|
31
|
+
if (isWordChar(lower[end])) return null;
|
|
32
|
+
|
|
33
|
+
const digits = end - start - 1;
|
|
34
|
+
if (digits === 3 || digits === 4) {
|
|
35
|
+
const red = lower[start + 1];
|
|
36
|
+
const green = lower[start + 2];
|
|
37
|
+
const blue = lower[start + 3];
|
|
38
|
+
return { color: `${red}${red}${green}${green}${blue}${blue}`, end };
|
|
39
|
+
}
|
|
40
|
+
if (digits === 6 || digits === 8) {
|
|
41
|
+
return { color: lower.slice(start + 1, start + 7), end };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The colour an `rgb()` or `rgba()` call starts at, in either the comma or the
|
|
48
|
+
* space separated spelling.
|
|
49
|
+
*
|
|
50
|
+
* Only a plain byte triple is read: a percentage, a decimal or a variable
|
|
51
|
+
* inside the call means the value cannot be compared with a hex literal, and
|
|
52
|
+
* is left alone rather than guessed at.
|
|
53
|
+
* @param lower - The text being scanned, already lowercased.
|
|
54
|
+
* @param start - The index of the `r`.
|
|
55
|
+
* @returns The colour and its end, or `null` when it is not one.
|
|
56
|
+
*/
|
|
57
|
+
export function readRgbColor(lower: string, start: number): ColorMatch | null {
|
|
58
|
+
let open = -1;
|
|
59
|
+
if (lower.startsWith('rgb(', start)) open = start + 3;
|
|
60
|
+
else if (lower.startsWith('rgba(', start)) open = start + 4;
|
|
61
|
+
if (open === -1) return null;
|
|
62
|
+
|
|
63
|
+
const close = lower.indexOf(')', open);
|
|
64
|
+
if (close === -1) return null;
|
|
65
|
+
|
|
66
|
+
const components: number[] = [];
|
|
67
|
+
let value = -1;
|
|
68
|
+
for (let index = open + 1; index < close; index++) {
|
|
69
|
+
const char = lower[index];
|
|
70
|
+
if (char !== undefined && char >= '0' && char <= '9') {
|
|
71
|
+
value = Math.max(value, 0) * 10 + Number(char);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (value >= 0) {
|
|
75
|
+
components.push(value);
|
|
76
|
+
value = -1;
|
|
77
|
+
}
|
|
78
|
+
// The alpha of the modern syntax, which the colour does not depend on.
|
|
79
|
+
if (char === '/') break;
|
|
80
|
+
if (char !== ' ' && char !== ',' && char !== '\t' && char !== '\n') {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (value >= 0) components.push(value);
|
|
85
|
+
if (components.length < 3) return null;
|
|
86
|
+
|
|
87
|
+
let color = '';
|
|
88
|
+
for (let index = 0; index < 3; index++) {
|
|
89
|
+
const component = components[index];
|
|
90
|
+
if (component === undefined || component > 255) return null;
|
|
91
|
+
color += component.toString(16).padStart(2, '0');
|
|
92
|
+
}
|
|
93
|
+
return { color, end: close + 1 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The `rrggbb` form of a colour written by hand — in an allow list, on a
|
|
98
|
+
* command line — in any of the spellings the scanner itself reads.
|
|
99
|
+
* @param value - The colour, with or without its `#`.
|
|
100
|
+
* @returns The six lowercase hex digits, or `null` when it is not a colour.
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeColor(value: string): string | null {
|
|
103
|
+
const text = value.trim().toLowerCase();
|
|
104
|
+
if (text === '') return null;
|
|
105
|
+
let match: ColorMatch | null = null;
|
|
106
|
+
if (text.startsWith('#')) match = readHexColor(text, 0);
|
|
107
|
+
else if (text.startsWith('rgb')) match = readRgbColor(text, 0);
|
|
108
|
+
else match = readHexColor(`#${text}`, 0);
|
|
109
|
+
return match === null ? null : match.color;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isHexDigit(char: string | undefined): boolean {
|
|
113
|
+
if (char === undefined) return false;
|
|
114
|
+
return (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isWordChar(char: string | undefined): boolean {
|
|
118
|
+
if (char === undefined) return false;
|
|
119
|
+
return (
|
|
120
|
+
(char >= '0' && char <= '9') || (char >= 'a' && char <= 'z') || char === '_'
|
|
121
|
+
);
|
|
122
|
+
}
|