@t09tanaka/stoneage 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +768 -0
- package/dist/agent-skill.d.ts +1 -0
- package/dist/agent-skill.js +140 -0
- package/dist/assets.d.ts +125 -0
- package/dist/assets.js +341 -0
- package/dist/cli.d.ts +236 -0
- package/dist/cli.js +3077 -0
- package/dist/core.d.ts +473 -0
- package/dist/core.js +2897 -0
- package/dist/data.d.ts +121 -0
- package/dist/data.js +358 -0
- package/dist/deploy.d.ts +34 -0
- package/dist/deploy.js +203 -0
- package/dist/dev.d.ts +36 -0
- package/dist/dev.js +313 -0
- package/dist/example.d.ts +134 -0
- package/dist/example.js +1272 -0
- package/dist/fragment/client.d.ts +13 -0
- package/dist/fragment/client.js +150 -0
- package/dist/fragment.d.ts +15 -0
- package/dist/fragment.js +27 -0
- package/dist/html.d.ts +57 -0
- package/dist/html.js +208 -0
- package/dist/images.d.ts +95 -0
- package/dist/images.js +292 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migration.d.ts +157 -0
- package/dist/migration.js +983 -0
- package/dist/optimize.d.ts +15 -0
- package/dist/optimize.js +215 -0
- package/dist/pagination.d.ts +26 -0
- package/dist/pagination.js +62 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +10 -0
- package/dist/search.d.ts +32 -0
- package/dist/search.js +55 -0
- package/dist/testing.d.ts +66 -0
- package/dist/testing.js +97 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +1 -0
- package/jsx-loader.mjs +52 -0
- package/jsx-register.mjs +7 -0
- package/package.json +135 -0
- package/tsconfig.base.json +16 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type CompressedFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
originalBytes: number;
|
|
4
|
+
brotliBytes: number;
|
|
5
|
+
gzipBytes: number;
|
|
6
|
+
};
|
|
7
|
+
export type CompressionMetrics = {
|
|
8
|
+
filesCompressed: number;
|
|
9
|
+
originalBytes: number;
|
|
10
|
+
brotliBytes: number;
|
|
11
|
+
gzipBytes: number;
|
|
12
|
+
files: CompressedFile[];
|
|
13
|
+
};
|
|
14
|
+
export declare function measureCompressedPublicOutput(outDir: string): Promise<CompressionMetrics>;
|
|
15
|
+
export declare function compressPublicOutput(outDir: string): Promise<CompressionMetrics>;
|
package/dist/optimize.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { brotliCompress, constants, gzip } from "node:zlib";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
6
|
+
const brotliCompressAsync = promisify(brotliCompress);
|
|
7
|
+
const gzipAsync = promisify(gzip);
|
|
8
|
+
const compressionMeasurementConcurrency = 64;
|
|
9
|
+
const compressibleExtensions = new Set([
|
|
10
|
+
".html",
|
|
11
|
+
".css",
|
|
12
|
+
".js",
|
|
13
|
+
".xml",
|
|
14
|
+
".json",
|
|
15
|
+
".csv",
|
|
16
|
+
".txt",
|
|
17
|
+
".svg",
|
|
18
|
+
]);
|
|
19
|
+
export async function measureCompressedPublicOutput(outDir) {
|
|
20
|
+
const files = await collectCompressiblePublicFiles(outDir);
|
|
21
|
+
return measureFiles(outDir, files);
|
|
22
|
+
}
|
|
23
|
+
export async function compressPublicOutput(outDir) {
|
|
24
|
+
const files = await collectCompressiblePublicFiles(outDir);
|
|
25
|
+
const previousManifest = await readCompressionManifest(outDir);
|
|
26
|
+
const nextManifest = {
|
|
27
|
+
version: 1,
|
|
28
|
+
files: {},
|
|
29
|
+
};
|
|
30
|
+
const compressedFiles = await Promise.all(files.map((path) => compressOrReuseFile(outDir, path, previousManifest, nextManifest)));
|
|
31
|
+
await writeCompressionManifest(outDir, nextManifest);
|
|
32
|
+
return {
|
|
33
|
+
filesCompressed: compressedFiles.length,
|
|
34
|
+
originalBytes: sum(compressedFiles, (file) => file.originalBytes),
|
|
35
|
+
brotliBytes: sum(compressedFiles, (file) => file.brotliBytes),
|
|
36
|
+
gzipBytes: sum(compressedFiles, (file) => file.gzipBytes),
|
|
37
|
+
files: compressedFiles,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function collectCompressiblePublicFiles(outDir) {
|
|
41
|
+
const files = [];
|
|
42
|
+
await walk(outDir, async (path) => {
|
|
43
|
+
const publicPath = relative(outDir, path);
|
|
44
|
+
if (isCompressiblePath(publicPath)) {
|
|
45
|
+
files.push(publicPath);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return files.sort();
|
|
49
|
+
}
|
|
50
|
+
async function walk(dir, onFile) {
|
|
51
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.name === ".stoneage") {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const path = join(dir, entry.name);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
await walk(path, onFile);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (entry.isFile()) {
|
|
62
|
+
await onFile(path);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function measureFiles(outDir, files) {
|
|
67
|
+
const compressedFiles = await mapConcurrent(files, compressionMeasurementConcurrency, async (path) => {
|
|
68
|
+
const source = await readFile(join(outDir, path));
|
|
69
|
+
const [brotli, gzipped] = await Promise.all([compressBrotli(source), gzipAsync(source)]);
|
|
70
|
+
return {
|
|
71
|
+
path,
|
|
72
|
+
originalBytes: source.length,
|
|
73
|
+
brotliBytes: brotli.length,
|
|
74
|
+
gzipBytes: gzipped.length,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
filesCompressed: compressedFiles.length,
|
|
79
|
+
originalBytes: sum(compressedFiles, (file) => file.originalBytes),
|
|
80
|
+
brotliBytes: sum(compressedFiles, (file) => file.brotliBytes),
|
|
81
|
+
gzipBytes: sum(compressedFiles, (file) => file.gzipBytes),
|
|
82
|
+
files: compressedFiles,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function mapConcurrent(items, concurrency, worker) {
|
|
86
|
+
const limit = Math.max(1, Math.floor(concurrency));
|
|
87
|
+
const results = new Array(items.length);
|
|
88
|
+
let nextIndex = 0;
|
|
89
|
+
async function runWorker() {
|
|
90
|
+
while (nextIndex < items.length) {
|
|
91
|
+
const index = nextIndex;
|
|
92
|
+
nextIndex += 1;
|
|
93
|
+
results[index] = await worker(items[index]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
async function compressOrReuseFile(outDir, path, previousManifest, nextManifest) {
|
|
100
|
+
const source = await readFile(join(outDir, path));
|
|
101
|
+
const sourceHash = hashBuffer(source);
|
|
102
|
+
const previous = previousManifest.files[path];
|
|
103
|
+
if (previous?.sourceHash === sourceHash && previous.originalBytes === source.length) {
|
|
104
|
+
const cached = await readCachedCompressedFile(outDir, path, previous);
|
|
105
|
+
if (cached) {
|
|
106
|
+
nextManifest.files[path] = previous;
|
|
107
|
+
return cached;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const [brotli, gzipped] = await Promise.all([compressBrotli(source), gzipAsync(source)]);
|
|
111
|
+
await Promise.all([
|
|
112
|
+
writeBufferIfChanged(join(outDir, `${path}.br`), brotli),
|
|
113
|
+
writeBufferIfChanged(join(outDir, `${path}.gz`), gzipped),
|
|
114
|
+
]);
|
|
115
|
+
const entry = {
|
|
116
|
+
sourceHash,
|
|
117
|
+
brotliHash: hashBuffer(brotli),
|
|
118
|
+
gzipHash: hashBuffer(gzipped),
|
|
119
|
+
originalBytes: source.length,
|
|
120
|
+
brotliBytes: brotli.length,
|
|
121
|
+
gzipBytes: gzipped.length,
|
|
122
|
+
};
|
|
123
|
+
nextManifest.files[path] = entry;
|
|
124
|
+
return {
|
|
125
|
+
path,
|
|
126
|
+
originalBytes: entry.originalBytes,
|
|
127
|
+
brotliBytes: entry.brotliBytes,
|
|
128
|
+
gzipBytes: entry.gzipBytes,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function readCachedCompressedFile(outDir, path, entry) {
|
|
132
|
+
try {
|
|
133
|
+
const [brotli, gzipped] = await Promise.all([
|
|
134
|
+
readFile(join(outDir, `${path}.br`)),
|
|
135
|
+
readFile(join(outDir, `${path}.gz`)),
|
|
136
|
+
]);
|
|
137
|
+
if (brotli.length !== entry.brotliBytes ||
|
|
138
|
+
gzipped.length !== entry.gzipBytes ||
|
|
139
|
+
hashBuffer(brotli) !== entry.brotliHash ||
|
|
140
|
+
hashBuffer(gzipped) !== entry.gzipHash) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
path,
|
|
145
|
+
originalBytes: entry.originalBytes,
|
|
146
|
+
brotliBytes: entry.brotliBytes,
|
|
147
|
+
gzipBytes: entry.gzipBytes,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (isMissingPath(error)) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function compressBrotli(source) {
|
|
158
|
+
return brotliCompressAsync(source, {
|
|
159
|
+
params: {
|
|
160
|
+
[constants.BROTLI_PARAM_QUALITY]: 11,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function isCompressiblePath(path) {
|
|
165
|
+
if (path.split(sep).includes(".stoneage")) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return [...compressibleExtensions].some((extension) => path.endsWith(extension));
|
|
169
|
+
}
|
|
170
|
+
function sum(items, valueOf) {
|
|
171
|
+
return items.reduce((total, item) => total + valueOf(item), 0);
|
|
172
|
+
}
|
|
173
|
+
async function writeBufferIfChanged(path, contents) {
|
|
174
|
+
try {
|
|
175
|
+
const current = await readFile(path);
|
|
176
|
+
if (current.equals(contents)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (!isMissingPath(error)) {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await writeFile(path, contents);
|
|
186
|
+
}
|
|
187
|
+
async function readCompressionManifest(outDir) {
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(await readFile(compressionManifestPath(outDir), "utf8"));
|
|
190
|
+
if (parsed.version === 1 && parsed.files && typeof parsed.files === "object") {
|
|
191
|
+
return parsed;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Missing or invalid compression metadata just means every file is recompressed.
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
version: 1,
|
|
199
|
+
files: {},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function writeCompressionManifest(outDir, manifest) {
|
|
203
|
+
const path = compressionManifestPath(outDir);
|
|
204
|
+
await mkdir(dirname(path), { recursive: true });
|
|
205
|
+
await writeFile(path, `${JSON.stringify(manifest)}\n`);
|
|
206
|
+
}
|
|
207
|
+
function compressionManifestPath(outDir) {
|
|
208
|
+
return join(outDir, ".stoneage/compression-manifest.json");
|
|
209
|
+
}
|
|
210
|
+
function hashBuffer(value) {
|
|
211
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
212
|
+
}
|
|
213
|
+
function isMissingPath(error) {
|
|
214
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
215
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type PaginationOptions = {
|
|
2
|
+
pageSize: number;
|
|
3
|
+
};
|
|
4
|
+
export type PaginatedPage<T> = {
|
|
5
|
+
items: T[];
|
|
6
|
+
page: number;
|
|
7
|
+
pageSize: number;
|
|
8
|
+
totalItems: number;
|
|
9
|
+
totalPages: number;
|
|
10
|
+
};
|
|
11
|
+
export type PaginationLinkOptions = PaginationOptions & {
|
|
12
|
+
pathForPage: (page: number) => string;
|
|
13
|
+
};
|
|
14
|
+
export type PaginationLinkPage = {
|
|
15
|
+
page: number;
|
|
16
|
+
path: string;
|
|
17
|
+
current: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type PaginationLinks = {
|
|
20
|
+
previous?: string;
|
|
21
|
+
next?: string;
|
|
22
|
+
pages: PaginationLinkPage[];
|
|
23
|
+
};
|
|
24
|
+
export declare function paginate<T>(items: T[], options: PaginationOptions): PaginatedPage<T>[];
|
|
25
|
+
export declare function pageForItemIndex(index: number, options: PaginationOptions): number;
|
|
26
|
+
export declare function paginationLinks(page: number, totalItems: number, options: PaginationLinkOptions): PaginationLinks;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function paginate(items, options) {
|
|
2
|
+
assertPageSize(options.pageSize);
|
|
3
|
+
const totalItems = items.length;
|
|
4
|
+
const totalPages = totalPagesForTotalItems(totalItems, options);
|
|
5
|
+
return Array.from({ length: totalPages }, (_, index) => {
|
|
6
|
+
const page = index + 1;
|
|
7
|
+
const start = index * options.pageSize;
|
|
8
|
+
return {
|
|
9
|
+
items: items.slice(start, start + options.pageSize),
|
|
10
|
+
page,
|
|
11
|
+
pageSize: options.pageSize,
|
|
12
|
+
totalItems,
|
|
13
|
+
totalPages,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function pageForItemIndex(index, options) {
|
|
18
|
+
assertPageSize(options.pageSize);
|
|
19
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
20
|
+
throw new Error("index must be a non-negative integer");
|
|
21
|
+
}
|
|
22
|
+
return Math.floor(index / options.pageSize) + 1;
|
|
23
|
+
}
|
|
24
|
+
export function paginationLinks(page, totalItems, options) {
|
|
25
|
+
assertPage(page);
|
|
26
|
+
assertTotalItems(totalItems);
|
|
27
|
+
const totalPages = totalPagesForTotalItems(totalItems, options);
|
|
28
|
+
if (page > totalPages) {
|
|
29
|
+
throw new Error("page must be between 1 and total pages");
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
...(page > 1 ? { previous: options.pathForPage(page - 1) } : {}),
|
|
33
|
+
...(page < totalPages ? { next: options.pathForPage(page + 1) } : {}),
|
|
34
|
+
pages: Array.from({ length: totalPages }, (_, index) => {
|
|
35
|
+
const linkedPage = index + 1;
|
|
36
|
+
return {
|
|
37
|
+
page: linkedPage,
|
|
38
|
+
path: options.pathForPage(linkedPage),
|
|
39
|
+
current: linkedPage === page,
|
|
40
|
+
};
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function assertPageSize(pageSize) {
|
|
45
|
+
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
|
46
|
+
throw new Error("pageSize must be a positive integer");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function assertPage(page) {
|
|
50
|
+
if (!Number.isInteger(page) || page <= 0) {
|
|
51
|
+
throw new Error("page must be a positive integer");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function assertTotalItems(totalItems) {
|
|
55
|
+
if (!Number.isInteger(totalItems) || totalItems < 0) {
|
|
56
|
+
throw new Error("totalItems must be a non-negative integer");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function totalPagesForTotalItems(totalItems, options) {
|
|
60
|
+
assertPageSize(options.pageSize);
|
|
61
|
+
return Math.max(1, Math.ceil(totalItems / options.pageSize));
|
|
62
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function publicAssetOutputPath(publicPath: string): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
export function publicAssetOutputPath(publicPath) {
|
|
3
|
+
const [path] = publicPath.split(/[?#]/, 1);
|
|
4
|
+
const segments = (path ?? "").split("/");
|
|
5
|
+
const normalized = posix.normalize(`/${path ?? ""}`).replace(/^\/+/, "");
|
|
6
|
+
if (!path || path.endsWith("/") || segments.includes("..") || normalized === "") {
|
|
7
|
+
throw new Error(`Public asset path must not traverse outside the output directory: ${publicPath}`);
|
|
8
|
+
}
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ArtifactFamily, Dependency } from "./core.js";
|
|
2
|
+
export type SearchIndexValue = string | number | boolean | null | undefined;
|
|
3
|
+
export type SearchIndexDocument = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
text?: string;
|
|
8
|
+
tags?: string[];
|
|
9
|
+
metadata?: Record<string, string | number | boolean | null>;
|
|
10
|
+
};
|
|
11
|
+
export type SearchIndexDocumentInput = Omit<SearchIndexDocument, "tags" | "metadata"> & {
|
|
12
|
+
tags?: readonly string[];
|
|
13
|
+
metadata?: Record<string, SearchIndexValue>;
|
|
14
|
+
};
|
|
15
|
+
export type SearchIndex = {
|
|
16
|
+
version: 1;
|
|
17
|
+
documents: SearchIndexDocument[];
|
|
18
|
+
};
|
|
19
|
+
export type BuildSearchIndexOptions = {
|
|
20
|
+
sort?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type SearchIndexArtifactOptions<RecordItem> = BuildSearchIndexOptions & {
|
|
23
|
+
name?: string;
|
|
24
|
+
path?: string;
|
|
25
|
+
dependencyKey?: string;
|
|
26
|
+
dependencies?: Dependency[] | ((index: SearchIndex) => Dependency[]);
|
|
27
|
+
records: readonly RecordItem[];
|
|
28
|
+
document: (record: RecordItem, index: number) => SearchIndexDocumentInput;
|
|
29
|
+
pretty?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export declare function buildSearchIndex<RecordItem>(records: readonly RecordItem[], document: (record: RecordItem, index: number) => SearchIndexDocumentInput, options?: BuildSearchIndexOptions): SearchIndex;
|
|
32
|
+
export declare function defineSearchIndexArtifact<RecordItem>(options: SearchIndexArtifactOptions<RecordItem>): ArtifactFamily;
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { renderJsonArtifact } from "./data.js";
|
|
2
|
+
export function buildSearchIndex(records, document, options = {}) {
|
|
3
|
+
const documents = records.map((record, index) => normalizeSearchDocument(document(record, index)));
|
|
4
|
+
return {
|
|
5
|
+
version: 1,
|
|
6
|
+
documents: options.sort === false ? documents : documents.sort(compareSearchDocuments),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function defineSearchIndexArtifact(options) {
|
|
10
|
+
return {
|
|
11
|
+
name: options.name ?? "searchIndex",
|
|
12
|
+
pattern: options.path ?? "/search-index.json",
|
|
13
|
+
entries: () => {
|
|
14
|
+
const index = buildSearchIndex(options.records, options.document, {
|
|
15
|
+
sort: options.sort,
|
|
16
|
+
});
|
|
17
|
+
const extraDependencies = typeof options.dependencies === "function"
|
|
18
|
+
? options.dependencies(index)
|
|
19
|
+
: (options.dependencies ?? []);
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
params: {},
|
|
23
|
+
dependencies: [
|
|
24
|
+
{
|
|
25
|
+
key: options.dependencyKey ?? "search:index",
|
|
26
|
+
value: index,
|
|
27
|
+
},
|
|
28
|
+
...extraDependencies,
|
|
29
|
+
],
|
|
30
|
+
render: () => renderJsonArtifact(index, { pretty: options.pretty }),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function normalizeSearchDocument(document) {
|
|
37
|
+
return {
|
|
38
|
+
id: document.id,
|
|
39
|
+
title: document.title,
|
|
40
|
+
url: document.url,
|
|
41
|
+
...(document.text ? { text: document.text } : {}),
|
|
42
|
+
...(document.tags && document.tags.length > 0
|
|
43
|
+
? { tags: [...new Set(document.tags)].filter(Boolean).sort() }
|
|
44
|
+
: {}),
|
|
45
|
+
...(document.metadata ? { metadata: normalizeSearchMetadata(document.metadata) } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function normalizeSearchMetadata(metadata) {
|
|
49
|
+
return Object.fromEntries(Object.entries(metadata)
|
|
50
|
+
.filter(([, value]) => value !== undefined)
|
|
51
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
52
|
+
}
|
|
53
|
+
function compareSearchDocuments(left, right) {
|
|
54
|
+
return left.url.localeCompare(right.url) || left.id.localeCompare(right.id);
|
|
55
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type HtmlNode } from "./html.js";
|
|
2
|
+
export { renderToString } from "./html.js";
|
|
3
|
+
export type { HtmlNode } from "./html.js";
|
|
4
|
+
/**
|
|
5
|
+
* The result of rendering a component for assertions. Wraps the rendered HTML
|
|
6
|
+
* string with a few lightweight, dependency-free query helpers so component
|
|
7
|
+
* tests do not have to hand-roll string matching or pull in a DOM.
|
|
8
|
+
*/
|
|
9
|
+
export type RenderResult = {
|
|
10
|
+
/** The fully rendered HTML string. */
|
|
11
|
+
readonly html: string;
|
|
12
|
+
/**
|
|
13
|
+
* The visible text content, with all tags stripped and HTML entities for
|
|
14
|
+
* the characters StoneAge escapes (`&`, `<`, `>`, `"`) decoded back to their
|
|
15
|
+
* literal form. Whitespace between tags is collapsed to single spaces.
|
|
16
|
+
*/
|
|
17
|
+
text(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Whether the rendered HTML contains the given substring verbatim. The
|
|
20
|
+
* substring is matched against the raw (escaped) markup, so pass escaped
|
|
21
|
+
* entities when checking for literal `<`, `>` or `&` in text content.
|
|
22
|
+
*/
|
|
23
|
+
contains(substring: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Reads the value of an attribute from the first element matching `tag`.
|
|
26
|
+
* `tag` is matched case-insensitively. Returns the decoded attribute value,
|
|
27
|
+
* `true` for valueless boolean attributes, or `undefined` when no matching
|
|
28
|
+
* element carries the attribute.
|
|
29
|
+
*/
|
|
30
|
+
attr(tag: string, name: string): string | true | undefined;
|
|
31
|
+
};
|
|
32
|
+
export type StoneAgeVitestConfig = {
|
|
33
|
+
test?: Record<string, unknown>;
|
|
34
|
+
esbuild?: Record<string, unknown>;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Renders a node to HTML and returns the string together with lightweight
|
|
39
|
+
* query helpers. This is intentionally a thin wrapper rather than a DOM
|
|
40
|
+
* implementation: it uses simple string and regular-expression matching, which
|
|
41
|
+
* is enough to assert on the static HTML StoneAge components produce.
|
|
42
|
+
*/
|
|
43
|
+
export declare function render(node: HtmlNode): RenderResult;
|
|
44
|
+
/**
|
|
45
|
+
* A framework-agnostic assertion helper. It throws a plain `Error` (which every
|
|
46
|
+
* test runner reports as a failure) when an expectation fails, so it can be used
|
|
47
|
+
* from `node:test`, vitest, or any other runner without a dependency on one.
|
|
48
|
+
*/
|
|
49
|
+
export declare function expectMarkup(node: HtmlNode): MarkupAssertions;
|
|
50
|
+
/**
|
|
51
|
+
* Assertions returned by {@link expectMarkup}. Each method throws on failure.
|
|
52
|
+
*/
|
|
53
|
+
export type MarkupAssertions = {
|
|
54
|
+
/** Asserts the rendered HTML exactly equals `expected`. */
|
|
55
|
+
toBe(expected: string): void;
|
|
56
|
+
/** Asserts the rendered HTML contains `substring` verbatim. */
|
|
57
|
+
toContain(substring: string): void;
|
|
58
|
+
/** Asserts the decoded visible text exactly equals `expected`. */
|
|
59
|
+
toHaveText(expected: string): void;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Returns a Vitest config preset for StoneAge component tests. It keeps Vitest
|
|
63
|
+
* in the consuming app's dev dependencies by returning a plain config object
|
|
64
|
+
* instead of importing `vitest/config`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stoneAgeVitestConfig(config?: StoneAgeVitestConfig): StoneAgeVitestConfig;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { renderToString } from "./html.js";
|
|
2
|
+
export { renderToString } from "./html.js";
|
|
3
|
+
const escapedEntities = [
|
|
4
|
+
[/"/g, '"'],
|
|
5
|
+
[/</g, "<"],
|
|
6
|
+
[/>/g, ">"],
|
|
7
|
+
[/&/g, "&"],
|
|
8
|
+
];
|
|
9
|
+
function decodeEntities(value) {
|
|
10
|
+
let output = value;
|
|
11
|
+
for (const [pattern, replacement] of escapedEntities) {
|
|
12
|
+
output = output.replace(pattern, replacement);
|
|
13
|
+
}
|
|
14
|
+
return output;
|
|
15
|
+
}
|
|
16
|
+
function escapeRegExp(value) {
|
|
17
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders a node to HTML and returns the string together with lightweight
|
|
21
|
+
* query helpers. This is intentionally a thin wrapper rather than a DOM
|
|
22
|
+
* implementation: it uses simple string and regular-expression matching, which
|
|
23
|
+
* is enough to assert on the static HTML StoneAge components produce.
|
|
24
|
+
*/
|
|
25
|
+
export function render(node) {
|
|
26
|
+
const html = renderToString(node);
|
|
27
|
+
return {
|
|
28
|
+
html,
|
|
29
|
+
text() {
|
|
30
|
+
return decodeEntities(html.replace(/<[^>]*>/g, " "))
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
},
|
|
34
|
+
contains(substring) {
|
|
35
|
+
return html.includes(substring);
|
|
36
|
+
},
|
|
37
|
+
attr(tag, name) {
|
|
38
|
+
const tagPattern = new RegExp(`<${escapeRegExp(tag)}(\\s[^>]*?)?\\s*/?>`, "i");
|
|
39
|
+
const tagMatch = tagPattern.exec(html);
|
|
40
|
+
if (tagMatch === null) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const attributes = tagMatch[1] ?? "";
|
|
44
|
+
const valued = new RegExp(`(?:^|\\s)${escapeRegExp(name)}="([^"]*)"`).exec(attributes);
|
|
45
|
+
if (valued !== null) {
|
|
46
|
+
return decodeEntities(valued[1]);
|
|
47
|
+
}
|
|
48
|
+
const boolean = new RegExp(`(?:^|\\s)${escapeRegExp(name)}(?=\\s|$)`).exec(attributes);
|
|
49
|
+
return boolean !== null ? true : undefined;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A framework-agnostic assertion helper. It throws a plain `Error` (which every
|
|
55
|
+
* test runner reports as a failure) when an expectation fails, so it can be used
|
|
56
|
+
* from `node:test`, vitest, or any other runner without a dependency on one.
|
|
57
|
+
*/
|
|
58
|
+
export function expectMarkup(node) {
|
|
59
|
+
const { html, text, contains } = render(node);
|
|
60
|
+
return {
|
|
61
|
+
toBe(expected) {
|
|
62
|
+
if (html !== expected) {
|
|
63
|
+
throw new Error(`Expected markup to equal:\n${expected}\nReceived:\n${html}`);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
toContain(substring) {
|
|
67
|
+
if (!contains(substring)) {
|
|
68
|
+
throw new Error(`Expected markup to contain ${JSON.stringify(substring)}.\nReceived:\n${html}`);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
toHaveText(expected) {
|
|
72
|
+
const actual = text();
|
|
73
|
+
if (actual !== expected) {
|
|
74
|
+
throw new Error(`Expected text to equal ${JSON.stringify(expected)} but received ${JSON.stringify(actual)}.`);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a Vitest config preset for StoneAge component tests. It keeps Vitest
|
|
81
|
+
* in the consuming app's dev dependencies by returning a plain config object
|
|
82
|
+
* instead of importing `vitest/config`.
|
|
83
|
+
*/
|
|
84
|
+
export function stoneAgeVitestConfig(config = {}) {
|
|
85
|
+
return {
|
|
86
|
+
...config,
|
|
87
|
+
test: {
|
|
88
|
+
environment: "node",
|
|
89
|
+
...(config.test ?? {}),
|
|
90
|
+
},
|
|
91
|
+
esbuild: {
|
|
92
|
+
jsxFactory: "html",
|
|
93
|
+
jsxFragment: "Fragment",
|
|
94
|
+
...(config.esbuild ?? {}),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { validateOutput } from "./cli.js";
|
package/jsx-loader.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// StoneAge JSX loader: a Node module-customization "load" hook that compiles
|
|
2
|
+
// .tsx/.jsx sources with esbuild using a FORCED html() JSX factory
|
|
3
|
+
// (jsxFactory="html", jsxFragment="Fragment").
|
|
4
|
+
//
|
|
5
|
+
// This is what makes StoneAge TSX zero-config: the JSX factory is pinned at the
|
|
6
|
+
// esbuild transform level, so it cannot be overridden by the consumer's own
|
|
7
|
+
// tsconfig.json (e.g. jsx:"react-jsx", which otherwise causes
|
|
8
|
+
// "React is not defined" / "Cannot find module react/jsx-runtime").
|
|
9
|
+
//
|
|
10
|
+
// It is layered ON TOP of tsx's loader (see jsx-register.mjs): tsx handles
|
|
11
|
+
// module resolution (including NodeNext ".js" -> ".ts") and ESM/CJS interop and
|
|
12
|
+
// compiles the remaining .ts sources, while this hook owns the JSX transform.
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
// Resolve esbuild from StoneAge's own dependency tree.
|
|
19
|
+
const esbuild = require("esbuild");
|
|
20
|
+
|
|
21
|
+
const JSX_EXTENSION = /\.(tsx|jsx)$/;
|
|
22
|
+
const NODE_TARGET = `node${process.versions.node.split(".")[0]}`;
|
|
23
|
+
|
|
24
|
+
export async function load(url, context, nextLoad) {
|
|
25
|
+
const pathname = url.startsWith("file:") ? new URL(url).pathname : "";
|
|
26
|
+
// Only transform the consumer's own .tsx/.jsx sources. Third-party packages
|
|
27
|
+
// under node_modules ship their own (often React) JSX and must not be
|
|
28
|
+
// recompiled with the html() factory.
|
|
29
|
+
if (
|
|
30
|
+
pathname &&
|
|
31
|
+
JSX_EXTENSION.test(pathname) &&
|
|
32
|
+
!pathname.includes("/node_modules/")
|
|
33
|
+
) {
|
|
34
|
+
const path = fileURLToPath(url);
|
|
35
|
+
const source = await readFile(path, "utf8");
|
|
36
|
+
const { code, warnings } = await esbuild.transform(source, {
|
|
37
|
+
loader: "tsx",
|
|
38
|
+
jsx: "transform",
|
|
39
|
+
jsxFactory: "html",
|
|
40
|
+
jsxFragment: "Fragment",
|
|
41
|
+
format: "esm",
|
|
42
|
+
target: NODE_TARGET,
|
|
43
|
+
sourcefile: path,
|
|
44
|
+
sourcemap: "inline",
|
|
45
|
+
});
|
|
46
|
+
for (const warning of warnings) {
|
|
47
|
+
console.warn(`[stoneage] ${warning.text}`);
|
|
48
|
+
}
|
|
49
|
+
return { format: "module", source: code, shortCircuit: true };
|
|
50
|
+
}
|
|
51
|
+
return nextLoad(url, context);
|
|
52
|
+
}
|
package/jsx-register.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Registers the StoneAge JSX loader (jsx-loader.mjs) as a Node module
|
|
2
|
+
// customization hook. Imported via `node --import` by `stoneage build` and
|
|
3
|
+
// `stoneage test` so .tsx/.jsx sources compile with the html() JSX factory
|
|
4
|
+
// forced, independent of the consumer's tsconfig.
|
|
5
|
+
import { register } from "node:module";
|
|
6
|
+
|
|
7
|
+
register(new URL("./jsx-loader.mjs", import.meta.url));
|