@smi-digital/web-kit
v1.5.0
Published
Shared Astro and React components for SMI websites.
Readme
@smi-digital/web-kit
Shared Astro and React components for SMI websites.
The package exists so a fix lands once instead of six times. Before it, the
same problems were solved repeatedly and independently across the fleet: a
working responsive-image helper lived in one component of seven, unicode-range
was applied correctly to one font and not to the one forty-five times larger.
The knowledge was never missing — it just was not the default.
Install
npm i @smi-digital/web-kitThen add one line to the consuming site's astro.config.mjs:
export default defineConfig({
vite: { ssr: { noExternal: ["@smi-digital/web-kit"] } },
});This is required, not optional. .astro files cannot be pre-compiled for
distribution — the consumer's Astro compiles them — so this package ships
source rather than build output. Without noExternal, Vite externalises the
package during SSR and hands raw .astro to Node, which fails at runtime.
Usage
---
import Icon from "@smi-digital/web-kit/Icon.astro";
---
<Icon name="arrow_right_alt" class={styles.arrow} />Inside a React island:
import Icon from "@smi-digital/web-kit/Icon";
<Icon name="chevron_left" className={styles.chevron} />;Imports are explicit subpaths rather than a barrel file — a .ts barrel cannot
cleanly re-export Astro components, and the .astro in the specifier tells you
at the import site what kind of thing you are getting.
Components
CmsImage
A Strapi image as <picture> — AVIF offered first, WebP (or the original)
catching everything else.
<CmsImage
media={data.heroImage}
sizes="(max-width: 767px) 100vw, 50vw"
baseUrl={import.meta.env.PUBLIC_STRAPI_URL}
priority
class={styles.hero}
/>| Prop | |
| ---------- | ------------------------------------------------------------- |
| media | The Strapi media object, never a URL string |
| sizes | Required. How wide the box will be in your layout |
| alt | Defaults to media.alternativeText; pass "" for decorative |
| priority | Marks the page's LCP element — eager + fetchpriority="high" |
| baseUrl | Origin for Strapi-relative URLs |
It takes the object, not a URL. Given a URL, a caller has already chosen a
derivative — and the one they reach for is formats.large, regardless of how
big the image is actually displayed. Demanding the object makes that mistake
impossible to express.
sizes is required because the browser picks a candidate before it has
laid the page out, so it cannot measure the box itself. Omit it and the browser
assumes 100vw and takes the largest file, silently defeating the srcset. It
describes your CSS layout, not the image, so only the person building that
section can supply it.
priority on at most one image per page. Prioritising several prioritises
none. One prop rather than three separate attributes so it is greppable — you
can count them per page.
Formats are grouped by the mime on each derivative, not by a key-naming
convention, so the Strapi upload pipeline can name its format keys however it
likes.
Two consequences of how <picture> actually selects, both load-bearing:
- A browser commits to the first
<source>whose type it supports and then selects within it — it never falls through to a later source. So every format must carry a complete set of widths. A partial AVIF ladder would hand an AVIF-capable phone the smallest AVIF available, potentially a desktop-sized file, rather than a small WebP. - For the same reason the most broadly-supported format goes on the
<img>itself rather than in a<source>.
Media with no derivatives — SVG, which Strapi does not resize — renders as a
plain <img> with no <picture> wrapper. An empty CMS slot renders nothing.
Icon
Inline SVG replacement for an icon webfont.
name is a Material Symbols ligature name — the same string editors type into
Strapi's icon_MSO field, so CMS content needs no migration. Unknown names
render nothing (with a dev warning) rather than the literal ligature text an
icon font shows while it loads.
The svg is 1em square and filled with currentColor, so it inherits whatever
font-size and color the caller's class sets. That makes it a drop-in for
<span class="icon">arrow_right_alt</span> markup with no CSS changes.
Adding an icon: copy the d attribute from
google/material-design-icons at
symbols/web/<name>/materialsymbolsoutlined/<name>_24px.svg into
src/components/Icon/icons.ts, keeping the list alphabetical.
Note that the icon map is one object, so bundlers cannot tree-shake per icon — a site ships the whole set. That is deliberate: CMS-authored names are only known at runtime, so the lookup must be complete. At ~15 icons it is ~4 KB. If the set passes roughly fifty entries, revisit whether it should be split.
What belongs in this package
Three questions, and all three must be yes:
- Does it render the same markup on every site?
- Is all its styling supplied by the caller?
- Would a bug in it be a bug on all six sites?
Icon passes — it renders one <svg> and takes class straight through. A
Button fails the first question and would become a prop explosion across six
client designs. Components with design opinions stay in the sites.
Development
npm run check # format, lint, typecheck, test — what CI runs
npm run test:watchastro.config.mjs exists only so astro check and the container API used by
the tests have a project to work against. It is not published; files in
package.json ships src alone.
Tests render real .astro components through Astro's container API rather than
hand-written fixtures, so a renamed class breaks the test that depends on it.
Releasing
Pushes to main run semantic-release, which derives the version and changelog
from the commit messages — there is no manual version bump.
| Commit prefix | Release |
| ---------------------------- | ------- |
| fix: | patch |
| feat: | minor |
| feat!: / BREAKING CHANGE | major |
| docs: chore: test: … | none |
.releaserc.json pins the conventionalcommits preset explicitly.
semantic-release defaults to the Angular preset, which is not what
@commitlint/config-conventional enforces on the way in — leaving it on the
default makes the two ends of the pipeline disagree.
A release reaches six client sites at once, so feat!: deserves deliberation.
Renovate is configured to automerge patch and minor and to open a PR for majors.
Publishing uses no token
There is no NPM_TOKEN secret. @semantic-release/npm 13 requests a GitHub
OIDC token and exchanges it with the npm registry for short-lived publish
credentials — npm's Trusted Publishing.
Nothing long-lived is stored anywhere.
The exchange happens in JavaScript inside the plugin, not in the npm CLI, so the runner's bundled npm version is irrelevant. Two things must hold:
- The workflow grants
id-token: write— it does. - npmjs.com has a trusted publisher configured for this package pointing
at
SMI-Digital/web-kitand the workflow filerelease.yml.
Set that up under the package's Settings → Trusted Publisher on npmjs.com.
Bootstrap note: a trusted publisher is configured on a package, so the
package generally has to exist first. If npm will not let you pre-configure it
for a name that has never been published, do the first publish once from a
local npm login session:
npm publish --access publicthen add the trusted publisher and let every subsequent release run tokenless from CI. That keeps the "no secrets in CI" property intact — the one-off credential is your interactive login, not something stored in the repo.
Optional hardening once the first publish is green: add "provenance": true to
publishConfig. Trusted publishing already provides the OIDC identity it needs.
