@astralarium/repo-config-vitest 0.1.0 → 0.2.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 CHANGED
@@ -33,3 +33,19 @@ export default defineConfig({
33
33
  },
34
34
  });
35
35
  ```
36
+
37
+ ```ts
38
+ testPages(pages, [
39
+ expectHead(
40
+ { title: "About - App", property: { "og:url": "/about" } },
41
+ { origin },
42
+ ),
43
+ ]);
44
+ ```
45
+
46
+ ```ts
47
+ // ./start: client-bundle view of a Start module graph
48
+ const { code, modules, imports } = await libraryBuild(entry, {
49
+ plugins: startClientCompiler(),
50
+ });
51
+ ```
@@ -0,0 +1,38 @@
1
+ import type { PageCheck } from "./types.ts";
2
+ /** A page's head metadata, character references decoded. */
3
+ export interface PageHead {
4
+ /** First `<title>` in `<head>`. */
5
+ title?: string;
6
+ /** `<meta name>` contents in `<head>` by lowercase name (HTML matches
7
+ * names case-insensitively); the first tag wins. */
8
+ name: Record<string, string>;
9
+ /** `<meta property>` contents in `<head>` by lowercase property
10
+ * (OpenGraph); the first tag wins. */
11
+ property: Record<string, string>;
12
+ /** Target of the first `<meta http-equiv="refresh">`, anywhere in the
13
+ * document. */
14
+ refresh?: string;
15
+ }
16
+ /** The head fields an `expectHead` check asserts; each named field must
17
+ * match, `undefined` asserting absence. */
18
+ export interface HeadExpectation {
19
+ title?: string;
20
+ /** `<meta name>` contents by name, any case. */
21
+ name?: Record<string, string | undefined>;
22
+ /** `<meta property>` contents by property, any case. */
23
+ property?: Record<string, string | undefined>;
24
+ refresh?: string;
25
+ }
26
+ /** `headOf` settings. */
27
+ export interface HeadOptions {
28
+ /** Origin the page serves from: same-origin absolute URLs in `property`
29
+ * contents and the `refresh` target read origin-relative. */
30
+ origin?: string;
31
+ }
32
+ /** `html`'s head metadata (`PageHead`). */
33
+ export declare function headOf(html: string, { origin }?: HeadOptions): PageHead;
34
+ /** The page's head carries each field `expected` names (`headOf`). */
35
+ export declare function expectHead(expected: HeadExpectation, options?: HeadOptions): PageCheck;
36
+ /** `url` resolved against `origin`: its path, query and hash when it
37
+ * shares `origin`, else absolute. */
38
+ export declare function originRelative(origin: string, url: string): string;
@@ -0,0 +1,111 @@
1
+ import { expect } from "vitest";
2
+ /** Every `<meta>` start tag of `html`, as attributes; a `>` inside a
3
+ * quoted value stays in the tag. */
4
+ function metaTags(html) {
5
+ return Array.from(html.matchAll(/<meta\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi), ([, attrs]) => attributes(attrs ?? ""));
6
+ }
7
+ /** `html`'s head metadata (`PageHead`). */
8
+ export function headOf(html, { origin } = {}) {
9
+ const head = html.split(/<\/head>/i)[0] ?? html;
10
+ const name = {};
11
+ const property = {};
12
+ for (const attrs of metaTags(head)) {
13
+ if (attrs.content === undefined)
14
+ continue;
15
+ const content = decodeEntities(attrs.content);
16
+ if (attrs.name !== undefined)
17
+ name[attrs.name.toLowerCase()] ??= content;
18
+ if (attrs.property !== undefined)
19
+ property[attrs.property.toLowerCase()] ??=
20
+ origin && /^https?:\/\//i.test(content)
21
+ ? originRelative(origin, content)
22
+ : content;
23
+ }
24
+ const result = { name, property };
25
+ const title = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1];
26
+ if (title !== undefined)
27
+ result.title = decodeEntities(title);
28
+ const refresh = metaTags(html).find((attrs) => attrs["http-equiv"]?.toLowerCase() === "refresh")?.content;
29
+ const target = refresh === undefined ? undefined : refreshUrl(decodeEntities(refresh));
30
+ if (target)
31
+ result.refresh = origin ? originRelative(origin, target) : target;
32
+ return result;
33
+ }
34
+ /** The page's head carries each field `expected` names (`headOf`). */
35
+ export function expectHead(expected, options = {}) {
36
+ return {
37
+ name: "carries the expected head metadata",
38
+ check: ({ html }) => {
39
+ const head = headOf(html, options);
40
+ const actual = {};
41
+ if ("title" in expected)
42
+ actual.title = head.title;
43
+ if ("refresh" in expected)
44
+ actual.refresh = head.refresh;
45
+ if (expected.name)
46
+ actual.name = Object.fromEntries(Object.keys(expected.name).map((key) => [
47
+ key,
48
+ head.name[key.toLowerCase()],
49
+ ]));
50
+ if (expected.property)
51
+ actual.property = Object.fromEntries(Object.keys(expected.property).map((key) => [
52
+ key,
53
+ head.property[key.toLowerCase()],
54
+ ]));
55
+ expect(actual).toEqual(expected);
56
+ },
57
+ };
58
+ }
59
+ /** `url` resolved against `origin`: its path, query and hash when it
60
+ * shares `origin`, else absolute. */
61
+ export function originRelative(origin, url) {
62
+ const resolved = new URL(url, origin);
63
+ return resolved.origin === new URL(origin).origin
64
+ ? `${resolved.pathname}${resolved.search}${resolved.hash}`
65
+ : resolved.href;
66
+ }
67
+ /** The URL of a refresh `content` (`0; url=…`), its quotes stripped. */
68
+ function refreshUrl(content) {
69
+ const url = /url\s*=\s*(.*)$/i.exec(content)?.[1]?.trim();
70
+ const quote = url?.[0];
71
+ if (url === undefined || (quote !== '"' && quote !== "'"))
72
+ return url;
73
+ const end = url.indexOf(quote, 1);
74
+ return url.slice(1, end === -1 ? undefined : end);
75
+ }
76
+ /** One attribute of a start tag: name, then a double-quoted,
77
+ * single-quoted or unquoted value, or none. */
78
+ const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
79
+ /** Attributes of a start tag's attribute text, by lowercase name; the first
80
+ * of a name wins, and a valueless attribute reads `""`. */
81
+ function attributes(text) {
82
+ const attrs = {};
83
+ for (const [, name, double, single, unquoted] of text.matchAll(ATTRIBUTE))
84
+ attrs[name.toLowerCase()] ??= double ?? single ?? unquoted ?? "";
85
+ return attrs;
86
+ }
87
+ /** Named character references `decodeEntities` resolves. */
88
+ const NAMED_ENTITIES = {
89
+ amp: "&",
90
+ lt: "<",
91
+ gt: ">",
92
+ quot: '"',
93
+ apos: "'",
94
+ nbsp: " ",
95
+ };
96
+ /** Highest Unicode code point. */
97
+ const MAX_CODE_POINT = 0x10ffff;
98
+ /** One pass of HTML character reference decoding: numeric references to
99
+ * code points and `NAMED_ENTITIES`; others stay as written. */
100
+ function decodeEntities(text) {
101
+ return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (reference, body) => {
102
+ if (!body.startsWith("#"))
103
+ return NAMED_ENTITIES[body.toLowerCase()] ?? reference;
104
+ const codePoint = /^#x/i.test(body)
105
+ ? parseInt(body.slice(2), 16)
106
+ : parseInt(body.slice(1), 10);
107
+ return codePoint <= MAX_CODE_POINT
108
+ ? String.fromCodePoint(codePoint)
109
+ : reference;
110
+ });
111
+ }
@@ -1,7 +1,9 @@
1
- /** Served-page invariants: the runner, and checks of server rendering. */
1
+ /** Served-page invariants: the runner, and checks of server rendering
2
+ * and head metadata. */
2
3
  export * from "./checks.ts";
3
4
  export * from "./classes.ts";
4
5
  export { dehydratedContext, type DehydratedContextOptions, type DehydratedMatch, type DehydratedRouter, dehydratedRouter, } from "./dehydrated.ts";
6
+ export * from "./head.ts";
5
7
  export * from "./runner.ts";
6
8
  export * from "./types.ts";
7
9
  export * from "./viewer.ts";
@@ -1,7 +1,9 @@
1
- /** Served-page invariants: the runner, and checks of server rendering. */
1
+ /** Served-page invariants: the runner, and checks of server rendering
2
+ * and head metadata. */
2
3
  export * from "./checks.js";
3
4
  export * from "./classes.js";
4
5
  export { dehydratedContext, dehydratedRouter, } from "./dehydrated.js";
6
+ export * from "./head.js";
5
7
  export * from "./runner.js";
6
8
  export * from "./types.js";
7
9
  export * from "./viewer.js";
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from "vite";
2
+ /** Start's compiler for the client environment, as an app's
3
+ * `tanstackStart()` configures it. */
4
+ export declare function startClientCompiler(): Plugin[];
@@ -0,0 +1,16 @@
1
+ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
2
+ /** Names of the client environment's compiler plugins in `tanstackStart()`. */
3
+ const CLIENT_COMPILER = new Set([
4
+ "tanstack-start-core::server-fn:client",
5
+ "tanstack-start-core:compiler-virtual-module",
6
+ ]);
7
+ /** Start's compiler for the client environment, as an app's
8
+ * `tanstackStart()` configures it. */
9
+ export function startClientCompiler() {
10
+ return tanstackStart()
11
+ .flat(Infinity)
12
+ .filter((plugin) => typeof plugin === "object" &&
13
+ plugin !== null &&
14
+ "name" in plugin &&
15
+ CLIENT_COMPILER.has(plugin.name));
16
+ }
@@ -0,0 +1,3 @@
1
+ /** Vite builds of a module graph, as a Start app's client bundle takes it. */
2
+ export * from "./client-compiler.ts";
3
+ export * from "./library-build.ts";
@@ -0,0 +1,3 @@
1
+ /** Vite builds of a module graph, as a Start app's client bundle takes it. */
2
+ export * from "./client-compiler.js";
3
+ export * from "./library-build.js";
@@ -0,0 +1,20 @@
1
+ import { type Plugin } from "vite";
2
+ /** What `libraryBuild` builds with. */
3
+ export interface LibraryBuildOptions {
4
+ /** Plugins the build runs, as an app's config adds them. */
5
+ plugins?: Plugin[];
6
+ /** Compile-time replacements, e.g. the dev build flag. */
7
+ define?: Record<string, string>;
8
+ }
9
+ /** A library build's output. */
10
+ export interface LibraryBuild {
11
+ /** The chunks' code, joined. */
12
+ code: string;
13
+ /** Files the chunks bundle. */
14
+ modules: string[];
15
+ /** Bare specifiers the chunks import. */
16
+ imports: string[];
17
+ }
18
+ /** The ES build of the module at `entry`, a file path: unminified,
19
+ * comment-free, bare imports external. */
20
+ export declare function libraryBuild(entry: string, { plugins, define }?: LibraryBuildOptions): Promise<LibraryBuild>;
@@ -0,0 +1,26 @@
1
+ import { build } from "vite";
2
+ /** The ES build of the module at `entry`, a file path: unminified,
3
+ * comment-free, bare imports external. */
4
+ export async function libraryBuild(entry, { plugins = [], define } = {}) {
5
+ const [output] = (await build({
6
+ configFile: false,
7
+ logLevel: "silent",
8
+ plugins,
9
+ define,
10
+ build: {
11
+ write: false,
12
+ minify: false,
13
+ lib: { entry, formats: ["es"] },
14
+ rolldownOptions: {
15
+ external: (id) => !id.startsWith(".") && !id.startsWith("/"),
16
+ output: { comments: false },
17
+ },
18
+ },
19
+ }));
20
+ const chunks = output.output.flatMap((chunk) => chunk.type === "chunk" ? [chunk] : []);
21
+ return {
22
+ code: chunks.map((chunk) => chunk.code).join("\n"),
23
+ modules: chunks.flatMap((chunk) => chunk.moduleIds),
24
+ imports: chunks.flatMap((chunk) => chunk.imports),
25
+ };
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astralarium/repo-config-vitest",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-config-vitest#readme",
6
6
  "repository": {
@@ -21,7 +21,8 @@
21
21
  "./harness/app-process": "./dist/harness/app-process.js",
22
22
  "./harness/worker-process": "./dist/harness/worker-process.js",
23
23
  "./pages": "./dist/pages/index.js",
24
- "./react": "./dist/react.js"
24
+ "./react": "./dist/react.js",
25
+ "./start": "./dist/start/index.js"
25
26
  },
26
27
  "publishConfig": {
27
28
  "access": "public"
@@ -38,10 +39,11 @@
38
39
  "seroval": "^1.7.2",
39
40
  "undici": "^7.29.0",
40
41
  "zod": "^4.6.7",
41
- "@astralarium/repo-telemetry": "^0.1.0",
42
- "@astralarium/repo-dev": "^0.1.0"
42
+ "@astralarium/repo-dev": "^0.1.1",
43
+ "@astralarium/repo-telemetry": "^0.2.0"
43
44
  },
44
45
  "devDependencies": {
46
+ "@tanstack/react-start": "^1.169.4",
45
47
  "@types/node": "^24.2.0",
46
48
  "eslint": "^10.2.1",
47
49
  "jsdom": "^29.1.1",
@@ -49,17 +51,21 @@
49
51
  "vite": "^8.3.4",
50
52
  "vitest": "^5.0.3",
51
53
  "wrangler": "^4.132.0",
52
- "@astralarium/repo-package-files": "^0.1.0",
53
- "@astralarium/repo-config-eslint": "^0.1.0",
54
- "@astralarium/repo-config-typescript": "^0.1.0"
54
+ "@astralarium/repo-config-typescript": "^0.1.0",
55
+ "@astralarium/repo-config-eslint": "^0.3.0",
56
+ "@astralarium/repo-package-files": "^0.1.0"
55
57
  },
56
58
  "peerDependencies": {
59
+ "@tanstack/react-start": "^1.169.4",
57
60
  "jsdom": "^29.1.1",
58
61
  "vite": "^8.3.4",
59
62
  "vitest": "^5.0.3",
60
63
  "wrangler": "^4.132.0"
61
64
  },
62
65
  "peerDependenciesMeta": {
66
+ "@tanstack/react-start": {
67
+ "optional": true
68
+ },
63
69
  "jsdom": {
64
70
  "optional": true
65
71
  },