@atelic-action/ui 0.15.0 → 0.16.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 +56 -8
- package/bin/atelic-check-access.ts +5 -0
- package/bin/atelic-check-links.ts +5 -0
- package/bin/atelic-check-wall.ts +5 -0
- package/bin/atelic-print-sheets.sh +69 -0
- package/bin/atelic-reader-link.ts +5 -0
- package/bin/atelic-seal-document.mjs +6 -0
- package/bin/atelic-serve-static.ts +5 -0
- package/bin/atelic-stamp-health.ts +5 -0
- package/dist/vite/artifact-config.d.ts +24 -0
- package/dist/vite/artifact-config.js +83 -0
- package/dist/vite/index.d.ts +7 -0
- package/dist/vite/index.js +7 -0
- package/package.json +28 -5
- package/src/artifact/Gate.tsx +23 -11
- package/src/scripts/build-gate-payload.mjs +175 -0
- package/src/scripts/check-access.ts +93 -0
- package/src/scripts/check-links.ts +104 -0
- package/src/scripts/check-wall.ts +357 -0
- package/src/scripts/reader-link.ts +108 -0
- package/src/scripts/serve-static.ts +47 -0
- package/src/scripts/site-config.ts +34 -0
- package/src/scripts/stamp-health.ts +29 -0
- package/src/vite/artifact-config.ts +105 -0
- package/src/vite/index.ts +12 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The package ships source, not a build. Its TSX and CSS arrive as written and com
|
|
|
10
10
|
bun add @atelic-action/ui
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Peer dependencies: `react` and `react-dom` at `^19.0.0`, and `lucide-react` at `^1.23.0`.
|
|
13
|
+
Peer dependencies: `react` and `react-dom` at `^19.0.0`, and `lucide-react` at `^1.23.0`. Three more are optional, needed only by a site whose `vite.config.ts` imports `@atelic-action/ui/vite`: `vite`, `@tanstack/react-start`, and `@vitejs/plugin-react`. The commands the package installs run under `bun`, with `node` for the sealing command.
|
|
14
14
|
|
|
15
15
|
## Vite Config
|
|
16
16
|
|
|
@@ -46,7 +46,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
46
46
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
47
47
|
| `@atelic-action/ui/components` | `NotFound`, the access gate's `GateLogin` and `Watermark` (with `readCookie`), and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
48
48
|
| `@atelic-action/ui/artifact` | `Writeup`, `Report`, `Gate`, `GradeChip`, `SignOff`, `ArtifactShell`, the `ArtifactConfig` types, `buildPageHead`, the reader helpers, `AppErrorBoundary`, `newTabProps`, and their prop types (see [The Artifact Documents](#the-artifact-documents)) |
|
|
49
|
-
| `@atelic-action/ui/artifact/readers` | The reader helpers alone, built, for what Node runs as shipped: a site's browser tests
|
|
49
|
+
| `@atelic-action/ui/artifact/readers` | The reader helpers alone, built, for what Node runs as shipped: a site's browser tests |
|
|
50
50
|
| `@atelic-action/ui/primitives` | `Button`, `Chip`, `Eyebrow`, `Lead`, `SectionHeading`, and their prop types (see [Primitives](#primitives)) |
|
|
51
51
|
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
52
52
|
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
@@ -55,7 +55,8 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
55
55
|
| `@atelic-action/ui/styles/primitives.css` | Styles for everything under `primitives`, the `.btn` classes included |
|
|
56
56
|
| `@atelic-action/ui/styles/gate.css` | Styles for `GateLogin` and `Watermark` |
|
|
57
57
|
| `@atelic-action/ui/styles/artifact.css` | Styles for everything under `artifact`: the writeup, the monthly page, the sealed document stage, the grade chip, and the sign off |
|
|
58
|
-
| `@atelic-action/ui/vite` | `privateChunks`, the Vite plugin that keeps private content in `/assets/doc/`, with `isPrivateContent` and `DOC_DIR` |
|
|
58
|
+
| `@atelic-action/ui/vite` | `artifactConfig`, the whole Vite configuration of an artifact site as one call, with `artifactPages`; and `privateChunks`, the Vite plugin that keeps private content in `/assets/doc/`, with `isPrivateContent` and `DOC_DIR` (see [The Artifact Vite Config](#the-artifact-vite-config)) |
|
|
59
|
+
| The `bin` commands | Eight commands a site's `package.json` calls by name, from `atelic-check-access` to `atelic-seal-document` (see [The Commands](#the-commands)) |
|
|
59
60
|
| `@atelic-action/ui/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
|
|
60
61
|
| `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
|
|
61
62
|
| `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
|
|
@@ -196,7 +197,7 @@ What it holds to, each with a test in `tst/gate/`:
|
|
|
196
197
|
|
|
197
198
|
What it does not do, by having no store: a link can be used more than once until it expires, and link requests are not rate limited. Rotating the secret ends every session at once. The `gate_email` and `gate_wm` cookies that feed the watermark are readable and unsigned on purpose: the watermark is drawn in the reader's own browser, so it deters and attributes nothing a reader set on removing it could not already remove.
|
|
198
199
|
|
|
199
|
-
This is one of the two corners of the package that ship compiled (the other is the Vite
|
|
200
|
+
This is one of the two corners of the package that ship compiled (the other is the Vite entry, under [The Artifact Vite Config](#the-artifact-vite-config)). Vercel runs a function's and a middleware's package imports as shipped and compiles nothing inside `node_modules`, so `bun run build` emits `src/gate/` to `dist/gate/`, and `prepack` runs it before every publish.
|
|
200
201
|
|
|
201
202
|
### The Screen and the Watermark
|
|
202
203
|
|
|
@@ -226,7 +227,7 @@ The two pieces a reader sees are ordinary components, in `@atelic-action/ui/comp
|
|
|
226
227
|
|
|
227
228
|
`@atelic-action/ui/artifact` is everything the artifact template shared across its cuts, so a fix reaches every business's documents with one version bump:
|
|
228
229
|
|
|
229
|
-
- **The documents:** `Writeup`, `Report`, and `Gate` (the sealed document, with its `GatePayload` type), plus the `GradeChip` and `SignOff` the first two share, and every content type they take (`WriteupProps`, `ReportProps`, `GateProps`, and the rest).
|
|
230
|
+
- **The documents:** `Writeup`, `Report`, and `Gate` (the sealed document, with its `GatePayload` type; a reader's address is the key, so a payload never lists it, and the component identifies a reader to HubSpot by the address they typed), plus the `GradeChip` and `SignOff` the first two share, and every content type they take (`WriteupProps`, `ReportProps`, `GateProps`, and the rest).
|
|
230
231
|
- **The shell:** `ArtifactShell`, the `.mkt` scope with the skip link, the dark header, the credit band, and the watermark around a page.
|
|
231
232
|
- **The contract and the head:** the `ArtifactConfig` types, and `buildPageHead` and `canonicalUrl` with their `PageMeta` type.
|
|
232
233
|
- **Who opened it:** `mintToken`, `sealReader`, `openReader`, `readerId`, `readerLink`, and `whoIsReading`. The same six are also built and exported alone as `@atelic-action/ui/artifact/readers`, because a Playwright spec and a script run under Node, which compiles nothing inside `node_modules`.
|
|
@@ -247,15 +248,62 @@ export function ArtifactShell(props: Omit<ArtifactShellProps, "currentPath">) {
|
|
|
247
248
|
|
|
248
249
|
The documents import no stylesheet of their own. A site imports `@atelic-action/ui/styles/artifact.css` in its base sheet after `gate.css` and before its `theme.css`, so every page carries the rules whichever route it is. The sheet holds two `@page` rules, the monthly page's and then the writeup's, and the later one wins for every page a site prints; the order inside the sheet is the order the template's build bundled them in and is load bearing.
|
|
249
250
|
|
|
250
|
-
|
|
251
|
+
### The Artifact Vite Config
|
|
252
|
+
|
|
253
|
+
`@atelic-action/ui/vite` holds the whole Vite configuration of an artifact site, so a site's `vite.config.ts` is one call that names its pages:
|
|
251
254
|
|
|
252
255
|
```ts
|
|
253
256
|
// vite.config.ts
|
|
254
|
-
import {
|
|
257
|
+
import { artifactConfig } from "@atelic-action/ui/vite";
|
|
258
|
+
|
|
259
|
+
export default artifactConfig({ pages: ["/writeup", "/proposal", "/report"] });
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`artifactConfig` returns the TanStack Start plugin with prerendering on, the crawler following links but kept out of `/images/` (where a prerendered page would overwrite a real file), one prerendered page for `/`, for each path the site names, and for `/404`, and no sitemap; the React plugin; `privateChunks()`; the `@` alias to the site's `src`, resolved from the directory Vite is run in; and `ssr.noExternal: ["@atelic-action/ui"]`. Nothing links to an artifact page, so a page a site leaves out of `pages` ships with no HTML in it. `artifactPages` is the list alone.
|
|
263
|
+
|
|
264
|
+
A site with a real need of its own passes `extend`, which is merged over the result with Vite's `mergeConfig`:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
export default artifactConfig({
|
|
268
|
+
pages: ["/writeup"],
|
|
269
|
+
extend: { server: { port: 5180 } },
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`privateChunks` is the plugin inside it, exported for a site that writes its own configuration: it writes every chunk carrying private content to `/assets/doc/`, where the access wall holds it, and fails the build when one lands anywhere else.
|
|
274
|
+
|
|
275
|
+
A `vite.config.ts` is run by Node as shipped, so `src/vite/` is built to `dist/vite/` like the gate. The entry imports `@tanstack/react-start` and `@vitejs/plugin-react` when it loads, so a site that imports anything from it has both installed; they are optional peers only because a site that never imports this entry needs neither.
|
|
276
|
+
|
|
277
|
+
### The Commands
|
|
255
278
|
|
|
256
|
-
|
|
279
|
+
The package installs the scripts every artifact site used to carry as files, so a site's `package.json` calls a command by name and holds no script of its own. Each reads the site it is run in from the working directory: `src/site.config.ts`, `public/`, and `dist/client` are the site's, found from where the command is run, which is the site's root.
|
|
280
|
+
|
|
281
|
+
| Command | What It Does | Where a Site Calls It |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `atelic-check-access` | Fails the build when `src/site.config.ts` says `access: "private"`, the build is on Vercel, and the wall's three variables are not set; warns on a local build of a private site | First step of `build` |
|
|
284
|
+
| `atelic-stamp-health` | Writes `dist/client/health.json` with the status, the commit, and the build time | Last step of `build` |
|
|
285
|
+
| `atelic-check-links` | Asks for every external link in the prerendered pages and exits 1 when one is dead | `check:links`, after a build |
|
|
286
|
+
| `atelic-check-wall <host> [path ...]` | Asks a deployed host for each document and held file with no cookie, and passes only when the wall answers every one and the sign in functions respond | `check:wall`, before any private link goes out |
|
|
287
|
+
| `atelic-reader-link <email> [path] [--token <token>] [--qr \| --no-qr]` | Mints a named reader: prints the sealed line for `analytics.identify` and the link to send, and writes `public/images/qr.png` for a public writeup's first reader | `reader` |
|
|
288
|
+
| `atelic-serve-static` | Serves `dist/client` on `127.0.0.1` the way Vercel does, on `PORT` or 4173 | The `webServer` command in `playwright.config.ts` |
|
|
289
|
+
| `atelic-print-sheets <url or dist/client> <out.pdf> [sheets] [page] [port]` | Prints a page to PDF with headless Chrome, reports the sheet count, and fails when a count is given and missed | `print` |
|
|
290
|
+
| `atelic-seal-document <config.json> [out.json]` | Seals a document into the payload `Gate` reads, one wrapped key for each reader. The payload names no one: no address, and readers numbered `r1`, `r2` whatever the config calls them | `seal` |
|
|
291
|
+
|
|
292
|
+
A site's scripts, whole:
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"build": "atelic-check-access && tsc -b && vite build && cp dist/client/404/index.html dist/client/404.html && atelic-stamp-health",
|
|
297
|
+
"check:links": "atelic-check-links",
|
|
298
|
+
"check:wall": "atelic-check-wall",
|
|
299
|
+
"reader": "atelic-reader-link",
|
|
300
|
+
"print": "atelic-print-sheets",
|
|
301
|
+
"seal": "atelic-seal-document"
|
|
302
|
+
}
|
|
257
303
|
```
|
|
258
304
|
|
|
305
|
+
The logic lives in `src/scripts/` as modules with their functions exported, which is what the tests import, and each file in `bin/` only reads the command line and calls one. The TypeScript commands open on `#!/usr/bin/env bun` and run as source, since `bun` runs TypeScript inside `node_modules`. The sealing command is plain `.mjs` under `#!/usr/bin/env node`, and the print command is `bash`; it needs Chrome (the `CHROME` variable names another binary), `python3`, and `curl`.
|
|
306
|
+
|
|
259
307
|
## Email
|
|
260
308
|
|
|
261
309
|
`@atelic-action/ui/email` is the runner email design as React components, ported one to one from the jq library the runners compose their mail from (homebase `runners/lib/email.jq`). The components are born inline: every layout is a table, every style is an inline style object, and there is no CSS file and no `className`, because Gmail strips a style block and ignores media queries on some accounts. The same components therefore mount on a web page as happily as they render into a mail client.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Print an artifact page to PDF with headless Chrome and report its sheet
|
|
3
|
+
# count. The writeup flows across as many letter pages as it needs, its
|
|
4
|
+
# collapsibles printed open, so its count is a record of the print and not a
|
|
5
|
+
# layout check. The monthly page is one sheet by construction, so pass 1 for
|
|
6
|
+
# it and the script fails when it prints as anything else.
|
|
7
|
+
#
|
|
8
|
+
# Usage: atelic-print-sheets <url | dist/client> <out.pdf> [expected sheets] [page] [port]
|
|
9
|
+
# atelic-print-sheets dist/client public/writeup.pdf
|
|
10
|
+
# atelic-print-sheets dist/client ../2026-10-01-report.pdf 1 /report
|
|
11
|
+
# atelic-print-sheets https://summit.atelic.me/writeup ../2026-09-10-writeup.pdf
|
|
12
|
+
# A site offers the same as `bun run print <...>`. Both paths are read from
|
|
13
|
+
# the directory the command is run in, which is the site's root.
|
|
14
|
+
#
|
|
15
|
+
# A directory is served on a local port for the print (the page loads its
|
|
16
|
+
# fonts and images by absolute path) and the server is stopped after; the
|
|
17
|
+
# page is the path printed from it, /writeup unless named. A URL is printed
|
|
18
|
+
# as given. The writeup's PDF belongs in public/ as writeup.pdf beside the
|
|
19
|
+
# page (on a private artifact the wall holds it like the page, see
|
|
20
|
+
# ACCESS-GATE.md), and a dated copy goes in the client folder of the
|
|
21
|
+
# practice repo.
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
TARGET=${1:?url or built directory required}
|
|
25
|
+
OUT=${2:?output pdf path required}
|
|
26
|
+
EXPECT=${3:-}
|
|
27
|
+
PAGE=${4:-/writeup}
|
|
28
|
+
PORT=${5:-8765}
|
|
29
|
+
CHROME=${CHROME:-"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"}
|
|
30
|
+
[[ -x "$CHROME" ]] || { echo "Chrome not found at $CHROME" >&2; exit 1; }
|
|
31
|
+
# Chromium refuses to start as root with its sandbox on, and a cloud run is root.
|
|
32
|
+
SANDBOX=()
|
|
33
|
+
[[ $(id -u) -eq 0 ]] && SANDBOX=(--no-sandbox)
|
|
34
|
+
|
|
35
|
+
URL="$TARGET"
|
|
36
|
+
if [[ -d "$TARGET" ]]; then
|
|
37
|
+
PAGE="/${PAGE#/}"
|
|
38
|
+
PAGE="${PAGE%/}"
|
|
39
|
+
[[ -f "$TARGET$PAGE/index.html" ]] || { echo "no $PAGE/index.html in $TARGET (run bun build first)" >&2; exit 1; }
|
|
40
|
+
if curl -s -o /dev/null "http://127.0.0.1:$PORT/"; then
|
|
41
|
+
echo "port $PORT is already in use" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$TARGET" >/dev/null 2>&1 &
|
|
45
|
+
SERVER=$!
|
|
46
|
+
trap 'kill $SERVER 2>/dev/null || true' EXIT
|
|
47
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
48
|
+
curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break
|
|
49
|
+
sleep 0.3
|
|
50
|
+
done
|
|
51
|
+
URL="http://127.0.0.1:$PORT$PAGE/"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
"$CHROME" --headless=new ${SANDBOX[@]+"${SANDBOX[@]}"} --disable-gpu --no-pdf-header-footer \
|
|
55
|
+
--virtual-time-budget=8000 --print-to-pdf="$OUT" "$URL" 2>/dev/null
|
|
56
|
+
[[ -s "$OUT" ]] || { echo "Chrome wrote no PDF for $URL" >&2; exit 1; }
|
|
57
|
+
|
|
58
|
+
PAGES=$(python3 - "$OUT" <<'PY'
|
|
59
|
+
import re, sys
|
|
60
|
+
data = open(sys.argv[1], "rb").read()
|
|
61
|
+
m = re.findall(rb"/Type\s*/Pages\b[^>]*?/Count\s+(\d+)", data)
|
|
62
|
+
print(max(int(x) for x in m) if m else 0)
|
|
63
|
+
PY
|
|
64
|
+
)
|
|
65
|
+
echo "printed $OUT: $PAGES sheets, $(wc -c <"$OUT" | tr -d ' ') bytes"
|
|
66
|
+
if [[ -n "$EXPECT" && "$PAGES" != "$EXPECT" ]]; then
|
|
67
|
+
echo "expected $EXPECT sheets; a sheet overflowed or one is missing" >&2
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type UserConfig } from "vite";
|
|
2
|
+
export interface ArtifactConfigOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The path of each artifact page the site carries, such as "/writeup".
|
|
5
|
+
* The root and the 404 page are always prerendered and need no entry.
|
|
6
|
+
*/
|
|
7
|
+
pages: readonly string[];
|
|
8
|
+
/**
|
|
9
|
+
* The escape hatch for a site with a real need of its own: merged over
|
|
10
|
+
* the result with Vite's `mergeConfig`, so a site adds a setting without
|
|
11
|
+
* going back to a configuration written by hand.
|
|
12
|
+
*/
|
|
13
|
+
extend?: UserConfig;
|
|
14
|
+
}
|
|
15
|
+
/** One entry of the prerender list. */
|
|
16
|
+
export interface ArtifactPage {
|
|
17
|
+
path: string;
|
|
18
|
+
prerender: {
|
|
19
|
+
enabled: true;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** The prerender list for a site: the root, each page it names, then the 404. */
|
|
23
|
+
export declare function artifactPages(pages: readonly string[]): ArtifactPage[];
|
|
24
|
+
export declare function artifactConfig({ pages, extend }: ArtifactConfigOptions): UserConfig;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole Vite configuration of an artifact site, as one call. Every site
|
|
3
|
+
* cut from the artifact template carried its own copy of this configuration;
|
|
4
|
+
* here it is written once, and a site's vite.config.ts names its pages:
|
|
5
|
+
*
|
|
6
|
+
* import { artifactConfig } from "@atelic-action/ui/vite";
|
|
7
|
+
*
|
|
8
|
+
* export default artifactConfig({ pages: ["/writeup", "/proposal", "/report"] });
|
|
9
|
+
*
|
|
10
|
+
* The site's own paths (the `@` alias to its src) are found from the
|
|
11
|
+
* directory Vite is run in, which is the site's root.
|
|
12
|
+
*/
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|
15
|
+
import react from "@vitejs/plugin-react";
|
|
16
|
+
import { mergeConfig } from "vite";
|
|
17
|
+
import { privateChunks } from "./private-chunks.js";
|
|
18
|
+
/** The prerender list for a site: the root, each page it names, then the 404. */
|
|
19
|
+
export function artifactPages(pages) {
|
|
20
|
+
const named = pages.filter((path) => path !== "/" && path !== "/404");
|
|
21
|
+
return [
|
|
22
|
+
// The root holds no document: it sends the reader to /writeup
|
|
23
|
+
// (vercel.json does the same on the host), and its prerendered
|
|
24
|
+
// index.html is what a static preview serves at the bare host.
|
|
25
|
+
{ path: "/", prerender: { enabled: true } },
|
|
26
|
+
...named.map((path) => ({ path, prerender: { enabled: true } })),
|
|
27
|
+
// The 404 page, rendered by the catch all route and copied to
|
|
28
|
+
// dist/client/404.html by the build for the host to serve on any miss.
|
|
29
|
+
{ path: "/404", prerender: { enabled: true } },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
export function artifactConfig({ pages, extend }) {
|
|
33
|
+
const config = {
|
|
34
|
+
// Every stylesheet rides in the one base sheet the root route links
|
|
35
|
+
// (src/styles/base.css), the documents' own included, since they come from
|
|
36
|
+
// @atelic-action/ui. Nothing imports CSS from a component, so there is no
|
|
37
|
+
// component bundle to fold: `build.cssCodeSplit: false`, which sat here to
|
|
38
|
+
// fold it, now fails the build, because TanStack Start looks for the
|
|
39
|
+
// `style.css` that setting emits and none is written. A site that gives a
|
|
40
|
+
// component its own stylesheet again restores the setting through `extend`.
|
|
41
|
+
plugins: [
|
|
42
|
+
tanstackStart({
|
|
43
|
+
prerender: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
crawlLinks: true,
|
|
46
|
+
// The writeup links each screenshot crop to its full image, and
|
|
47
|
+
// the crawler follows every href it finds: without this filter it
|
|
48
|
+
// prerenders /images/<shot>.png as an HTML page and writes that
|
|
49
|
+
// over the real file in dist/client (found on a client's writeup,
|
|
50
|
+
// 2026-09-24).
|
|
51
|
+
filter: ({ path }) => !path.startsWith("/images/"),
|
|
52
|
+
},
|
|
53
|
+
// Nothing links to an artifact page, so the crawler would miss every
|
|
54
|
+
// one of them; each is listed by the site to be prerendered at all. A
|
|
55
|
+
// site names the pages it uses and no others.
|
|
56
|
+
pages: artifactPages(pages),
|
|
57
|
+
// No sitemap. A sitemap is a page asking to be crawled, and every
|
|
58
|
+
// page here is a document prepared for one business; public/robots.txt
|
|
59
|
+
// disallows the whole host to match.
|
|
60
|
+
sitemap: { enabled: false },
|
|
61
|
+
}),
|
|
62
|
+
react(),
|
|
63
|
+
// A document's content builds into /assets/doc/, which the access wall
|
|
64
|
+
// holds back, and the build fails if any lands where the login page
|
|
65
|
+
// can load it (privateChunks beside this file, needsSession in
|
|
66
|
+
// @atelic-action/ui/gate).
|
|
67
|
+
privateChunks(),
|
|
68
|
+
],
|
|
69
|
+
resolve: {
|
|
70
|
+
alias: {
|
|
71
|
+
// The site's src, from the directory Vite is run in: this file lives
|
|
72
|
+
// in the package, so nothing here can be found relative to it.
|
|
73
|
+
"@": resolve(process.cwd(), "./src"),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
// @atelic-action/ui ships TSX and CSS as written, so the prerender's server build
|
|
77
|
+
// has to compile it rather than hand it to Node as an external.
|
|
78
|
+
ssr: {
|
|
79
|
+
noExternal: ["@atelic-action/ui"],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
return extend ? mergeConfig(config, extend) : config;
|
|
83
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a site's vite.config.ts imports (`@atelic-action/ui/vite`): the
|
|
3
|
+
* configuration of an artifact site as one call, and the plugin inside it
|
|
4
|
+
* for a site that writes its own.
|
|
5
|
+
*/
|
|
6
|
+
export { type ArtifactConfigOptions, type ArtifactPage, artifactConfig, artifactPages, } from "./artifact-config.js";
|
|
7
|
+
export { DOC_DIR, isPrivateContent, privateChunks } from "./private-chunks.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a site's vite.config.ts imports (`@atelic-action/ui/vite`): the
|
|
3
|
+
* configuration of an artifact site as one call, and the plugin inside it
|
|
4
|
+
* for a site that writes its own.
|
|
5
|
+
*/
|
|
6
|
+
export { artifactConfig, artifactPages, } from "./artifact-config.js";
|
|
7
|
+
export { DOC_DIR, isPrivateContent, privateChunks } from "./private-chunks.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, and the access gate they install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -13,8 +13,19 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
|
-
"dist"
|
|
16
|
+
"dist",
|
|
17
|
+
"bin"
|
|
17
18
|
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"atelic-check-access": "./bin/atelic-check-access.ts",
|
|
21
|
+
"atelic-check-links": "./bin/atelic-check-links.ts",
|
|
22
|
+
"atelic-check-wall": "./bin/atelic-check-wall.ts",
|
|
23
|
+
"atelic-print-sheets": "./bin/atelic-print-sheets.sh",
|
|
24
|
+
"atelic-reader-link": "./bin/atelic-reader-link.ts",
|
|
25
|
+
"atelic-seal-document": "./bin/atelic-seal-document.mjs",
|
|
26
|
+
"atelic-serve-static": "./bin/atelic-serve-static.ts",
|
|
27
|
+
"atelic-stamp-health": "./bin/atelic-stamp-health.ts"
|
|
28
|
+
},
|
|
18
29
|
"sideEffects": [
|
|
19
30
|
"**/*.css"
|
|
20
31
|
],
|
|
@@ -56,8 +67,8 @@
|
|
|
56
67
|
"default": "./dist/gate/verify.js"
|
|
57
68
|
},
|
|
58
69
|
"./vite": {
|
|
59
|
-
"types": "./dist/vite/
|
|
60
|
-
"default": "./dist/vite/
|
|
70
|
+
"types": "./dist/vite/index.d.ts",
|
|
71
|
+
"default": "./dist/vite/index.js"
|
|
61
72
|
},
|
|
62
73
|
"./package.json": "./package.json"
|
|
63
74
|
},
|
|
@@ -73,24 +84,36 @@
|
|
|
73
84
|
"gallery:build": "vite build gallery"
|
|
74
85
|
},
|
|
75
86
|
"dependencies": {
|
|
76
|
-
"jose": "^6.2.12"
|
|
87
|
+
"jose": "^6.2.12",
|
|
88
|
+
"qrcode": "^1.5.4"
|
|
77
89
|
},
|
|
78
90
|
"peerDependencies": {
|
|
91
|
+
"@tanstack/react-start": "^1.168.0",
|
|
92
|
+
"@vitejs/plugin-react": "^5.0.0 || ^6.0.0",
|
|
79
93
|
"lucide-react": "^1.23.0",
|
|
80
94
|
"react": "^19.0.0",
|
|
81
95
|
"react-dom": "^19.0.0",
|
|
82
96
|
"vite": "^7.0.0 || ^8.0.0"
|
|
83
97
|
},
|
|
84
98
|
"peerDependenciesMeta": {
|
|
99
|
+
"@tanstack/react-start": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"@vitejs/plugin-react": {
|
|
103
|
+
"optional": true
|
|
104
|
+
},
|
|
85
105
|
"vite": {
|
|
86
106
|
"optional": true
|
|
87
107
|
}
|
|
88
108
|
},
|
|
89
109
|
"devDependencies": {
|
|
90
110
|
"@biomejs/biome": "^2.5.13",
|
|
111
|
+
"@tanstack/react-start": "^1.168.60",
|
|
91
112
|
"@testing-library/jest-dom": "^6.6.2",
|
|
92
113
|
"@testing-library/react": "^16.0.1",
|
|
114
|
+
"@types/bun": "^1.4.2",
|
|
93
115
|
"@types/node": "^24.0.0",
|
|
116
|
+
"@types/qrcode": "^1.5.6",
|
|
94
117
|
"@types/react": "^19.0.0",
|
|
95
118
|
"@types/react-dom": "^19.0.0",
|
|
96
119
|
"@vitejs/plugin-react": "^6.1.1",
|
package/src/artifact/Gate.tsx
CHANGED
|
@@ -19,10 +19,13 @@ export interface GatePerson {
|
|
|
19
19
|
/** Stable handle for the reader, used in the payload and in `onUnlock`. */
|
|
20
20
|
id: string;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Never read, and a payload sealed today does not carry it. The reader's
|
|
23
|
+
* address is the secret their key derives from, so a payload that lists
|
|
24
|
+
* it hands the key to anyone who reads the page's script; payloads sealed
|
|
25
|
+
* before 2026-10-08 did. The address a reader types is the one this
|
|
26
|
+
* component uses.
|
|
24
27
|
*/
|
|
25
|
-
email
|
|
28
|
+
email?: string;
|
|
26
29
|
/** Base64: this reader's PBKDF2 salt, GCM nonce, and wrapped content key. */
|
|
27
30
|
salt: string;
|
|
28
31
|
iv: string;
|
|
@@ -83,6 +86,8 @@ function fromBase64(value: string): Uint8Array {
|
|
|
83
86
|
|
|
84
87
|
interface Opened {
|
|
85
88
|
person: GatePerson;
|
|
89
|
+
/** The address that opened the document, as the reader gave it. */
|
|
90
|
+
email: string;
|
|
86
91
|
html: string;
|
|
87
92
|
}
|
|
88
93
|
|
|
@@ -137,7 +142,7 @@ async function openDocument(payload: GatePayload, rawEmail: string): Promise<Ope
|
|
|
137
142
|
contentKey,
|
|
138
143
|
fromBase64(payload.ct) as BufferSource,
|
|
139
144
|
);
|
|
140
|
-
return { person, html: new TextDecoder().decode(plain) };
|
|
145
|
+
return { person, email: rawEmail, html: new TextDecoder().decode(plain) };
|
|
141
146
|
} catch {
|
|
142
147
|
// Wrong email for this reader. Try the next one; say nothing.
|
|
143
148
|
}
|
|
@@ -145,11 +150,18 @@ async function openDocument(payload: GatePayload, rawEmail: string): Promise<Ope
|
|
|
145
150
|
return null;
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Tells HubSpot who opened the document, from the address the reader gave:
|
|
155
|
+
* the payload names no one. The key forgives what the reader types (case,
|
|
156
|
+
* spaces, even a missing at sign), so the address is sent only when it reads
|
|
157
|
+
* as one, and only on the domain the site names.
|
|
158
|
+
*/
|
|
159
|
+
function identifyReader(rawEmail: string, domain: string | undefined) {
|
|
160
|
+
const email = rawEmail.trim().toLowerCase();
|
|
161
|
+
if (!domain || !/^[^\s@]+@[^\s@]+$/.test(email)) return;
|
|
162
|
+
if (!email.endsWith(`@${domain.toLowerCase()}`)) return;
|
|
151
163
|
window._hsq = window._hsq || [];
|
|
152
|
-
window._hsq.push(["identify", { email
|
|
164
|
+
window._hsq.push(["identify", { email }]);
|
|
153
165
|
window._hsq.push(["setPath", window.location.pathname]);
|
|
154
166
|
window._hsq.push(["trackPageView"]);
|
|
155
167
|
}
|
|
@@ -238,7 +250,7 @@ export function Gate({
|
|
|
238
250
|
if (cancelled) return;
|
|
239
251
|
if (opened) {
|
|
240
252
|
writeStored(storageKey, fromQuery);
|
|
241
|
-
identifyReader(opened.
|
|
253
|
+
identifyReader(opened.email, identifyDomain);
|
|
242
254
|
setHtml(opened.html);
|
|
243
255
|
onUnlockRef.current?.(opened.person);
|
|
244
256
|
return;
|
|
@@ -252,7 +264,7 @@ export function Gate({
|
|
|
252
264
|
if (!stored) return;
|
|
253
265
|
const opened = await openDocument(payload, stored);
|
|
254
266
|
if (cancelled || !opened) return;
|
|
255
|
-
identifyReader(opened.
|
|
267
|
+
identifyReader(opened.email, identifyDomain);
|
|
256
268
|
setHtml(opened.html);
|
|
257
269
|
onUnlockRef.current?.(opened.person);
|
|
258
270
|
}
|
|
@@ -275,7 +287,7 @@ export function Gate({
|
|
|
275
287
|
return;
|
|
276
288
|
}
|
|
277
289
|
writeStored(storageKey, value);
|
|
278
|
-
identifyReader(opened.
|
|
290
|
+
identifyReader(opened.email, identifyDomain);
|
|
279
291
|
setHtml(opened.html);
|
|
280
292
|
onUnlockRef.current?.(opened.person);
|
|
281
293
|
}
|