@webstir-io/webstir-frontend 0.1.40
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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/assets/assetManifest.d.ts +16 -0
- package/dist/assets/assetManifest.js +31 -0
- package/dist/assets/imageOptimizer.d.ts +6 -0
- package/dist/assets/imageOptimizer.js +93 -0
- package/dist/assets/precompression.d.ts +1 -0
- package/dist/assets/precompression.js +21 -0
- package/dist/builders/contentBuilder.d.ts +2 -0
- package/dist/builders/contentBuilder.js +1052 -0
- package/dist/builders/cssBuilder.d.ts +2 -0
- package/dist/builders/cssBuilder.js +439 -0
- package/dist/builders/htmlBuilder.d.ts +2 -0
- package/dist/builders/htmlBuilder.js +430 -0
- package/dist/builders/index.d.ts +2 -0
- package/dist/builders/index.js +14 -0
- package/dist/builders/jsBuilder.d.ts +2 -0
- package/dist/builders/jsBuilder.js +300 -0
- package/dist/builders/staticAssetsBuilder.d.ts +2 -0
- package/dist/builders/staticAssetsBuilder.js +158 -0
- package/dist/builders/types.d.ts +12 -0
- package/dist/builders/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/config/manifest.d.ts +7 -0
- package/dist/config/manifest.js +17 -0
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.js +11 -0
- package/dist/config/schema.d.ts +413 -0
- package/dist/config/schema.js +44 -0
- package/dist/config/setup.d.ts +2 -0
- package/dist/config/setup.js +12 -0
- package/dist/config/workspace.d.ts +2 -0
- package/dist/config/workspace.js +131 -0
- package/dist/config/workspaceManifest.d.ts +23 -0
- package/dist/config/workspaceManifest.js +1 -0
- package/dist/core/constants.d.ts +70 -0
- package/dist/core/constants.js +70 -0
- package/dist/core/diagnostics.d.ts +15 -0
- package/dist/core/diagnostics.js +21 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +3 -0
- package/dist/core/pages.d.ts +6 -0
- package/dist/core/pages.js +23 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +115 -0
- package/dist/html/criticalCss.d.ts +4 -0
- package/dist/html/criticalCss.js +192 -0
- package/dist/html/htmlSecurity.d.ts +5 -0
- package/dist/html/htmlSecurity.js +73 -0
- package/dist/html/lazyLoad.d.ts +6 -0
- package/dist/html/lazyLoad.js +21 -0
- package/dist/html/pageScaffold.d.ts +10 -0
- package/dist/html/pageScaffold.js +51 -0
- package/dist/html/resourceHints.d.ts +7 -0
- package/dist/html/resourceHints.js +64 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/modes/ssg/index.d.ts +4 -0
- package/dist/modes/ssg/index.js +4 -0
- package/dist/modes/ssg/metadata.d.ts +5 -0
- package/dist/modes/ssg/metadata.js +50 -0
- package/dist/modes/ssg/routing.d.ts +2 -0
- package/dist/modes/ssg/routing.js +186 -0
- package/dist/modes/ssg/seo.d.ts +4 -0
- package/dist/modes/ssg/seo.js +208 -0
- package/dist/modes/ssg/validation.d.ts +3 -0
- package/dist/modes/ssg/validation.js +27 -0
- package/dist/modes/ssg/views.d.ts +2 -0
- package/dist/modes/ssg/views.js +236 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +102 -0
- package/dist/pipeline.d.ts +7 -0
- package/dist/pipeline.js +71 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +176 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/utils/changedFile.d.ts +8 -0
- package/dist/utils/changedFile.js +26 -0
- package/dist/utils/fs.d.ts +11 -0
- package/dist/utils/fs.js +39 -0
- package/dist/utils/hash.d.ts +1 -0
- package/dist/utils/hash.js +5 -0
- package/dist/utils/pagePaths.d.ts +5 -0
- package/dist/utils/pagePaths.js +36 -0
- package/dist/utils/pathMatch.d.ts +3 -0
- package/dist/utils/pathMatch.js +29 -0
- package/dist/watch/frontendFiles.d.ts +3 -0
- package/dist/watch/frontendFiles.js +25 -0
- package/dist/watch/hotUpdateTracker.d.ts +51 -0
- package/dist/watch/hotUpdateTracker.js +205 -0
- package/dist/watch/pipelineHelpers.d.ts +26 -0
- package/dist/watch/pipelineHelpers.js +177 -0
- package/dist/watch/types.d.ts +27 -0
- package/dist/watch/types.js +1 -0
- package/dist/watch/watchCoordinator.d.ts +36 -0
- package/dist/watch/watchCoordinator.js +551 -0
- package/dist/watch/watchDaemon.d.ts +17 -0
- package/dist/watch/watchDaemon.js +127 -0
- package/dist/watch/watchReporter.d.ts +21 -0
- package/dist/watch/watchReporter.js +64 -0
- package/package.json +92 -0
- package/scripts/publish.sh +101 -0
- package/scripts/smoke.mjs +35 -0
- package/scripts/update-contract.sh +121 -0
- package/src/assets/assetManifest.ts +51 -0
- package/src/assets/imageOptimizer.ts +112 -0
- package/src/assets/precompression.ts +25 -0
- package/src/builders/contentBuilder.ts +1400 -0
- package/src/builders/cssBuilder.ts +552 -0
- package/src/builders/htmlBuilder.ts +540 -0
- package/src/builders/index.ts +16 -0
- package/src/builders/jsBuilder.ts +358 -0
- package/src/builders/staticAssetsBuilder.ts +174 -0
- package/src/builders/types.ts +15 -0
- package/src/cli.ts +108 -0
- package/src/config/manifest.ts +24 -0
- package/src/config/paths.ts +14 -0
- package/src/config/schema.ts +49 -0
- package/src/config/setup.ts +14 -0
- package/src/config/workspace.ts +150 -0
- package/src/config/workspaceManifest.ts +27 -0
- package/src/core/constants.ts +73 -0
- package/src/core/diagnostics.ts +40 -0
- package/src/core/index.ts +3 -0
- package/src/core/pages.ts +31 -0
- package/src/hooks.ts +175 -0
- package/src/html/criticalCss.ts +214 -0
- package/src/html/htmlSecurity.ts +86 -0
- package/src/html/lazyLoad.ts +30 -0
- package/src/html/pageScaffold.ts +70 -0
- package/src/html/resourceHints.ts +91 -0
- package/src/index.ts +5 -0
- package/src/modes/ssg/index.ts +4 -0
- package/src/modes/ssg/metadata.ts +63 -0
- package/src/modes/ssg/routing.ts +230 -0
- package/src/modes/ssg/seo.ts +261 -0
- package/src/modes/ssg/validation.ts +37 -0
- package/src/modes/ssg/views.ts +309 -0
- package/src/operations.ts +138 -0
- package/src/pipeline.ts +88 -0
- package/src/provider.ts +249 -0
- package/src/types.ts +67 -0
- package/src/utils/changedFile.ts +39 -0
- package/src/utils/fs.ts +48 -0
- package/src/utils/hash.ts +6 -0
- package/src/utils/pagePaths.ts +43 -0
- package/src/utils/pathMatch.ts +36 -0
- package/src/watch/frontendFiles.ts +32 -0
- package/src/watch/hotUpdateTracker.ts +285 -0
- package/src/watch/pipelineHelpers.ts +242 -0
- package/src/watch/types.ts +23 -0
- package/src/watch/watchCoordinator.ts +666 -0
- package/src/watch/watchDaemon.ts +144 -0
- package/src/watch/watchReporter.ts +98 -0
- package/tests/add-page-defaults.test.js +64 -0
- package/tests/content-pages.test.js +81 -0
- package/tests/css-app-imports.test.js +64 -0
- package/tests/css-page-imports.test.js +100 -0
- package/tests/diagnostics.test.js +48 -0
- package/tests/features.test.js +63 -0
- package/tests/hooks.test.js +71 -0
- package/tests/provider.integration.test.js +137 -0
- package/tests/ssg-defaults.test.js +201 -0
- package/tests/ssg-guardrails.test.js +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { createInterface, Interface as ReadLineInterface } from 'node:readline';
|
|
3
|
+
import { emitDiagnostic } from '../core/diagnostics.js';
|
|
4
|
+
import { WatchCoordinator } from './watchCoordinator.js';
|
|
5
|
+
import type { WatchDaemonCommand, WatchDaemonOptions } from './types.js';
|
|
6
|
+
|
|
7
|
+
export class WatchDaemon {
|
|
8
|
+
private readonly coordinator: WatchCoordinator;
|
|
9
|
+
private readonly options: WatchDaemonOptions;
|
|
10
|
+
private readonly shutdownPromise: Promise<void>;
|
|
11
|
+
private resolveShutdown: (() => void) | null = null;
|
|
12
|
+
private commandQueue: Promise<void> = Promise.resolve();
|
|
13
|
+
private isShuttingDown = false;
|
|
14
|
+
private rl?: ReadLineInterface;
|
|
15
|
+
|
|
16
|
+
public constructor(options: WatchDaemonOptions) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.coordinator = new WatchCoordinator({
|
|
19
|
+
workspaceRoot: options.workspaceRoot,
|
|
20
|
+
verbose: options.verbose ?? false,
|
|
21
|
+
hmrVerbose: options.hmrVerbose ?? false
|
|
22
|
+
});
|
|
23
|
+
this.shutdownPromise = new Promise((resolve) => {
|
|
24
|
+
this.resolveShutdown = resolve;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async run(): Promise<void> {
|
|
29
|
+
if (this.options.autoStart !== false) {
|
|
30
|
+
await this.coordinator.start();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.setupSignalHandlers();
|
|
34
|
+
this.setupCommandLoop();
|
|
35
|
+
|
|
36
|
+
await this.shutdownPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private setupCommandLoop(): void {
|
|
40
|
+
if (process.stdin.isTTY) {
|
|
41
|
+
process.stdin.setRawMode(false);
|
|
42
|
+
}
|
|
43
|
+
process.stdin.setEncoding('utf8');
|
|
44
|
+
|
|
45
|
+
this.rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
46
|
+
this.rl.on('line', (line) => this.processLine(line));
|
|
47
|
+
this.rl.on('close', () => {
|
|
48
|
+
void this.shutdown();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private setupSignalHandlers(): void {
|
|
53
|
+
const shutdown = () => {
|
|
54
|
+
void this.shutdown();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
process.on('SIGINT', shutdown);
|
|
58
|
+
process.on('SIGTERM', shutdown);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private processLine(rawLine: string): void {
|
|
62
|
+
const line = rawLine.trim();
|
|
63
|
+
if (line.length === 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let command: WatchDaemonCommand | null = null;
|
|
68
|
+
try {
|
|
69
|
+
command = JSON.parse(line) as WatchDaemonCommand;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
emitDiagnostic({
|
|
72
|
+
code: 'frontend.watch.command.invalid',
|
|
73
|
+
kind: 'watch-daemon',
|
|
74
|
+
stage: 'command',
|
|
75
|
+
severity: 'warning',
|
|
76
|
+
message: `Discarding invalid command payload: ${String(error)}`
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.commandQueue = this.commandQueue.then(() => this.handleCommand(command!)).catch((error) => {
|
|
82
|
+
emitDiagnostic({
|
|
83
|
+
code: 'frontend.watch.command.failure',
|
|
84
|
+
kind: 'watch-daemon',
|
|
85
|
+
stage: 'command',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
message: `Command handling failed: ${error instanceof Error ? error.message : String(error)}`
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async handleCommand(command: WatchDaemonCommand): Promise<void> {
|
|
93
|
+
switch (command.type) {
|
|
94
|
+
case 'start':
|
|
95
|
+
await this.coordinator.start();
|
|
96
|
+
return;
|
|
97
|
+
case 'reload':
|
|
98
|
+
await this.coordinator.reload();
|
|
99
|
+
return;
|
|
100
|
+
case 'change':
|
|
101
|
+
await this.coordinator.handleChange({ path: command.path });
|
|
102
|
+
return;
|
|
103
|
+
case 'shutdown':
|
|
104
|
+
await this.shutdown();
|
|
105
|
+
return;
|
|
106
|
+
case 'ping':
|
|
107
|
+
emitDiagnostic({
|
|
108
|
+
code: 'frontend.watch.pong',
|
|
109
|
+
kind: 'watch-daemon',
|
|
110
|
+
stage: 'command',
|
|
111
|
+
severity: 'info',
|
|
112
|
+
message: 'Watch daemon heartbeat acknowledged.',
|
|
113
|
+
data: command.id ? { id: command.id } : undefined
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
default:
|
|
117
|
+
emitDiagnostic({
|
|
118
|
+
code: 'frontend.watch.command.unknown',
|
|
119
|
+
kind: 'watch-daemon',
|
|
120
|
+
stage: 'command',
|
|
121
|
+
severity: 'warning',
|
|
122
|
+
message: `Unknown watch daemon command: ${(command as { type?: unknown }).type}`
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async shutdown(): Promise<void> {
|
|
129
|
+
if (this.isShuttingDown) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.isShuttingDown = true;
|
|
134
|
+
|
|
135
|
+
if (this.rl) {
|
|
136
|
+
this.rl.close();
|
|
137
|
+
this.rl = undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await this.coordinator.stop();
|
|
141
|
+
this.resolveShutdown?.();
|
|
142
|
+
this.resolveShutdown = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { BuildResult, Message } from 'esbuild';
|
|
2
|
+
import { emitDiagnostic } from '../core/diagnostics.js';
|
|
3
|
+
import type { DiagnosticEvent } from '../core/diagnostics.js';
|
|
4
|
+
|
|
5
|
+
export interface WatchReporterOptions {
|
|
6
|
+
readonly verbose: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SerializedMessage {
|
|
10
|
+
readonly text: string;
|
|
11
|
+
readonly location?: {
|
|
12
|
+
readonly file?: string;
|
|
13
|
+
readonly line?: number;
|
|
14
|
+
readonly column?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class WatchReporter {
|
|
19
|
+
private readonly verbose: boolean;
|
|
20
|
+
|
|
21
|
+
public constructor(options: WatchReporterOptions) {
|
|
22
|
+
this.verbose = options.verbose;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public emit(event: DiagnosticEvent): void {
|
|
26
|
+
emitDiagnostic(event);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public emitVerbose(event: DiagnosticEvent): void {
|
|
30
|
+
if (!this.verbose) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
emitDiagnostic(event);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public emitJavaScriptStats(pageName: string, result: BuildResult, durationMs: number): void {
|
|
38
|
+
if (!this.verbose) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const stats = extractMetafileStats(result);
|
|
43
|
+
const data: Record<string, unknown> = {
|
|
44
|
+
page: pageName,
|
|
45
|
+
durationMs: Number(durationMs.toFixed(1))
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (stats) {
|
|
49
|
+
data.inputs = stats.inputs;
|
|
50
|
+
data.outputs = stats.outputs;
|
|
51
|
+
data.bytes = stats.bytes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
emitDiagnostic({
|
|
55
|
+
code: 'frontend.watch.javascript.build.stats',
|
|
56
|
+
kind: 'watch-daemon',
|
|
57
|
+
stage: 'javascript',
|
|
58
|
+
severity: 'info',
|
|
59
|
+
message: `JavaScript rebuild stats for '${pageName}' (${durationMs.toFixed(1)}ms).`,
|
|
60
|
+
data
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function serializeMessages(messages: readonly Message[]): SerializedMessage[] {
|
|
66
|
+
return messages.map((message) => ({
|
|
67
|
+
text: message.text,
|
|
68
|
+
location: message.location
|
|
69
|
+
? {
|
|
70
|
+
file: message.location.file,
|
|
71
|
+
line: message.location.line,
|
|
72
|
+
column: message.location.column
|
|
73
|
+
}
|
|
74
|
+
: undefined
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractMetafileStats(result: BuildResult): MetafileStats | null {
|
|
79
|
+
if (!result.metafile) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const inputs = Object.keys(result.metafile.inputs ?? {}).length;
|
|
84
|
+
const outputsEntries = Object.entries(result.metafile.outputs ?? {});
|
|
85
|
+
const bytes = outputsEntries.reduce((sum, [, output]) => sum + (output.bytes ?? 0), 0);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
inputs,
|
|
89
|
+
outputs: outputsEntries.length,
|
|
90
|
+
bytes
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface MetafileStats {
|
|
95
|
+
readonly inputs: number;
|
|
96
|
+
readonly outputs: number;
|
|
97
|
+
readonly bytes: number;
|
|
98
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import fssync from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { runAddPage } from '../dist/index.js';
|
|
9
|
+
|
|
10
|
+
async function createWorkspace(pkg) {
|
|
11
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-add-page-'));
|
|
12
|
+
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(pkg, null, 2), 'utf8');
|
|
13
|
+
return root;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('add-page defaults to ssg scaffold when webstir.mode=ssg', async () => {
|
|
17
|
+
const workspace = await createWorkspace({
|
|
18
|
+
name: 'webstir-project',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
webstir: { mode: 'ssg' }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
|
|
25
|
+
|
|
26
|
+
const pageDir = path.join(workspace, 'src', 'frontend', 'pages', 'about');
|
|
27
|
+
const htmlPath = path.join(pageDir, 'index.html');
|
|
28
|
+
const cssPath = path.join(pageDir, 'index.css');
|
|
29
|
+
const tsPath = path.join(pageDir, 'index.ts');
|
|
30
|
+
|
|
31
|
+
assert.equal(fssync.existsSync(htmlPath), true);
|
|
32
|
+
assert.equal(fssync.existsSync(cssPath), true);
|
|
33
|
+
assert.equal(fssync.existsSync(tsPath), false);
|
|
34
|
+
|
|
35
|
+
const html = await fs.readFile(htmlPath, 'utf8');
|
|
36
|
+
assert.ok(!html.includes('<script type="module"'), 'ssg scaffold should not include module script tag');
|
|
37
|
+
} finally {
|
|
38
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('add-page defaults to standard scaffold when webstir.mode is not ssg', async () => {
|
|
43
|
+
const workspace = await createWorkspace({
|
|
44
|
+
name: 'webstir-project',
|
|
45
|
+
version: '1.0.0'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
|
|
50
|
+
|
|
51
|
+
const pageDir = path.join(workspace, 'src', 'frontend', 'pages', 'about');
|
|
52
|
+
const htmlPath = path.join(pageDir, 'index.html');
|
|
53
|
+
const tsPath = path.join(pageDir, 'index.ts');
|
|
54
|
+
|
|
55
|
+
assert.equal(fssync.existsSync(htmlPath), true);
|
|
56
|
+
assert.equal(fssync.existsSync(tsPath), true);
|
|
57
|
+
|
|
58
|
+
const html = await fs.readFile(htmlPath, 'utf8');
|
|
59
|
+
assert.ok(html.includes('<script type="module"'), 'standard scaffold should include module script tag');
|
|
60
|
+
} finally {
|
|
61
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import fssync from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
async function loadProviderOrSkip(t) {
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import('../dist/index.js');
|
|
11
|
+
return mod.frontendProvider;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.warn('[frontend-tests] Skipping provider integration: optional dependency unavailable:', err?.message ?? err);
|
|
14
|
+
t?.diagnostic?.('skip: missing optional dependency');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function createWorkspaceWithContent() {
|
|
20
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-content-'));
|
|
21
|
+
const appDir = path.join(root, 'src', 'frontend', 'app');
|
|
22
|
+
const pageDir = path.join(root, 'src', 'frontend', 'pages', 'home');
|
|
23
|
+
const contentDir = path.join(root, 'src', 'frontend', 'content');
|
|
24
|
+
await fs.mkdir(appDir, { recursive: true });
|
|
25
|
+
await fs.mkdir(pageDir, { recursive: true });
|
|
26
|
+
await fs.mkdir(contentDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
await fs.writeFile(
|
|
29
|
+
path.join(appDir, 'app.html'),
|
|
30
|
+
'<!DOCTYPE html><html><head><title>My Site</title></head><body><main></main></body></html>',
|
|
31
|
+
'utf8'
|
|
32
|
+
);
|
|
33
|
+
await fs.writeFile(path.join(appDir, 'app.css'), 'body{font-family:sans-serif;}', 'utf8');
|
|
34
|
+
await fs.writeFile(path.join(pageDir, 'index.html'), '<head></head><main><section>Home</section></main>', 'utf8');
|
|
35
|
+
|
|
36
|
+
await fs.writeFile(
|
|
37
|
+
path.join(contentDir, 'readme.md'),
|
|
38
|
+
[
|
|
39
|
+
'---',
|
|
40
|
+
'title: Content pipeline',
|
|
41
|
+
'description: How it works',
|
|
42
|
+
'order: 1',
|
|
43
|
+
'---',
|
|
44
|
+
'',
|
|
45
|
+
'# Content pipeline',
|
|
46
|
+
'',
|
|
47
|
+
'Hello from markdown.'
|
|
48
|
+
].join('\n'),
|
|
49
|
+
'utf8'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('content builder strips frontmatter and injects app styles', async (t) => {
|
|
56
|
+
const frontendProvider = await loadProviderOrSkip(t);
|
|
57
|
+
if (!frontendProvider) return;
|
|
58
|
+
const workspace = await createWorkspaceWithContent();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await frontendProvider.build({ workspaceRoot: workspace, env: { WEBSTIR_MODULE_MODE: 'build' }, incremental: false });
|
|
62
|
+
|
|
63
|
+
const htmlPath = path.join(workspace, 'build', 'frontend', 'pages', 'docs', 'readme', 'index.html');
|
|
64
|
+
assert.equal(fssync.existsSync(htmlPath), true, `expected ${htmlPath}`);
|
|
65
|
+
|
|
66
|
+
const html = await fs.readFile(htmlPath, 'utf8');
|
|
67
|
+
assert.ok(!html.includes('title: Content pipeline'), 'frontmatter should not be rendered');
|
|
68
|
+
assert.ok(/<article\b/i.test(html), 'expected markdown wrapped in <article>');
|
|
69
|
+
assert.ok(html.includes('href="/app/app.css"'), 'expected app.css link injected');
|
|
70
|
+
assert.ok(html.includes('href="/pages/docs/index.css"'), 'expected docs css link injected');
|
|
71
|
+
|
|
72
|
+
const navPath = path.join(workspace, 'build', 'frontend', 'docs-nav.json');
|
|
73
|
+
assert.equal(fssync.existsSync(navPath), true, `expected ${navPath}`);
|
|
74
|
+
|
|
75
|
+
const nav = JSON.parse(await fs.readFile(navPath, 'utf8'));
|
|
76
|
+
assert.ok(Array.isArray(nav) && nav.length > 0, 'expected docs-nav.json to contain entries');
|
|
77
|
+
assert.ok(nav.some((entry) => entry.path === '/docs/readme/'), 'expected docs-nav.json to include /docs/readme/');
|
|
78
|
+
} finally {
|
|
79
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import fssync from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
async function loadProviderOrSkip(t) {
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import('../dist/index.js');
|
|
11
|
+
return mod.frontendProvider;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.warn('[frontend-tests] Skipping provider integration: optional dependency unavailable:', err?.message ?? err);
|
|
14
|
+
t?.diagnostic?.('skip: missing optional dependency');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function createWorkspace() {
|
|
20
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-css-'));
|
|
21
|
+
const appDir = path.join(root, 'src', 'frontend', 'app');
|
|
22
|
+
const stylesDir = path.join(appDir, 'styles');
|
|
23
|
+
const pageDir = path.join(root, 'src', 'frontend', 'pages', 'home');
|
|
24
|
+
await fs.mkdir(stylesDir, { recursive: true });
|
|
25
|
+
await fs.mkdir(pageDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
path.join(appDir, 'app.html'),
|
|
29
|
+
'<!DOCTYPE html><html><head><title>App</title></head><body><main></main></body></html>',
|
|
30
|
+
'utf8'
|
|
31
|
+
);
|
|
32
|
+
await fs.writeFile(
|
|
33
|
+
path.join(appDir, 'app.css'),
|
|
34
|
+
[
|
|
35
|
+
'@layer reset, base;',
|
|
36
|
+
'@import "./styles/base.css";'
|
|
37
|
+
].join('\n'),
|
|
38
|
+
'utf8'
|
|
39
|
+
);
|
|
40
|
+
await fs.writeFile(path.join(stylesDir, 'base.css'), '@layer base { body { background: blue; } }', 'utf8');
|
|
41
|
+
await fs.writeFile(path.join(pageDir, 'index.html'), '<head></head><main><section>Home</section></main>', 'utf8');
|
|
42
|
+
await fs.writeFile(path.join(pageDir, 'index.css'), '@import "@app/app.css";', 'utf8');
|
|
43
|
+
|
|
44
|
+
return root;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('development app.css import URLs include a cache-busting version', async (t) => {
|
|
48
|
+
const frontendProvider = await loadProviderOrSkip(t);
|
|
49
|
+
if (!frontendProvider) return;
|
|
50
|
+
const workspace = await createWorkspace();
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await frontendProvider.build({ workspaceRoot: workspace, env: { WEBSTIR_MODULE_MODE: 'build' }, incremental: false });
|
|
54
|
+
|
|
55
|
+
const appCssPath = path.join(workspace, 'build', 'frontend', 'app', 'app.css');
|
|
56
|
+
assert.equal(fssync.existsSync(appCssPath), true, `expected ${appCssPath}`);
|
|
57
|
+
|
|
58
|
+
const appCss = await fs.readFile(appCssPath, 'utf8');
|
|
59
|
+
assert.match(appCss, /@import\s+["']\.\/styles\/base\.css\?v=[a-f0-9]+["'];/i);
|
|
60
|
+
} finally {
|
|
61
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import fssync from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
async function loadProviderOrSkip(t) {
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import('../dist/index.js');
|
|
11
|
+
return mod.frontendProvider;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.warn('[frontend-tests] Skipping provider integration: optional dependency unavailable:', err?.message ?? err);
|
|
14
|
+
t?.diagnostic?.('skip: missing optional dependency');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function createWorkspace() {
|
|
20
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-page-css-'));
|
|
21
|
+
const appDir = path.join(root, 'src', 'frontend', 'app');
|
|
22
|
+
const pageDir = path.join(root, 'src', 'frontend', 'pages', 'home');
|
|
23
|
+
const partialsDir = path.join(pageDir, 'partials');
|
|
24
|
+
await fs.mkdir(appDir, { recursive: true });
|
|
25
|
+
await fs.mkdir(partialsDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
path.join(appDir, 'app.html'),
|
|
29
|
+
'<!DOCTYPE html><html><head><title>App</title></head><body><main></main></body></html>',
|
|
30
|
+
'utf8'
|
|
31
|
+
);
|
|
32
|
+
await fs.writeFile(path.join(appDir, 'app.css'), '', 'utf8');
|
|
33
|
+
await fs.writeFile(path.join(pageDir, 'index.html'), '<head></head><main><section>Home</section></main>', 'utf8');
|
|
34
|
+
|
|
35
|
+
await fs.writeFile(
|
|
36
|
+
path.join(pageDir, 'index.css'),
|
|
37
|
+
[
|
|
38
|
+
'@layer overrides { .home { color: red; } }',
|
|
39
|
+
'@import "./layout.css";',
|
|
40
|
+
'@import url("./partials/colors.css");'
|
|
41
|
+
].join('\n'),
|
|
42
|
+
'utf8'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
await fs.writeFile(
|
|
46
|
+
path.join(pageDir, 'layout.css'),
|
|
47
|
+
[
|
|
48
|
+
'@layer overrides { .layout { display: grid; } }',
|
|
49
|
+
'@import "./partials/typography.css";'
|
|
50
|
+
].join('\n'),
|
|
51
|
+
'utf8'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await fs.writeFile(
|
|
55
|
+
path.join(partialsDir, 'colors.css'),
|
|
56
|
+
'@layer overrides { .colors { color: blue; } }',
|
|
57
|
+
'utf8'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await fs.writeFile(
|
|
61
|
+
path.join(partialsDir, 'typography.css'),
|
|
62
|
+
'@layer overrides { .type { font-weight: 700; } }',
|
|
63
|
+
'utf8'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return root;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('build inlines page-local CSS @import files', async (t) => {
|
|
70
|
+
const frontendProvider = await loadProviderOrSkip(t);
|
|
71
|
+
if (!frontendProvider) return;
|
|
72
|
+
const workspace = await createWorkspace();
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await frontendProvider.build({ workspaceRoot: workspace, env: { WEBSTIR_MODULE_MODE: 'build' }, incremental: false });
|
|
76
|
+
|
|
77
|
+
const cssPath = path.join(workspace, 'build', 'frontend', 'pages', 'home', 'index.css');
|
|
78
|
+
assert.equal(fssync.existsSync(cssPath), true, `expected ${cssPath}`);
|
|
79
|
+
|
|
80
|
+
assert.equal(
|
|
81
|
+
fssync.existsSync(path.join(workspace, 'build', 'frontend', 'pages', 'home', 'layout.css')),
|
|
82
|
+
true,
|
|
83
|
+
'expected imported layout.css copied for dev server'
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
fssync.existsSync(path.join(workspace, 'build', 'frontend', 'pages', 'home', 'partials', 'colors.css')),
|
|
87
|
+
true,
|
|
88
|
+
'expected imported nested css copied for dev server'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const css = await fs.readFile(cssPath, 'utf8');
|
|
92
|
+
assert.ok(css.includes('.home'), 'expected entry css content');
|
|
93
|
+
assert.ok(css.includes('.layout'), 'expected imported css content');
|
|
94
|
+
assert.ok(css.includes('.colors'), 'expected imported css content');
|
|
95
|
+
assert.ok(css.includes('.type'), 'expected nested imported css content');
|
|
96
|
+
assert.equal(css.includes('@import "./layout.css"'), false, 'expected imports to be inlined');
|
|
97
|
+
} finally {
|
|
98
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { emitDiagnostic, STRUCTURED_DIAGNOSTIC_PREFIX } from '../dist/core/index.js';
|
|
5
|
+
|
|
6
|
+
test('emitDiagnostic emits human-readable and structured output', () => {
|
|
7
|
+
const originalLog = console.log;
|
|
8
|
+
const originalWarn = console.warn;
|
|
9
|
+
const logs = [];
|
|
10
|
+
const warnings = [];
|
|
11
|
+
|
|
12
|
+
console.log = (message) => {
|
|
13
|
+
logs.push(message);
|
|
14
|
+
};
|
|
15
|
+
console.warn = (message) => {
|
|
16
|
+
warnings.push(message);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
emitDiagnostic({
|
|
21
|
+
code: 'frontend.test.warning',
|
|
22
|
+
kind: 'test',
|
|
23
|
+
stage: 'unit',
|
|
24
|
+
severity: 'warning',
|
|
25
|
+
message: 'Sample diagnostic for testing.',
|
|
26
|
+
data: { flag: true }
|
|
27
|
+
});
|
|
28
|
+
} finally {
|
|
29
|
+
console.log = originalLog;
|
|
30
|
+
console.warn = originalWarn;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
assert.equal(warnings.length, 1);
|
|
34
|
+
assert.match(warnings[0], /\[webstir-frontend]\[frontend\.test\.warning\] Sample diagnostic for testing\./);
|
|
35
|
+
|
|
36
|
+
assert.equal(logs.length, 1);
|
|
37
|
+
const structuredLine = logs[0];
|
|
38
|
+
assert.ok(structuredLine.startsWith(STRUCTURED_DIAGNOSTIC_PREFIX));
|
|
39
|
+
|
|
40
|
+
const payload = JSON.parse(structuredLine.slice(STRUCTURED_DIAGNOSTIC_PREFIX.length));
|
|
41
|
+
assert.equal(payload.type, 'diagnostic');
|
|
42
|
+
assert.equal(payload.code, 'frontend.test.warning');
|
|
43
|
+
assert.equal(payload.kind, 'test');
|
|
44
|
+
assert.equal(payload.stage, 'unit');
|
|
45
|
+
assert.equal(payload.severity, 'warning');
|
|
46
|
+
assert.equal(payload.message, 'Sample diagnostic for testing.');
|
|
47
|
+
assert.deepEqual(payload.data, { flag: true });
|
|
48
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
|
|
7
|
+
import { buildConfig } from '../dist/config/workspace.js';
|
|
8
|
+
|
|
9
|
+
async function createWorkspace(frontendConfig) {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-'));
|
|
11
|
+
const workspaceRoot = path.join(tempRoot, 'workspace');
|
|
12
|
+
const frontendRoot = path.join(workspaceRoot, 'src', 'frontend');
|
|
13
|
+
await fs.mkdir(frontendRoot, { recursive: true });
|
|
14
|
+
|
|
15
|
+
if (frontendConfig !== undefined) {
|
|
16
|
+
const configPath = path.join(frontendRoot, 'frontend.config.json');
|
|
17
|
+
await fs.writeFile(configPath, JSON.stringify(frontendConfig, null, 2), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
workspaceRoot,
|
|
22
|
+
cleanup: () => fs.rm(tempRoot, { recursive: true, force: true })
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('buildConfig returns defaults when frontend.config.json is absent', async (t) => {
|
|
27
|
+
const workspace = await createWorkspace();
|
|
28
|
+
t.after(workspace.cleanup);
|
|
29
|
+
|
|
30
|
+
const config = buildConfig(workspace.workspaceRoot);
|
|
31
|
+
assert.equal(config.features.htmlSecurity, true);
|
|
32
|
+
assert.equal(config.features.imageOptimization, true);
|
|
33
|
+
assert.equal(config.features.precompression, true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('buildConfig applies overrides from nested features key', async (t) => {
|
|
37
|
+
const workspace = await createWorkspace({
|
|
38
|
+
features: {
|
|
39
|
+
htmlSecurity: false,
|
|
40
|
+
precompression: false
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
t.after(workspace.cleanup);
|
|
44
|
+
|
|
45
|
+
const config = buildConfig(workspace.workspaceRoot);
|
|
46
|
+
assert.equal(config.features.htmlSecurity, false);
|
|
47
|
+
assert.equal(config.features.precompression, false);
|
|
48
|
+
assert.equal(config.features.imageOptimization, true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('buildConfig accepts top-level feature flags', async (t) => {
|
|
52
|
+
const workspace = await createWorkspace({
|
|
53
|
+
htmlSecurity: false,
|
|
54
|
+
imageOptimization: false,
|
|
55
|
+
precompression: true
|
|
56
|
+
});
|
|
57
|
+
t.after(workspace.cleanup);
|
|
58
|
+
|
|
59
|
+
const config = buildConfig(workspace.workspaceRoot);
|
|
60
|
+
assert.equal(config.features.htmlSecurity, false);
|
|
61
|
+
assert.equal(config.features.imageOptimization, false);
|
|
62
|
+
assert.equal(config.features.precompression, true);
|
|
63
|
+
});
|