@harpy-js/core 0.5.1 → 0.5.3
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/dist/cli.js +3 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -1
- package/dist/seo/index.d.ts +5 -0
- package/dist/seo/index.js +12 -0
- package/dist/seo/robots.controller.d.ts +6 -0
- package/dist/seo/robots.controller.js +37 -0
- package/dist/seo/seo.module.d.ts +7 -0
- package/dist/seo/seo.module.js +54 -0
- package/dist/seo/seo.service.d.ts +16 -0
- package/dist/seo/seo.service.js +136 -0
- package/dist/seo/seo.types.d.ts +29 -0
- package/dist/seo/seo.types.js +2 -0
- package/dist/seo/sitemap.controller.d.ts +6 -0
- package/dist/seo/sitemap.controller.js +37 -0
- package/package.json +1 -1
- package/scripts/auto-wrap-exports.ts +7 -6
- package/scripts/build-hydration.ts +14 -15
- package/scripts/build-page-styles.ts +6 -5
- package/scripts/build.ts +93 -0
- package/scripts/dev.ts +40 -31
- package/scripts/logger.ts +53 -0
- package/scripts/start.ts +41 -0
- package/src/cli.ts +3 -1
- package/src/index.ts +5 -0
- package/src/seo/index.ts +5 -0
- package/src/seo/robots.controller.ts +15 -0
- package/src/seo/seo.module.ts +59 -0
- package/src/seo/seo.service.ts +161 -0
- package/src/seo/seo.types.ts +40 -0
- package/src/seo/sitemap.controller.ts +15 -0
package/scripts/build.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { Logger } from "./logger";
|
|
5
|
+
|
|
6
|
+
const logger = new Logger("Builder");
|
|
7
|
+
|
|
8
|
+
async function runCommand(cmd: string, args: string[] = []): Promise<void> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const proc = spawn(cmd, args, { stdio: "inherit", shell: true });
|
|
11
|
+
proc.on("close", (code) => {
|
|
12
|
+
if (code !== 0) {
|
|
13
|
+
reject(new Error(`Command failed with code ${code}`));
|
|
14
|
+
} else {
|
|
15
|
+
resolve();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function buildNestApp(): Promise<void> {
|
|
22
|
+
logger.log("Building NestJS application...");
|
|
23
|
+
try {
|
|
24
|
+
await runCommand("nest", ["build"]);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
logger.error(`NestJS build failed: ${error}`);
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function buildHydration(): Promise<void> {
|
|
32
|
+
logger.log("Building hydration components...");
|
|
33
|
+
try {
|
|
34
|
+
const tsxPath = require("child_process")
|
|
35
|
+
.execSync("which tsx", { encoding: "utf-8" })
|
|
36
|
+
.trim();
|
|
37
|
+
await runCommand(tsxPath, [
|
|
38
|
+
require("path").join(__dirname, "build-hydration.ts"),
|
|
39
|
+
]);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error(`Hydration build failed: ${error}`);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function autoWrap(): Promise<void> {
|
|
47
|
+
logger.log("Auto-wrapping client components...");
|
|
48
|
+
try {
|
|
49
|
+
const tsxPath = require("child_process")
|
|
50
|
+
.execSync("which tsx", { encoding: "utf-8" })
|
|
51
|
+
.trim();
|
|
52
|
+
await runCommand(tsxPath, [
|
|
53
|
+
require("path").join(__dirname, "auto-wrap-exports.ts"),
|
|
54
|
+
]);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.error(`Auto-wrap failed: ${error}`);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function buildStyles(): Promise<void> {
|
|
62
|
+
logger.log("Building styles...");
|
|
63
|
+
try {
|
|
64
|
+
const tsxPath = require("child_process")
|
|
65
|
+
.execSync("which tsx", { encoding: "utf-8" })
|
|
66
|
+
.trim();
|
|
67
|
+
await runCommand(tsxPath, [
|
|
68
|
+
require("path").join(__dirname, "build-page-styles.ts"),
|
|
69
|
+
]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logger.error(`Styles build failed: ${error}`);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function main(): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
logger.log("Starting production build...");
|
|
79
|
+
|
|
80
|
+
await buildNestApp();
|
|
81
|
+
await buildHydration();
|
|
82
|
+
await autoWrap();
|
|
83
|
+
await buildStyles();
|
|
84
|
+
|
|
85
|
+
logger.log("Production build complete!");
|
|
86
|
+
process.exit(0);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.error(`Build failed: ${error}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
main();
|
package/scripts/dev.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import * as http from "http";
|
|
6
|
+
import { Logger } from "./logger";
|
|
7
|
+
|
|
8
|
+
const logger = new Logger("DevServer");
|
|
6
9
|
|
|
7
10
|
let nestProcess: any = null;
|
|
8
11
|
let isRebuilding = false;
|
|
@@ -43,43 +46,52 @@ async function runCommand(cmd: string, args: string[] = []): Promise<void> {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
async function buildHydration(): Promise<void> {
|
|
46
|
-
|
|
49
|
+
logger.log("Building hydration components...");
|
|
47
50
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
const tsxPath = require("child_process")
|
|
52
|
+
.execSync("which tsx", { encoding: "utf-8" })
|
|
53
|
+
.trim();
|
|
54
|
+
await runCommand(tsxPath, [
|
|
55
|
+
require("path").join(__dirname, "build-hydration.ts"),
|
|
56
|
+
]);
|
|
50
57
|
} catch (error) {
|
|
51
|
-
|
|
58
|
+
logger.error(`Hydration build failed: ${error}`);
|
|
52
59
|
throw error;
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
async function autoWrap(): Promise<void> {
|
|
57
|
-
|
|
64
|
+
logger.log("Auto-wrapping client components...");
|
|
58
65
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
const tsxPath = require("child_process")
|
|
67
|
+
.execSync("which tsx", { encoding: "utf-8" })
|
|
68
|
+
.trim();
|
|
69
|
+
await runCommand(tsxPath, [
|
|
70
|
+
require("path").join(__dirname, "auto-wrap-exports.ts"),
|
|
71
|
+
]);
|
|
61
72
|
} catch (error) {
|
|
62
|
-
|
|
63
|
-
throw error;
|
|
73
|
+
logger.error(`Auto-wrap failed: ${error}`);
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
async function buildStyles(): Promise<void> {
|
|
68
|
-
|
|
78
|
+
logger.log("Building styles...");
|
|
69
79
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
const tsxPath = require("child_process")
|
|
81
|
+
.execSync("which tsx", { encoding: "utf-8" })
|
|
82
|
+
.trim();
|
|
83
|
+
await runCommand(tsxPath, [
|
|
84
|
+
require("path").join(__dirname, "build-page-styles.ts"),
|
|
85
|
+
]);
|
|
72
86
|
} catch (error) {
|
|
73
|
-
|
|
74
|
-
throw error;
|
|
87
|
+
logger.error(`Styles build failed: ${error}`);
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
async function startNestServer(): Promise<void> {
|
|
79
92
|
return new Promise((resolve) => {
|
|
80
|
-
|
|
93
|
+
logger.log("Starting NestJS application...");
|
|
81
94
|
// Run compiled dist/main.js instead of using ts-node
|
|
82
|
-
// This ensures auto-wrapped components are used
|
|
83
95
|
nestProcess = spawn("node", ["--watch", "dist/main.js"], {
|
|
84
96
|
stdio: "pipe",
|
|
85
97
|
shell: false,
|
|
@@ -106,15 +118,14 @@ async function startNestServer(): Promise<void> {
|
|
|
106
118
|
setTimeout(async () => {
|
|
107
119
|
if (isRebuilding) return;
|
|
108
120
|
isRebuilding = true;
|
|
109
|
-
|
|
121
|
+
logger.log("Rebuilding assets after code change...");
|
|
110
122
|
try {
|
|
111
123
|
await buildHydration();
|
|
112
124
|
await autoWrap();
|
|
113
125
|
await buildStyles();
|
|
114
|
-
console.log("✅ Assets rebuilt\n");
|
|
115
126
|
triggerBrowserReload();
|
|
116
127
|
} catch (error) {
|
|
117
|
-
|
|
128
|
+
logger.error(`Asset rebuild failed: ${error}`);
|
|
118
129
|
} finally {
|
|
119
130
|
isRebuilding = false;
|
|
120
131
|
}
|
|
@@ -139,10 +150,9 @@ async function startNestServer(): Promise<void> {
|
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
function watchSourceChanges(): void {
|
|
142
|
-
|
|
153
|
+
logger.log("Watching source files for changes...");
|
|
143
154
|
|
|
144
155
|
let debounceTimer: NodeJS.Timeout | null = null;
|
|
145
|
-
const watchedFiles = new Set<string>();
|
|
146
156
|
|
|
147
157
|
// Watch src for CSS changes only (TS/TSX changes are handled by NestJS watch + stdout detection)
|
|
148
158
|
fs.watch("src", { recursive: true }, async (eventType, filename) => {
|
|
@@ -168,14 +178,13 @@ function watchSourceChanges(): void {
|
|
|
168
178
|
|
|
169
179
|
debounceTimer = setTimeout(async () => {
|
|
170
180
|
isRebuilding = true;
|
|
171
|
-
|
|
181
|
+
logger.log(`CSS file changed: ${filename}`);
|
|
172
182
|
|
|
173
183
|
try {
|
|
174
184
|
await buildStyles();
|
|
175
|
-
console.log("✅ Styles rebuilt\n");
|
|
176
185
|
triggerBrowserReload();
|
|
177
186
|
} catch (error) {
|
|
178
|
-
|
|
187
|
+
logger.error(`Style rebuild failed: ${error}`);
|
|
179
188
|
} finally {
|
|
180
189
|
watchedFiles.delete(filename);
|
|
181
190
|
isRebuilding = false;
|
|
@@ -188,7 +197,7 @@ let tscProcess: any = null;
|
|
|
188
197
|
|
|
189
198
|
async function startTypeScriptWatch(): Promise<void> {
|
|
190
199
|
return new Promise((resolve) => {
|
|
191
|
-
|
|
200
|
+
logger.log("Starting TypeScript compiler in watch mode...");
|
|
192
201
|
tscProcess = spawn("pnpm", ["nest", "build", "--watch"], {
|
|
193
202
|
stdio: "pipe",
|
|
194
203
|
shell: true,
|
|
@@ -215,13 +224,13 @@ async function startTypeScriptWatch(): Promise<void> {
|
|
|
215
224
|
|
|
216
225
|
async function main(): Promise<void> {
|
|
217
226
|
try {
|
|
218
|
-
|
|
227
|
+
logger.log("Initializing development environment...");
|
|
219
228
|
|
|
220
229
|
// First: Start TypeScript compiler in watch mode
|
|
221
230
|
await startTypeScriptWatch();
|
|
222
231
|
|
|
223
232
|
// Build initial assets after first compilation
|
|
224
|
-
|
|
233
|
+
logger.log("Building initial assets...");
|
|
225
234
|
await buildHydration();
|
|
226
235
|
await autoWrap();
|
|
227
236
|
await buildStyles();
|
|
@@ -229,27 +238,27 @@ async function main(): Promise<void> {
|
|
|
229
238
|
// Now start the node server with compiled dist files
|
|
230
239
|
await startNestServer();
|
|
231
240
|
|
|
232
|
-
|
|
241
|
+
logger.log("Development server ready!");
|
|
233
242
|
|
|
234
243
|
// Watch for source changes
|
|
235
244
|
watchSourceChanges();
|
|
236
245
|
|
|
237
246
|
// Handle graceful shutdown
|
|
238
247
|
process.on("SIGINT", async () => {
|
|
239
|
-
|
|
248
|
+
logger.log("Stopping development server...");
|
|
240
249
|
if (tscProcess) tscProcess.kill();
|
|
241
250
|
if (nestProcess) nestProcess.kill();
|
|
242
251
|
process.exit(0);
|
|
243
252
|
});
|
|
244
253
|
|
|
245
254
|
process.on("SIGTERM", async () => {
|
|
246
|
-
|
|
255
|
+
logger.log("Stopping development server...");
|
|
247
256
|
if (tscProcess) tscProcess.kill();
|
|
248
257
|
if (nestProcess) nestProcess.kill();
|
|
249
258
|
process.exit(0);
|
|
250
259
|
});
|
|
251
260
|
} catch (error) {
|
|
252
|
-
|
|
261
|
+
logger.error(`Fatal error: ${error}`);
|
|
253
262
|
process.exit(1);
|
|
254
263
|
}
|
|
255
264
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility to format logs matching NestJS style
|
|
3
|
+
* Format: [Harpy] PID - DATE, TIME LEVEL [Context] Message +Xms
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const pid = process.pid;
|
|
7
|
+
|
|
8
|
+
function getTimestamp(): string {
|
|
9
|
+
const now = new Date();
|
|
10
|
+
const date = now.toLocaleDateString("en-US");
|
|
11
|
+
const time = now.toLocaleTimeString("en-US");
|
|
12
|
+
return `${date}, ${time}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Logger {
|
|
16
|
+
private context: string;
|
|
17
|
+
private lastLogTime: number = Date.now();
|
|
18
|
+
|
|
19
|
+
constructor(context: string) {
|
|
20
|
+
this.context = context;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private formatMessage(level: string, message: string, levelColor: string): string {
|
|
24
|
+
const timestamp = getTimestamp();
|
|
25
|
+
const elapsed = Date.now() - this.lastLogTime;
|
|
26
|
+
this.lastLogTime = Date.now();
|
|
27
|
+
|
|
28
|
+
const harpyPid = `\x1b[32m[Harpy] ${pid}\x1b[0m`; // green
|
|
29
|
+
const timestampFormatted = `\x1b[37m${timestamp}\x1b[0m`; // white
|
|
30
|
+
const levelColored = `\x1b[${levelColor}m${level.padEnd(5)}\x1b[0m`;
|
|
31
|
+
const contextColored = `\x1b[33m[${this.context}]\x1b[0m`; // yellow
|
|
32
|
+
const messageColored = `\x1b[${levelColor}m${message}\x1b[0m`;
|
|
33
|
+
const elapsedColored = `\x1b[33m+${elapsed}ms\x1b[0m`; // yellow
|
|
34
|
+
|
|
35
|
+
return `${harpyPid} - ${timestampFormatted} ${levelColored} ${contextColored} ${messageColored} ${elapsedColored}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log(message: string): void {
|
|
39
|
+
console.log(this.formatMessage("LOG", message, "32")); // green
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
error(message: string): void {
|
|
43
|
+
console.error(this.formatMessage("ERROR", message, "31")); // red
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
warn(message: string): void {
|
|
47
|
+
console.warn(this.formatMessage("WARN", message, "33")); // yellow
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
verbose(message: string): void {
|
|
51
|
+
console.log(this.formatMessage("LOG", message, "36")); // cyan
|
|
52
|
+
}
|
|
53
|
+
}
|
package/scripts/start.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { Logger } from "./logger";
|
|
5
|
+
|
|
6
|
+
const logger = new Logger("Server");
|
|
7
|
+
|
|
8
|
+
function startServer(): void {
|
|
9
|
+
logger.log("Starting production server...");
|
|
10
|
+
|
|
11
|
+
const serverProcess = spawn("node", ["dist/main.js"], {
|
|
12
|
+
stdio: "inherit",
|
|
13
|
+
shell: false,
|
|
14
|
+
cwd: process.cwd(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
serverProcess.on("error", (error) => {
|
|
18
|
+
logger.error(`Failed to start server: ${error.message}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
serverProcess.on("exit", (code) => {
|
|
23
|
+
if (code !== 0) {
|
|
24
|
+
logger.error(`Server exited with code ${code}`);
|
|
25
|
+
process.exit(code || 1);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Handle termination signals
|
|
30
|
+
process.on("SIGINT", () => {
|
|
31
|
+
logger.log("Shutting down server...");
|
|
32
|
+
serverProcess.kill("SIGINT");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
process.on("SIGTERM", () => {
|
|
36
|
+
logger.log("Shutting down server...");
|
|
37
|
+
serverProcess.kill("SIGTERM");
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
startServer();
|
package/src/cli.ts
CHANGED
|
@@ -10,12 +10,14 @@ const scripts: Record<string, string> = {
|
|
|
10
10
|
"build-hydration": path.join(__dirname, "../scripts/build-hydration.ts"),
|
|
11
11
|
"auto-wrap": path.join(__dirname, "../scripts/auto-wrap-exports.ts"),
|
|
12
12
|
"build-styles": path.join(__dirname, "../scripts/build-page-styles.ts"),
|
|
13
|
+
build: path.join(__dirname, "../scripts/build.ts"),
|
|
14
|
+
start: path.join(__dirname, "../scripts/start.ts"),
|
|
13
15
|
dev: path.join(__dirname, "../scripts/dev.ts"),
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
if (!command || !scripts[command]) {
|
|
17
19
|
console.error("Usage: harpy <command>");
|
|
18
|
-
console.error("Commands: build-hydration, auto-wrap, build-styles
|
|
20
|
+
console.error("Commands: build, start, dev, build-hydration, auto-wrap, build-styles");
|
|
19
21
|
process.exit(1);
|
|
20
22
|
}
|
|
21
23
|
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,10 @@ export { JsxRender } from "./decorators/jsx.decorator";
|
|
|
11
11
|
export { WithLayout } from "./decorators/layout.decorator";
|
|
12
12
|
export type { MetaOptions, RenderOptions } from "./decorators/jsx.decorator";
|
|
13
13
|
|
|
14
|
+
// SEO Module
|
|
15
|
+
export { SeoModule, BaseSeoService, DefaultSeoService } from "./seo";
|
|
16
|
+
export type { SitemapUrl, RobotsConfig, SeoModuleOptions } from "./seo";
|
|
17
|
+
|
|
14
18
|
// I18n is provided in a separate package: @harpy-js/i18n
|
|
15
19
|
// Consumers should import i18n types and modules from that package.
|
|
16
20
|
|
|
@@ -29,3 +33,4 @@ export {
|
|
|
29
33
|
getActiveItemIdFromIndex,
|
|
30
34
|
getActiveItemIdFromManifest,
|
|
31
35
|
} from "./client/getActiveItemId";
|
|
36
|
+
export { useI18n } from "./client/use-i18n";
|
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SeoModule } from './seo.module';
|
|
2
|
+
export { BaseSeoService, DefaultSeoService } from './seo.service';
|
|
3
|
+
export { RobotsController } from './robots.controller';
|
|
4
|
+
export { SitemapController } from './sitemap.controller';
|
|
5
|
+
export type { SitemapUrl, RobotsConfig, SeoModuleOptions } from './seo.types';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller, Get, Header } from '@nestjs/common';
|
|
2
|
+
import { BaseSeoService } from './seo.service';
|
|
3
|
+
|
|
4
|
+
@Controller('robots.txt')
|
|
5
|
+
export class RobotsController {
|
|
6
|
+
constructor(private readonly seoService: BaseSeoService) {}
|
|
7
|
+
|
|
8
|
+
@Get()
|
|
9
|
+
@Header('Content-Type', 'text/plain')
|
|
10
|
+
@Header('Cache-Control', 'public, max-age=86400') // Cache for 24 hours
|
|
11
|
+
getRobots(): string {
|
|
12
|
+
const config = this.seoService.getRobotsConfig();
|
|
13
|
+
return this.seoService.formatRobotsTxt(config);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Module, DynamicModule, Type } from '@nestjs/common';
|
|
2
|
+
import { RobotsController } from './robots.controller';
|
|
3
|
+
import { SitemapController } from './sitemap.controller';
|
|
4
|
+
import {
|
|
5
|
+
BaseSeoService,
|
|
6
|
+
DefaultSeoService,
|
|
7
|
+
SEO_MODULE_OPTIONS,
|
|
8
|
+
} from './seo.service';
|
|
9
|
+
import type { SeoModuleOptions } from './seo.types';
|
|
10
|
+
|
|
11
|
+
@Module({})
|
|
12
|
+
export class SeoModule {
|
|
13
|
+
/**
|
|
14
|
+
* Register the SEO module with default implementation
|
|
15
|
+
*/
|
|
16
|
+
static forRoot(options?: SeoModuleOptions): DynamicModule {
|
|
17
|
+
return {
|
|
18
|
+
module: SeoModule,
|
|
19
|
+
controllers: [RobotsController, SitemapController],
|
|
20
|
+
providers: [
|
|
21
|
+
{
|
|
22
|
+
provide: SEO_MODULE_OPTIONS,
|
|
23
|
+
useValue: options || {},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
provide: BaseSeoService,
|
|
27
|
+
useClass: DefaultSeoService,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
exports: [BaseSeoService],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register the SEO module with a custom service implementation
|
|
36
|
+
* @param customService Your custom service that extends BaseSeoService
|
|
37
|
+
* @param options Optional configuration options
|
|
38
|
+
*/
|
|
39
|
+
static forRootWithService(
|
|
40
|
+
customService: Type<BaseSeoService>,
|
|
41
|
+
options?: SeoModuleOptions,
|
|
42
|
+
): DynamicModule {
|
|
43
|
+
return {
|
|
44
|
+
module: SeoModule,
|
|
45
|
+
controllers: [RobotsController, SitemapController],
|
|
46
|
+
providers: [
|
|
47
|
+
{
|
|
48
|
+
provide: SEO_MODULE_OPTIONS,
|
|
49
|
+
useValue: options || {},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
provide: BaseSeoService,
|
|
53
|
+
useClass: customService,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
exports: [BaseSeoService],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Injectable, Inject, Optional } from '@nestjs/common';
|
|
2
|
+
import type { SitemapUrl, RobotsConfig, SeoModuleOptions } from './seo.types';
|
|
3
|
+
|
|
4
|
+
export const SEO_MODULE_OPTIONS = 'SEO_MODULE_OPTIONS';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export abstract class BaseSeoService {
|
|
8
|
+
protected readonly baseUrl: string;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
@Optional()
|
|
12
|
+
@Inject(SEO_MODULE_OPTIONS)
|
|
13
|
+
protected readonly options?: SeoModuleOptions,
|
|
14
|
+
) {
|
|
15
|
+
this.baseUrl = options?.baseUrl || 'http://localhost:3000';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Override this method to provide custom sitemap URLs
|
|
20
|
+
* This can fetch from database, CMS, or any other source
|
|
21
|
+
*/
|
|
22
|
+
abstract getSitemapUrls(): Promise<SitemapUrl[]>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Override this method to provide custom robots.txt configuration
|
|
26
|
+
*/
|
|
27
|
+
abstract getRobotsConfig(): RobotsConfig;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format sitemap URLs to XML string
|
|
31
|
+
*/
|
|
32
|
+
formatSitemapXml(urls: SitemapUrl[]): string {
|
|
33
|
+
const urlEntries = urls
|
|
34
|
+
.map((entry) => {
|
|
35
|
+
const lastmod = entry.lastModified
|
|
36
|
+
? new Date(entry.lastModified).toISOString()
|
|
37
|
+
: new Date().toISOString();
|
|
38
|
+
|
|
39
|
+
return ` <url>
|
|
40
|
+
<loc>${this.escapeXml(entry.url)}</loc>
|
|
41
|
+
<lastmod>${lastmod}</lastmod>
|
|
42
|
+
${entry.changeFrequency ? `<changefreq>${entry.changeFrequency}</changefreq>` : ''}
|
|
43
|
+
${entry.priority !== undefined ? `<priority>${entry.priority}</priority>` : ''}
|
|
44
|
+
</url>`;
|
|
45
|
+
})
|
|
46
|
+
.join('\n');
|
|
47
|
+
|
|
48
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
49
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
50
|
+
${urlEntries}
|
|
51
|
+
</urlset>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Format robots.txt configuration to string
|
|
56
|
+
*/
|
|
57
|
+
formatRobotsTxt(config: RobotsConfig): string {
|
|
58
|
+
const rules = Array.isArray(config.rules) ? config.rules : [config.rules];
|
|
59
|
+
|
|
60
|
+
const rulesText = rules
|
|
61
|
+
.map((rule) => {
|
|
62
|
+
const userAgents = Array.isArray(rule.userAgent)
|
|
63
|
+
? rule.userAgent
|
|
64
|
+
: [rule.userAgent];
|
|
65
|
+
const allows = rule.allow
|
|
66
|
+
? Array.isArray(rule.allow)
|
|
67
|
+
? rule.allow
|
|
68
|
+
: [rule.allow]
|
|
69
|
+
: [];
|
|
70
|
+
const disallows = rule.disallow
|
|
71
|
+
? Array.isArray(rule.disallow)
|
|
72
|
+
? rule.disallow
|
|
73
|
+
: [rule.disallow]
|
|
74
|
+
: [];
|
|
75
|
+
|
|
76
|
+
let text = userAgents.map((ua) => `User-agent: ${ua}`).join('\n');
|
|
77
|
+
|
|
78
|
+
if (allows.length > 0) {
|
|
79
|
+
text += '\n' + allows.map((path) => `Allow: ${path}`).join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (disallows.length > 0) {
|
|
83
|
+
text +=
|
|
84
|
+
'\n' + disallows.map((path) => `Disallow: ${path}`).join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (rule.crawlDelay) {
|
|
88
|
+
text += `\nCrawl-delay: ${rule.crawlDelay}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return text;
|
|
92
|
+
})
|
|
93
|
+
.join('\n\n');
|
|
94
|
+
|
|
95
|
+
let result = rulesText;
|
|
96
|
+
|
|
97
|
+
if (config.sitemap) {
|
|
98
|
+
const sitemaps = Array.isArray(config.sitemap)
|
|
99
|
+
? config.sitemap
|
|
100
|
+
: [config.sitemap];
|
|
101
|
+
result += '\n\n' + sitemaps.map((s) => `Sitemap: ${s}`).join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (config.host) {
|
|
105
|
+
result += `\n\nHost: ${config.host}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected escapeXml(str: string): string {
|
|
112
|
+
return str
|
|
113
|
+
.replace(/&/g, '&')
|
|
114
|
+
.replace(/</g, '<')
|
|
115
|
+
.replace(/>/g, '>')
|
|
116
|
+
.replace(/"/g, '"')
|
|
117
|
+
.replace(/'/g, ''');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Default SEO service implementation
|
|
123
|
+
* Users can extend BaseSeoService to provide custom implementations
|
|
124
|
+
*/
|
|
125
|
+
@Injectable()
|
|
126
|
+
export class DefaultSeoService extends BaseSeoService {
|
|
127
|
+
async getSitemapUrls(): Promise<SitemapUrl[]> {
|
|
128
|
+
const now = new Date();
|
|
129
|
+
|
|
130
|
+
// Default homepage only
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
url: this.baseUrl,
|
|
134
|
+
lastModified: now,
|
|
135
|
+
changeFrequency: 'daily',
|
|
136
|
+
priority: 1.0,
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getRobotsConfig(): RobotsConfig {
|
|
142
|
+
const defaultConfig: RobotsConfig = {
|
|
143
|
+
rules: {
|
|
144
|
+
userAgent: '*',
|
|
145
|
+
allow: '/',
|
|
146
|
+
},
|
|
147
|
+
sitemap: `${this.baseUrl}/sitemap.xml`,
|
|
148
|
+
host: this.baseUrl,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Merge with options if provided
|
|
152
|
+
if (this.options?.robotsConfig) {
|
|
153
|
+
return {
|
|
154
|
+
...defaultConfig,
|
|
155
|
+
...this.options.robotsConfig,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return defaultConfig;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface SitemapUrl {
|
|
2
|
+
url: string;
|
|
3
|
+
lastModified?: Date | string;
|
|
4
|
+
changeFrequency?:
|
|
5
|
+
| 'always'
|
|
6
|
+
| 'hourly'
|
|
7
|
+
| 'daily'
|
|
8
|
+
| 'weekly'
|
|
9
|
+
| 'monthly'
|
|
10
|
+
| 'yearly'
|
|
11
|
+
| 'never';
|
|
12
|
+
priority?: number;
|
|
13
|
+
alternates?: {
|
|
14
|
+
languages?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RobotsConfig {
|
|
19
|
+
rules:
|
|
20
|
+
| {
|
|
21
|
+
userAgent: string | string[];
|
|
22
|
+
allow?: string | string[];
|
|
23
|
+
disallow?: string | string[];
|
|
24
|
+
crawlDelay?: number;
|
|
25
|
+
}
|
|
26
|
+
| Array<{
|
|
27
|
+
userAgent: string | string[];
|
|
28
|
+
allow?: string | string[];
|
|
29
|
+
disallow?: string | string[];
|
|
30
|
+
crawlDelay?: number;
|
|
31
|
+
}>;
|
|
32
|
+
sitemap?: string | string[];
|
|
33
|
+
host?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SeoModuleOptions {
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
robotsConfig?: Partial<RobotsConfig>;
|
|
39
|
+
sitemapGenerator?: () => Promise<SitemapUrl[]>;
|
|
40
|
+
}
|