Assembled React components shared by the *.cheminfo.org sites.
react-science gives us
primitives — Toolbar, Accordion, SplitPane, Button. This package is
the layer above: assemblies, a component you hand data to and it delivers a
whole feature, its copy, its interactions and its logic included. A site
normally depends on both.
npm i react-cheminforeact, react-dom, @blueprintjs/core and react-science are peer
dependencies: a site already has them, and a second copy of react-science
would mean two Blueprint style trees in one page.
Subpaths are bundle boundaries, not taxonomy: a feature gets its own door only when it drags a heavy dependency behind it.
| Import | Holds | Costs |
|---|---|---|
react-cheminfo/core |
every framework-free helper — 140 value exports | nothing |
react-cheminfo/ui |
every React component and hook — 60 value exports | React |
react-cheminfo/vite |
the prerender plugin and the OG card | nothing; React on the card |
react-cheminfo/orbital |
the 3D atomic-orbital viewer | React, molstar |
react-cheminfo/structure |
the structure editor and renderer | React, react-ocl, OCL |
react-cheminfo/slides |
the deck format and the slideshow player | React, react-markdown |
react-cheminfo/styles/chrome.css |
the shared tokens and site-header stylesheet | nothing |
react-cheminfo/styles/slides.css |
the deck stylesheet, themed on the site's colours | nothing |
A backend serving an RIS endpoint, a prerender script writing a sitemap, and every unit test of that logic therefore load no React at all — and a worker sampling an orbital loads neither React nor molstar. Every peer is optional, so a site that only wants the Tools menu downloads none of them, and a project that only prerenders installs neither React nor Vite.
react-cheminfo/vite is the one door with a split bill: cheminfoPrerender
needs nothing beyond Vite's own plugin type, while ogCardHtml renders a mark
and therefore loads React — but on the call, not on the import, so the plugin
stays reachable from a checkout that has neither.
The stylesheet ships from styles/, which tsc leaves alone, and is reached
through a wildcard subpath exactly as react-science serves its own.
| Area | …/core |
…/ui |
|---|---|---|
| Site identity | siteById, findSiteByHost, siteTokensCss, siteThemeColor, renderEcosystemLinksHtml |
SiteMark, Wordmark, SiteTheme, SiteTile, EcosystemButton, EcosystemLinks |
| Chrome | — | SiteHeader, SiteFooter, NavLink, NavMenuButton, MenuButton, useCompactHeader |
| Citation | formatCitation, formatCitations, citationSegments, downloadCitation, citedReferences, doiUrl |
CiteButton, CitationMenu, CitationPreview |
| Share & embed | parseShareConfig, applyShareConfig, buildShareUrl, buildEmbedCode, isHidden, the param codecs |
ShareDialog, ShareButton, HiddenPartsProvider, PagePart, useIsHidden |
| Routing & head | createTabRouter, createPageAddresses, adoptLegacyHashAddress, writeDocumentMeta, canonicalLink |
— |
| Indexing | injectPageMeta, pageHeadTags, pageDocumentMeta, fill, PAGE_HEAD_MARKER, PAGE_BODY_MARKER, robotsTxt, sitemapXml, noscriptIndex, structuredDataScript, assertRoutes, routeFor, homeRoute |
cheminfoPrerender, ogCardHtml, OG_WIDTH, OG_HEIGHT (all /vite) |
| Pedagogy | parseGlossaryMarkers, localStorageProgressStore, progressSummary, finishValidation |
GlossaryText, SyntaxTooltip, HintLadder, ExerciseActions, ExerciseProgressHeader, TutorialStepStrip, ReferenceGrid, TestCaseList |
| Clipboard & files | writeToClipboard, downloadBlob, downloadText, sanitizeFileName, toDelimited, readDelimited, downloadFigure, figureSvg, figurePng, figureSize |
CopyButton, CodeBlock, DelimitedTextDialog, FigureDownload |
| Formatting & colour | formatInteger, formatDecimal, formatBytes, pluralize, readableInk, contrastRatio, COLOR_SCALES, resolveColorScale, formatColorScale, colorAt, swatchAt, sampleScale, colorScaleGradient, rgbToHsv |
ColorScaleLegend, ColorScaleSelect, ColorScaleEditor, ColorScaleBar |
| Widgets | CREDITS, credits |
ErrorBoundary, CollapsibleSection, CapsuleFilter, HelpTooltip, CreditsList |
| Hooks & state | createWorkerChannel |
persistBucket, useDebouncedValue, useContainerSize, useListKeyboardNavigation, useDisclosure |
| Chemistry | atomicOrbitalsOf, configurationOf, classifyMolfile, readStructure |
AtomicOrbitalViewer (/orbital), StructureEditor, Structure (/structure) |
| Spectra | FILTER_CATALOG, filterMenu, settingsProblems, normalizationFilters, addFilter, moveFilter, setFilterOption, principalComponentChoices, clampPrincipalComponents, EMPTY_SETTINGS |
SpectraSettingsEditor, FilterChainEditor, PrincipalComponentSelect |
| About | resolveAbout, aboutProblems |
AboutPage |
| Slideshows | parseTalk, splitDemoLinks, slideActionForKey, parseTalkOrigin, buildTalkManifest (all /slides) |
Slideshow, SlideView, TalkList, BackToSlides (all /slides), cheminfoTalks (/vite) |
| Token guard | findTokenViolations |
cheminfo-check-tokens (the bin) |
| Figures | chartScale, chartAxisScale, chartAxisTitle, chartShare, chartColumnExtent, chartBinCounts, chartSeriesColor, rowMatrix, stackedMatrix, emptiestCorner, placeOverlayCard, overlayMetrics |
ChartFrame, ChartAxis, TrackedLineChart, OverlayBar, OverlaySelect, OverlaySegmented, OverlayToggle, OverlayNumber, OverlayLegend, OverlayCaption, OverlayReadout |
| Projections | pcaResult, embeddingResult, projectionTabs, loadingProfiles, explainedShares, confidenceEllipse, projectEllipse, pointsInPolygon, resolveProjectionGroups, PROJECTION_COPY |
PcaViewer, ProjectionViewer, ScatterPlot, ScatterMatrix |
| Periodic table | PERIODIC_ELEMENTS, elementBySymbol, elementByAtomicNumber, cellOf, placedElements, elementByArrowKey, categorySwatch, CATEGORY_LABELS |
PeriodicTable, ElementCell, CategoryLegend |
Everything in that table is exported from ./core, ./ui, ./slides,
./vite or ./structure and nothing else is: the sub-components a component is built
from, the parsers a helper calls and the internals of a hook stay inside the
package, reachable only by their own path. If it is exported, it is supported.
The rule for a new site is short: react-cheminfo first, react-science
second, your own code last. The full import table and the checklist live in
websites/CLAUDE.md.
Storybook is the demo, and every exported component has one — 287 stories:
npm run dev # the book on http://localhost:10815
npm run test-e2e # Playwright opens all 287 and fails on any console errorThe Brand toolbar at the top retunes --brand / --brand-alt, so any story
can be read as it would look on any site of the family.
The sources are organised the other way round — one folder per feature
(src/citation, src/ecosystem, src/share, src/seo, src/pedagogy, …),
each holding a core/ and a ui/ half — and src/core.ts, src/ui.ts,
src/vite.ts, src/structure.ts and src/orbital.ts are the barrels the entry
points point at. ESLint forbids a core/ folder from importing react,
react-dom or anything under a ui/, which is what keeps the framework-free
entry points honest; src/seo/vite is the one half named for its consumer
rather than for a framework, because a build plugin is neither.
src/shared holds what more than one of them is built on: MenuButton, the
shape every button of a site header takes, which is why CiteButton and
EcosystemButton differ only in their glyph and their menu, and why both accept
the compact and placement props of the exported HeaderButtonProps.
The Cite entry of a site header: one button opening the work at its DOI, copying its reference in the style a journal asks for, and saving the files a reference manager imports.
import { CiteButton } from 'react-cheminfo/ui';
<CiteButton reference={PAPER} />;- HTML and Markdown, each in the four styles chemists are asked for — ACS, Nature, RSC and Wiley. An HTML copy is written to the clipboard in both flavours, so Word and Google Docs keep the emphasis while a plain editor receives a clean line; that is why plain text is not a separate entry.
- BibTeX, RIS and the DOI link, which carry no style.
- RIS and BibTeX files, served with the MIME types Zotero, Mendeley and EndNote recognise, so opening the saved file imports it.
- A hover preview of exactly what each entry copies or saves.
compactdrops the text and the caret, leaving the icon alone to open the menu, for a header that has run out of room. The label stays what the pointer and a screen reader are told.
Adding a style means one function in citation/core/segments.ts and one entry
in CITATION_STYLES — the three output formats and the preview follow.
A site is often built on more than one work — the tool it wraps, and the
platform it runs on — and a reader handed two references has to be told what is
what. Pass works rather than reference, each one carrying the words that say
what citing it credits:
<CiteButton
works={[
{
reference: SURGE_PAPER,
what: 'The isomer generator',
note: 'Cite it for the enumeration: every structure here comes from surge.',
},
{
reference: PLATFORM_PAPER,
what: 'The browser platform',
note: 'Cite it for the site itself.',
},
]}
/>The menu then opens on a line asking for all of them — guidance writes that
line when the default does not fit — and lists every work with what it covers.
Each work holds its own article and its own reference, in the default style; the
sections below carry the whole set at once, so one copy pastes both citations and
one saved file holds both records (references.ris). Nothing changes for a site
asking for a single work.
The two works the whole family shares are held here rather than copied into each
site, so none of them can name a different version of the same paper — import
them from react-cheminfo/core and put the site's own work first:
| Export | The work |
|---|---|
PLATFORM_PAPER / PLATFORM_WORK |
Chemical data processed in the browser, which is what makes these applications rather than queues of jobs. Every site. |
TEACHING_PAPER / TEACHING_WORK |
Two decades of online teaching, which is what the tutorials and the exercises are. The sites that teach. |
import { PLATFORM_WORK, TEACHING_WORK } from 'react-cheminfo/core';
<CiteButton works={[OWN_WORK, TEACHING_WORK, PLATFORM_WORK]} />;The Tools entry of a site header: one button opening every other site of the family, each behind its own little logo and the two colours it owns.
import { EcosystemButton } from 'react-cheminfo/ui';
<EcosystemButton currentSiteId="vcl" />;currentSiteIdis the one thing that differs per site: that tile is shown with a you are here label and is not a link. Passing nothing links them all.compactdrops the text and the caret, leaving the icon alone to open the menu, for a header that has run out of room. The label stays what the pointer and a screen reader are told.- A tile lights up in the colour of the site it opens, so running the pointer down the grid is what makes the pairs of colours read.
One hydrogen-like atomic orbital, screened by Slater's rules, sampled in the browser and drawn as a signed isosurface with molstar.
import { AtomicOrbitalViewer } from 'react-cheminfo/orbital';
<AtomicOrbitalViewer atomicNumber={26} orbitalId="3dz2" />;- The maths is exact, not a cartoon: a hydrogen-like radial function with its
n − ℓ − 1nodes, times a real spherical harmonic with its ℓ angular ones. The Slater orbital a hybridisation model uses is nodeless and would draw a 3s exactly like a 1s, only fatter. - The canvas is behind a
React.lazyboundary and nothing this entry point exports pulls molstar in statically, so a page that never draws an orbital never downloads it. - A WebGL probe runs before molstar is touched (
probeViewerCapability), so a locked-down school machine gets a sentence rather than a blank rectangle. - Every orbital is drawn at one canonical size. Molstar's camera clamps its near plane, so uranium's 4f — which reaches 0.35 Å — would otherwise stay a dot in the corner; the true extent is reported as ⟨r⟩ instead.
- The isovalue is a weighted quantile of the samples (
isocontourCutoff), never molstar'scomputeOrbitalIsocontourValues: that one abandons any field whose mean ψ² falls under an absolute1e-5, which reads the orbital's size rather than its shape, and left 2588 of 7460 orbitals blank. A quantile has no scale of its own. - The isosurface is extracted on the CPU (
tryUseGpu: false). Molstar's GPU marching cubes quantises the field to 255 steps on upload, which terraces xenon's 4p, and pits a diffuse outer lobe with voxel-sized dimples. The surface is a thin shell whatever the box holds, so the CPU path costs 13–44 ms even at 152³. resolutiontakes a{ floor, cap }pair as well as a number, and then each orbital picks its own: one resolution for a whole table leaves xenon's innermost 4p lobe spanning 6.6 voxels while the outer one spans 41. A nodeless orbital stays on the floor and costs nothing extra.sampleaccepts a worker-backed sampler when a site would rather not spend ~25 ms of its main thread per orbital.runAtomicSamplefromreact-cheminfo/coreis the function that worker calls, and it imports neither React nor molstar.
The maths is exported on its own from react-cheminfo/core —
atomicOrbitalsOf, configurationOf, slaterScreening, radialProfile,
radialNodeRadii, sampleAtomicOrbital, orbitalContour — so a site can draw
its own radial plot, list an element's orbitals, or print a screened charge
without mounting anything.
Each mark keeps the geometry of that site's own logo where it has one, redrawn on a plate of the site's own colour so every mark of the family still reads as one row, and carrying the site's answering colour on exactly one element — which is what stops it collapsing into a flat shape at 16 px.
NMRium keeps its artwork whole: the symbol is the one from Logo.tsx of the
NMRium sources, in its orange, on the plum its logo sets the rest of the
wordmark in. ChemCalc is the one mark that is ours — its logo is an atom
that is unreadable small, so the tile draws what the tool returns, an isotopic
pattern, in ChemCalc's own indigo and teal.
The colours a name is set in are the site's own and are not retuned to reach the 4.5:1 of body text: ChemCalc's teal and NMRium's orange both land just under it, which is why a name is set bold, where 3:1 is the threshold.
The colours a quantity is read with, chosen by looking at them rather than by reading their names — and, behind the last entry, built from anchors of the reader's own.
import { COLOR_SCALES, resolveColorScale, swatchAt } from 'react-cheminfo/core';
import { ColorScaleSelect } from 'react-cheminfo/ui';
<ColorScaleSelect label="Colour scale" value={scale} onChange={setScale} />;
const { scale } = resolveColorScale(text); // 'plasma', or 'hsv-long,0-0000ff,1-ff0000'
swatchAt(scale, 0.5); // { background, foreground }- The choice is one string, which is what goes in the state and in the
address: the id of one of
COLOR_SCALES, or a scale spelled out. Nothing else has to be carried, andresolveColorScalenever throws — a scale renamed since a course page was written falls back to viridis rather than blanking the figure. - Ten scales are offered: viridis, plasma, magma, inferno, cividis and turbo; greys for a photocopier; cool-warm and blue-red for a quantity read away from a middle; and the rainbow, which orders nothing but is what a course usually draws.
- A custom scale is anchors plus a path.
rgbmixes the three channels, which is what a browser gradient does;hsvturns along the colour wheel the short way, keeping the saturation instead of fading through the grey in the middle;hsv-longturns the other way, which is how two anchors of one hue draw a whole rainbow.ColorScaleEditoris that editor on its own, for a site that wants it inline. - A scale that turns is sampled, never handed to CSS as its two ends — a
browser only interpolates the straight line between two colours.
colorScaleGradientandColorScaleBardo the sampling.
Everything spectra-processor
can be told, laid out in the order it happens — and the principal components a
score plot is then drawn against.
import { EMPTY_SETTINGS, settingsProblems } from 'react-cheminfo/core';
import { SpectraSettingsEditor } from 'react-cheminfo/ui';
<SpectraSettingsEditor
value={settings}
onChange={setSettings}
spectrumIds={processor.getIDs()}
principalComponents={{
selection,
onSelectionChange: setSelection, // { x: 0, y: 1 } — columns of the score matrix
count: scores.columns,
explainedVariance: pca.getExplainedVariance(),
settings: pcaOptions,
onSettingsChange: setPcaOptions,
}}
/>;
processor.setNormalization(settings.processor.normalization);
processor.getPostProcessedData(settings.postProcessing);- The types come from the packages that own them, not from a copy.
src/spectra/core/settings.tsis the only file namingspectra-processororml-signal-processing, and it reads the shapes off their public surface —ConstructorParameters<typeof SpectraProcessor>[0],FilterXYType. Both are optional peers and every import isimport type, so a site that does not use this component downloads neither. - All 23 chain steps are offered, grouped as a chain is built — baseline,
smoothing and derivatives, scaling, the two axes, housekeeping — with every
option each one takes, down to the nested peak picking of
calibrateX. The catalog is aRecordkeyed by the step name, so the dayml-signal-processingadds a filter this package stops compiling instead of quietly not offering it. - The order is the meaning, so it is editable and it is checked. A step moves with two arrow buttons — no drag dependency, and it works from the keyboard. Scaling placed before the baseline it depends on, a second resampling, a crop that leaves the spectra on grids that no longer line up: each is named where it sits.
- Nothing blocks. A reader mid-thought may hold settings the processor would
throw on.
settingsProblemssays which value it would throw on — separating what fails from what merely looks unintended — and the editor still takes the edit. - A cleared box means "whatever upstream does", never
undefinedwritten into the settings, which is the only way back to a default once it has been typed over. A half-typed1e-is kept as typed rather than parsed away. - The components are named
PC1and handed back as0. The label is what a chemist writes; the number is the column of the score matrix, because that is what indexes it.clampPrincipalComponentskeeps a selection saved on other data inside the components that exist, and keeps the two axes apart so a plot never collapses onto its diagonal.FilterChainEditorandPrincipalComponentSelectare each usable on their own.
A finished principal component analysis, read by somebody who has never met one.
import { PCA } from 'ml-pca';
import { PcaViewer } from 'react-cheminfo/ui';
const pca = new PCA(rows, { scale: true });
<PcaViewer
pca={pca}
rows={rows}
scaled
projected={newBatch} // placed into the finished model, drawn hollow
variables={{
kind: 'named',
names: ['Sepal length', 'Sepal width', 'Petal length', 'Petal width'],
unit: 'cm',
}}
samples={{
ids,
groups: species,
groupLabel: 'Species',
fields: (index) => [
{ label: 'Sepal length', value: `${rows[index][0]} cm` },
],
}}
onSelectionChange={(ids) => setPicked(ids)}
onTrackVariable={(track) => setHovered(track?.readout ?? null)}
/>;- Four tabs, and only the ones the data can fill.
projectionTabsreads the result rather than a prop: a principal component analysis publishes a share of the variance and a set of weights, so it gets the map, every pair, what differs and how much each explains. A UMAP embedding publishes neither, so it gets the map alone — one tab and no tab strip, instead of three empty ones. - It never imports
ml-pca.PcaLikeis a structural type —predict,getExplainedVariance,getEigenvalues,getLoadings— whichml-pca'sPCAsatisfies as it stands, with no adapter and no cast. The package has no runtime dependency on it, andembeddingResultis the same door for coordinates from anywhere else. - An outline says what share of a group it holds, not how many standard
deviations it is. In two dimensions those are not the numbers anybody
remembers: an ellipse at one standard deviation holds about 39% of the points,
not 68%. The picker therefore offers
50% of samples…99% of samples, and the legend writes the same words, so the figure cannot promise a coverage it does not have. - The outline is projected, not rotated. The two axes almost never carry the
same units per pixel, so an ellipse turned by an angle in the data is not
turned by that angle on the screen. Handing an SVG
<ellipse>the data-space angle draws every tilted group slightly wrong and never says so. - A sample the model was built from is filled; one placed into it afterwards is
hollow.
pcaResult(pca, { rows, projected })appends the second kind and records where they start, because the two are not equally trustworthy: a fitted sample helped choose where the axes point and is bound to land somewhere reasonable, while a projected one can land anywhere — and landing far out is the finding, not a fault. - Colour means one thing per tab and the legend says which. Groups on the map
and the pair grid, components on the other two, drawn from two orders of the
Okabe–Ito palette that are disjoint over their first four, so blue-is-setosa on
one tab is never blue-is-PC1 on the next.
OverlayLegendhas no default title: a legend that does not name its encoding is how a reader carries the wrong meaning from one tab to the next. - The direction of a component is arbitrary, so it is never a colour. Sign is carried by geometry — above or below the zero line — and each component is flipped so its strongest measurement comes out positive, because otherwise refitting on one more sample mirrors the whole map and reads as a fault.
- Selecting is a drag. Plain drag replaces the selection, shift adds, alt
removes, and the caption says which while the pointer is still down.
onSelectionChangehands back sample ids, so a caller never keeps a second index. Pointer capture, cancellation and lost capture all funnel into one path, which is what stops a lasso being left half-drawn.
PcaViewer is a thin adapter over ProjectionViewer, which draws any
dimension-reduction result — a k-means run with its centroids, a UMAP embedding,
coordinates read from a file — from one ProjectionResult. The pieces below it
are meant to be built on:
| Domain | Holds |
|---|---|
src/chart |
MatrixLike read where it stands, linear scales, round ticks, the SVG frame, the tracked line chart |
src/overlay |
The card of controls that floats over a figure, and the caption, legend and readout that explain one |
src/scatter |
Lasso selection, hover, confidence ellipses, the plot and the pair grid |
src/projection |
The result model, the two adapters, and the viewer |
The overlay domain is the part most likely to turn up elsewhere. A card rests at three quarters strength and wakes when the pointer reaches the figure or the keyboard reaches one of its controls; only its ground fades, never its text, because a label at three quarters over a busy scatter is unreadable exactly when it is wanted. One or two controls stay in the strip and everything an expert changes lives behind a cog — concealment is the default, and a control is visible only because somebody put it there deliberately. Below about 420 pixels the whole bar folds into that cog, and the tab order is the same folded or open.
<OverlayLayer width={width} density="compact">
<OverlayBar placement={emptiestCorner(points)} more={<Expert />}>
<OverlaySelect caption="Across" value={x} options={axes} onChange={setX} />
</OverlayBar>
</OverlayLayer>Every projection viewer carries a save glyph in its bar: a reader picks PNG or
SVG, picks a resolution, and gets the figure as a file. The panel writes out the
pixels each resolution would produce, and fileName names what arrives — the
view is appended, so fileName="ecstasy" saves ecstasy-map.png.
The control behind it is FigureDownload, and it is not tied to the viewer: it
takes the id of the box a figure sits in, so it can stand in a bar, a
toolbar or a menu without the component that drew the figure handing anything
over.
<FigureDownload targetId="pca-figure" fileName="ecstasy-map" />
<div id="pca-figure">
<PcaViewer pca={pca} rows={rows} samples={samples} />
</div>Three things it does that a screenshot does not. Everything inside the box is
saved, so a view that is really sixteen charts arrives as one picture with each
placed back where the reader saw it. The tokens the figure is drawn from are
resolved first — a var(--border) that left the site would draw a chart with no
axes. And the chrome floating over the picture is left behind: an OverlayLayer
marks itself, so the cog in the corner never lands in the middle of the scatter.
downloadFigure(target, options), figureSvg and figurePng are the same thing
without the button, for a site saving a figure from its own menu.
A deck is one Markdown file in the repository of the site whose subject it
teaches — front matter, slides separated by a --- line, and a
<!-- layout: … --> comment where the default is not what is wanted. The
format and the player are react-cheminfo/slides; the addresses and the
authoring rules are in rules/slideshows.md.
import { parseTalk, Slideshow } from 'react-cheminfo/slides';
import 'react-cheminfo/styles/slides.css';
const talk = parseTalk(source);
<Slideshow
talk={talk}
talkId="20260824_IMSC"
index={slide}
onIndex={(next) => navigate(`/talks/${id}?slide=${next}`)}
layouts={{ octochemdb: OctoChemDbFlow }}
/>;The index is controlled: the site owns the address, so a link can open one
slide and a demo link can come back to it. Arrow keys, space and PageUp/Down
navigate, Home and End jump, f is fullscreen, b and w blank the
screen, n shows the speaker's notes. A layout name the player does not know
falls back to content rather than breaking a deck in front of a room, and a
site adds its own through layouts.
The embed layout puts a live tool on the slide — the last bare URL of the
body is framed — which is what our ?embed mode exists for.
The format itself is framework-free and is also exported from
react-cheminfo/core, so a backend gathering the family's manifests reads them
without loading React.
A site that fullscreens its own shell — so a slide can open the live tool and
come back still presenting — passes fullscreen={{ isFullscreen, onToggle }},
and the f key and the bar's button drive that instead of the player's own
element.
Publishing a site's decks to the family is one plugin:
import { cheminfoTalks } from 'react-cheminfo/vite';
plugins: [cheminfoTalks({ site: 'chemcalc' })];It writes dist/talks.json and dist/talks/<id>.md, which is what
learn.cheminfo.org reads to list and play every site's talks beside its own.
A static site has no server to rewrite a head per request, so a crawler gets
whatever came off the wire. cheminfoPrerender writes one real HTML file per
routed address at build time, each with its own title, description and
canonical, plus sitemap.xml, robots.txt, the JSON-LD block and the
noscript crawl path. Without it every address carries the same head and a
search engine folds the whole site into one result.
index.html is the template, and it carries no title and no description of
its own: it declares where they go, with two comments.
<head>
<meta charset="utf-8" />
<link rel="icon" href="%BASE_URL%favicon.svg" />
<!--cheminfo:head-->
</head>
<body>
<div id="root"></div>
<!--cheminfo:body-->
</body>That is the whole contract, and it is what makes the rest of this small: the
head is written, never found and operated on. Nothing is parsed and nothing is
searched for but the marker, so a byte order mark, an implicit head, a </head>
the page's own prose displays or a bundled script quotes in a string, and a
<title> inside an <svg> all leave the result exactly as it is — and no
duplicate title or description is possible, because the template carries none. A
page missing <!--cheminfo:head--> throws rather than shipping headless.
<!--cheminfo:body--> is where the noscript crawl path goes, and a site
running with noscript: false needs no such marker.
It is a Vite plugin, and the whole of a live caller — 3d.cheminfo.org — is:
// vite.config.ts
import react from '@vitejs/plugin-react';
import { cheminfoPrerender } from 'react-cheminfo/vite';
import { defineConfig } from 'vite';
import { PAGE_ROUTES } from './src/seo/routes.ts';
export default defineConfig({
plugins: [
react(),
cheminfoPrerender({
site: '3d',
routes: PAGE_ROUTES,
operatingSystem: 'Any browser with WebGL2',
}),
],
});routes is one RouteMeta[] the site owns and three things read: this plugin,
injectPageMeta on a server that has one, and writeDocumentMeta in the
running app after an in-app move. A page missing from it is a page a search
engine only ever sees as the home page.
npm run dev fills the same two markers from the home route, so a developer
never opens a page showing its markers and dev reads like production.
origin is where the site is served, mount path included — one value
carrying both, because that is exactly what a canonical link, an og:url and a
sitemap entry need. Everything else is read back off it: robots.txt writes its
Allow and Disallow under the mount, the noscript index links under the
mount, and every absolute address is built on it. There is no second option to
keep in step.
cheminfoPrerender({ site: 'surge', routes: PAGE_ROUTES });
// origin defaults to https://surge.cheminfo.org — the site owns its host,
// the mount is '', and /exercises is written at dist/exercises/index.html.
cheminfoPrerender({
site: 'surge',
routes: PAGE_ROUTES,
origin: 'https://learn.cheminfo.org/surge',
});
// the same build, mounted under /surge: canonicals and the sitemap read
// https://learn.cheminfo.org/surge/exercises, and robots.txt allows /surge/.The files on disk are laid out from the build's own root either way —
dist/exercises/index.html, never dist/surge/exercises/index.html. It is the
server that puts them under the mount. An origin must be an absolute http or
https address or it is refused: one written localhost:3000 parses with
localhost: as its scheme, which would read the mount back as /3000.
robots: false skips robots.txt for a site shipping its own,
category: false skips the JSON-LD block, and noscript: false leaves the site
without its only crawl path — so leave it on.
ogCardHtml draws public/og.png from the site's own record — its mark, its
two colours, its name — so the card never drifts from the mark the site shows.
It returns a page for a headless browser to screenshot, and it is the one export
of this entry point that loads React, on the call rather than on the import:
// scripts/generateOgImage.ts, run with `npm run og-image`
import { chromium } from '@playwright/test';
import { OG_HEIGHT, OG_WIDTH, ogCardHtml } from 'react-cheminfo/vite';
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: OG_WIDTH, height: OG_HEIGHT },
});
await page.setContent(await ogCardHtml({ site: '3d' }), { waitUntil: 'load' });
const png = await page.screenshot({ type: 'png' });
await browser.close();The same head is written per request instead:
injectPageMeta(index, { site, routes, origin, url }), next to the
tracking-script injection, on /, /index.html and the SPA fallback alike — the
served page is the same template, with the same two markers. robotsTxt and
sitemapXml are the same two calls, answered as routes rather than written to
disk. A server writing more into the same place composes it itself, with
pageHeadTags and fill(page, PAGE_BODY_MARKER, noscriptIndex(…)). All of that
is react-cheminfo/core, so a backend loads no React and no Vite.
No stylesheet, nothing for a consumer to import. Every rule is a
module-level CSSProperties constant applied with style, the way react-mf
does it — so there is no import step, no load-order surprise, and no specificity
fight with Blueprint, since an inline rule outranks every selector.
A component never hard-codes a site's identity: what differs per site is passed
in as props, or read from a CSS variable (--brand, --brand-alt, --accent)
so each site keeps the two colours it owns.
npm run devopens Storybook on http://localhost:10815 — every component of the package,
under every prop it takes, with the controls to set them and the snippet that
reproduces what is on show. The Brand toolbar retunes --brand /
--brand-alt / --accent on the canvas, so you can watch a component carry a
site's two colours. Components land there as they move into the package.
A story is a *.stories.tsx file in stories/, written in CSF: a default
export naming the component, and one export per state the sites use it in. The
prose and the types of the controls come from the props' own JSDoc, read off the
source by @storybook/addon-docs, so a component is documented by documenting
its props — never by writing a page for it. stories/headerButton.ts already
describes the controls every header button shares.
npm run build-storybook # storybook-static/is what GitHub Pages serves at https://cheminfo.github.io/react-cheminfo/,
built and deployed by .github/workflows/pages.yml on every push to main.
Unit tests cover the framework-free half, which is where the citation formats and the site list live:
npm run test # vitest + type-check + eslint + prettierThe components themselves are almost entirely interaction — Blueprint popovers with hover delays, submenus, clipboard writes in two flavours, file downloads — so they are tested in a real browser rather than a simulated DOM:
npx playwright install --with-deps chromium # once
npm run test-e2e # or test-e2e-ui to watchnpm run test-e2e starts Storybook itself and drives each story on its own
address (/iframe.html?id=…), so there is nothing to launch first. It is a
separate command from npm run test, because it needs a browser that a plain CI
job does not have; its own workflow installs one.
Before publishing, test the packed artifact rather than the sources: it is
what a site will actually install, and it is where a missing file or a wrong
exports entry shows up.
npm run tsc # build lib/
npm pack # react-cheminfo-0.0.0.tgzThen, from a throwaway app — or from the site you are about to adopt it in:
npm i ../react-cheminfo/react-cheminfo-0.0.0.tgzImport from every entry point, so the check covers the framework-free halves too:
import { CITATION_STYLES, formatCitation } from 'react-cheminfo/core';
import { CiteButton, EcosystemButton } from 'react-cheminfo/ui';
import { cheminfoPrerender } from 'react-cheminfo/vite';Install the tarball on its own once — with no other dependency — and import
react-cheminfo/core and react-cheminfo/vite from it. Every peer is optional,
so npm installs none of them, and either import failing means a module that
should be framework-free is loading one at module scope.
Reinstall the tarball after every npm pack — npm caches it by name and
version, and this package stays at 0.0.0 until release-please cuts the first
one.