@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

vite-plugin-nucleus

Build, serve and preview a Nucleus Stack site with one Vite plugin: pages, Quark modules and the service worker are found by convention, and the preview answers as your host does.

Features

  • One line of config plugins: [nucleus()] covers vite, vite build and vite preview
  • Conventions, not entry lists Root *.html files are pages, *.ts files are Quark @use modules, the service worker is bundled
  • Host-true preview vite preview answers as Cloudflare Workers static assets do: _redirects, _headers, the 404 page
  • Kit from a CDN kit: "unpkg" loads the NucleusKit from unpkg in a deploy build instead of bundling it
  • CSS chain included @import / @import-glob, mixins, custom selectors and preset-env; nesting and light-dark() shipped as written
  • Prerender-ready Builds what nucleus-ssr prerenders, and serves it for browser checks

Installation

This package is available in the NucleusKit. Or it can be used by itself:

CDN Package Manager
npm install @excom/vite-plugin-nucleus
HTML Imports JS / CSS Imports
import { /* … */ } from "@excom/vite-plugin-nucleus";
Peer dependencies (1)

Packages a consumer must install alongside this one. Workspace deps are bundled.

Package Version
vite ^8.3.0
View Dist Files

All exports:

.
  • default: ./index.mjs
./host
  • default: ./host.mjs
./css
  • default: ./css.mjs

Usage

Add the plugin to vite.config.js; vite, vite build and vite preview need nothing else. It runs on Vite 8.3 and Node 24.13, or later.

// vite.config.js
import { nucleus } from "@excom/vite-plugin-nucleus";
​
export default {
  plugins: [nucleus()], // a deploy build: nucleus({ kit: "unpkg" })
};

Conventions

What the project holds decides what is built:

index.html                              a page, as every *.html at the root
shell.ts                                a Quark module, built to /shell.js
public/
  _redirects                            /shell /shell.js 200
  views/cart/cart.ts                    a Quark module, built to /views/cart/cart.js
  service-worker/service-worker.js      bundled into one classic script
  • Every *.ts at the root and under publicDir is a Quark module, built unhashed at its URL: keep other TypeScript in a folder of the root. Declarations, *.config.ts, *.test.ts and *.spec.ts are left alone
  • An extensionless module URL (@use "/shell") needs its 200 line in _redirects, in dev as on the host
  • The files the service worker imports are left out of the build unless a page or sheet names them. Dev sends Service-Worker-Allowed: / with the worker; on the host that is a line of your _headers
  • Two files that would answer at one URL stop the build

The kit

A page loads the kit from a module script: import "@excom/nucleus-kit/nucleus-kit.progressive";. That path resolves from the first NucleusKit release after 0.3.0; with 0.3.0 write …/nucleus-kit.progressive.min, which works in both modes.

  • kit: "bundled" (default) Vite bundles the kit like any dependency
  • kit: "unpkg" vite build loads it from unpkg at the installed version, so the kit must be installed in the app. Imports of @excom/nucleus-kit/<entry> in a script and of @excom/nucleus-kit/<name>.css in a stylesheet are rewritten; the bare @excom/nucleus-kit is not. The build stops when kit code would ship, or on a path the kit does not export

What the plugin sets

  • Set by the plugin The build's inputs and output file names, css.postcss, css.lightningcss.exclude (the minifier leaves light-dark() alone: Valence declares color-scheme in its own stylesheet, and a lowered light-dark() only works in a sheet that declares it itself) and, with kit: "unpkg", build.modulePreload: { polyfill: false }. A PostCSS config file is not read: add plugins in css.postcss.plugins
  • Yours publicDir, build.outDir, build.emptyOutDir (default true) and build.assetsDir
  • Not supported base: the site is served from /

Dev / preview

  • vite applies the 200 lines of _redirects and answers any other unknown route with index.html. The site is read once per start: a new page or module needs a restart
  • vite preview serves the build as Cloudflare Workers static assets do: _redirects, _headers (without Cache-Control and Strict-Transport-Security) and the assets options not_found_handling / html_handling of the nearest wrangler.jsonc or wrangler.json whose assets.directory is the build. What it does not emulate, it refuses with an error: a wrangler.toml, a Worker script (main), run_worker_first

Host / CSS entries

  • @excom/vite-plugin-nucleus/host serveSite({ root, port?, shell? }) serves a build as the preview does, on every network interface, and resolves { port, close() }: for browser checks. With shell, the file nucleus-ssr --save-shell wrote, every prerendered page answers with the untouched shell, the cold origin of nucleus-ssr's cold-render check. Also hostHandler, answerOf, hostOf, rulesOf, redirectsOf, headersOf
  • @excom/vite-plugin-nucleus/css cssConfig is the Vite css option the plugin sets, for another Vite config; transformCss(css, from) runs the same chain on one stylesheet

Credits

The host emulation holds portions ported from Cloudflare's workers-sdk: see THIRD-PARTY-NOTICES.md.

Release notes (1)

0.1.2

  • Fix colours set with light-dark() in a site build: every one was invalid, because the build rewrote the function into a form that only works in a stylesheet declaring color-scheme itself, which Valence does for the app. light-dark() now ships as written (Chrome 123, Firefox 120, Safari 17.5)
View Source

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.