metaowl 0.4.1 → 0.6.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 +50 -0
- package/README.md +267 -2
- package/build/runtime/bin/metaowl-build.js +10 -0
- package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
- package/build/runtime/bin/metaowl-dev.js +10 -0
- package/build/runtime/bin/metaowl-generate.js +231 -0
- package/build/runtime/bin/metaowl-lint.js +58 -0
- package/build/runtime/bin/utils.js +68 -0
- package/build/runtime/index.js +144 -0
- package/build/runtime/modules/app-mounter.js +73 -0
- package/build/runtime/modules/auto-import.js +140 -0
- package/build/runtime/modules/cache.js +49 -0
- package/build/runtime/modules/composables.js +353 -0
- package/build/runtime/modules/constants.js +38 -0
- package/build/runtime/modules/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +207 -0
- package/build/runtime/modules/fonts.js +172 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +180 -0
- package/build/runtime/modules/image.js +175 -0
- package/build/runtime/modules/layouts.js +214 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +265 -0
- package/build/runtime/modules/pwa.js +272 -0
- package/build/runtime/modules/router.js +384 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +198 -0
- package/build/runtime/modules/templates-manager.js +52 -0
- package/build/runtime/modules/test-utils.js +238 -0
- package/build/runtime/vite/plugin.js +197 -0
- package/eslint.js +29 -0
- package/package.json +45 -27
- package/CONTRIBUTING.md +0 -49
- package/bin/metaowl-build.js +0 -12
- package/bin/metaowl-dev.js +0 -12
- package/bin/metaowl-generate.js +0 -339
- package/bin/metaowl-lint.js +0 -71
- package/bin/utils.js +0 -82
- package/eslint.config.js +0 -3
- package/index.js +0 -328
- package/modules/app-mounter.js +0 -104
- package/modules/auto-import.js +0 -225
- package/modules/cache.js +0 -59
- package/modules/composables.js +0 -600
- package/modules/error-boundary.js +0 -228
- package/modules/fetch.js +0 -51
- package/modules/file-router.js +0 -478
- package/modules/forms.js +0 -353
- package/modules/i18n.js +0 -333
- package/modules/layouts.js +0 -431
- package/modules/link.js +0 -255
- package/modules/meta.js +0 -119
- package/modules/odoo-rpc.js +0 -511
- package/modules/pwa.js +0 -515
- package/modules/router.js +0 -769
- package/modules/seo.js +0 -501
- package/modules/store.js +0 -409
- package/modules/templates-manager.js +0 -89
- package/modules/test-utils.js +0 -532
- package/test/auto-import.test.js +0 -110
- package/test/cache.test.js +0 -55
- package/test/composables.test.js +0 -103
- package/test/dynamic-routes.test.js +0 -469
- package/test/error-boundary.test.js +0 -126
- package/test/fetch.test.js +0 -100
- package/test/file-router.test.js +0 -55
- package/test/forms.test.js +0 -203
- package/test/i18n.test.js +0 -188
- package/test/layouts.test.js +0 -395
- package/test/link.test.js +0 -189
- package/test/meta.test.js +0 -146
- package/test/odoo-rpc.test.js +0 -547
- package/test/pwa.test.js +0 -154
- package/test/router-guards.test.js +0 -229
- package/test/router.test.js +0 -77
- package/test/seo.test.js +0 -353
- package/test/store.test.js +0 -476
- package/test/templates-manager.test.js +0 -83
- package/test/test-utils.test.js +0 -314
- package/vite/plugin.js +0 -290
- package/vitest.config.js +0 -8
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Router
|
|
3
|
+
*
|
|
4
|
+
* Enhanced router with navigation guards support.
|
|
5
|
+
*/
|
|
6
|
+
import { buildSimpleRoutePattern } from './constants.js';
|
|
7
|
+
let currentRoute = null;
|
|
8
|
+
let previousRoute = null;
|
|
9
|
+
const beforeEachGuards = [];
|
|
10
|
+
const afterEachHooks = [];
|
|
11
|
+
let navigating = false;
|
|
12
|
+
let cancelCurrentNavigation = null;
|
|
13
|
+
let spaNavigationCallback = null;
|
|
14
|
+
let spaEnabled = false;
|
|
15
|
+
class Router {
|
|
16
|
+
routes;
|
|
17
|
+
routeMap;
|
|
18
|
+
constructor(routes) {
|
|
19
|
+
this.routes = routes;
|
|
20
|
+
this.routeMap = new Map();
|
|
21
|
+
for (const route of routes) {
|
|
22
|
+
for (const path of route.path) {
|
|
23
|
+
this.routeMap.set(path, route);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
resolve(path) {
|
|
28
|
+
const currentPath = path || document.location.pathname;
|
|
29
|
+
if (this.routeMap.has(currentPath)) {
|
|
30
|
+
return this.routeMap.get(currentPath) || null;
|
|
31
|
+
}
|
|
32
|
+
for (const route of this.routes) {
|
|
33
|
+
if (this.matchRoute(route, currentPath)) {
|
|
34
|
+
return route;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
matchRoute(route, path) {
|
|
40
|
+
for (const routePath of route.path) {
|
|
41
|
+
if (this.pathMatches(routePath, path)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
pathMatches(routePath, currentPath) {
|
|
48
|
+
if (!routePath.includes(':') && !routePath.includes('*')) {
|
|
49
|
+
const normalizedRoute = (routePath.replace(/\/$/, '') || '/');
|
|
50
|
+
const normalizedCurrent = (currentPath.replace(/\/$/, '') || '/');
|
|
51
|
+
return normalizedRoute === normalizedCurrent;
|
|
52
|
+
}
|
|
53
|
+
const pattern = buildSimpleRoutePattern(routePath);
|
|
54
|
+
return new RegExp(pattern).test(currentPath);
|
|
55
|
+
}
|
|
56
|
+
extractParams(route, path) {
|
|
57
|
+
const params = {};
|
|
58
|
+
for (const routePath of route.path) {
|
|
59
|
+
const match = this.matchAndExtract(routePath, path);
|
|
60
|
+
if (match) {
|
|
61
|
+
Object.assign(params, match);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return params;
|
|
65
|
+
}
|
|
66
|
+
matchAndExtract(routePath, currentPath) {
|
|
67
|
+
if (!routePath.includes(':')) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const paramNames = [];
|
|
71
|
+
const pattern = routePath
|
|
72
|
+
.replace(/:([^/(]+)\(\.\*\)/g, (_match, name) => {
|
|
73
|
+
paramNames.push(name);
|
|
74
|
+
return '(.*)';
|
|
75
|
+
})
|
|
76
|
+
.replace(/\/:([^/(]+)\?/g, (_match, name) => {
|
|
77
|
+
paramNames.push(name);
|
|
78
|
+
return '(?:/([^/]+))?';
|
|
79
|
+
})
|
|
80
|
+
.replace(/:([^/(?\s]+)/g, (_match, name) => {
|
|
81
|
+
paramNames.push(name);
|
|
82
|
+
return '([^/]+)';
|
|
83
|
+
});
|
|
84
|
+
const matches = currentPath.match(new RegExp('^' + pattern + '$'));
|
|
85
|
+
if (!matches)
|
|
86
|
+
return null;
|
|
87
|
+
const params = {};
|
|
88
|
+
for (let index = 0; index < paramNames.length; index++) {
|
|
89
|
+
const value = matches[index + 1];
|
|
90
|
+
if (value !== undefined) {
|
|
91
|
+
params[paramNames[index]] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return params;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const injectedRouteSets = new WeakSet();
|
|
98
|
+
export async function processRoutes(routes, customPath) {
|
|
99
|
+
const targetPath = customPath || document.location.pathname;
|
|
100
|
+
if (!injectedRouteSets.has(routes)) {
|
|
101
|
+
injectedRouteSets.add(routes);
|
|
102
|
+
for (const route of routes) {
|
|
103
|
+
const originalPaths = [...route.path];
|
|
104
|
+
for (const path of originalPaths) {
|
|
105
|
+
if (typeof path === 'string') {
|
|
106
|
+
injectSystemRoutes(route, path);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const routerInstance = new Router(routes);
|
|
112
|
+
const toRoute = routerInstance.resolve(targetPath);
|
|
113
|
+
if (!toRoute) {
|
|
114
|
+
throw new Error(`No route found for "${targetPath}".`);
|
|
115
|
+
}
|
|
116
|
+
const to = buildRouteObject(toRoute, routerInstance);
|
|
117
|
+
const from = currentRoute;
|
|
118
|
+
try {
|
|
119
|
+
await runGuards(to, from);
|
|
120
|
+
previousRoute = currentRoute;
|
|
121
|
+
currentRoute = to;
|
|
122
|
+
for (const hook of afterEachHooks) {
|
|
123
|
+
hook(to, from);
|
|
124
|
+
}
|
|
125
|
+
return [toRoute];
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (isNavigationRedirect(error) && error.path) {
|
|
129
|
+
window.location.href = error.path;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function buildRouteObject(routeDef, routerInstance) {
|
|
136
|
+
const currentPath = document.location.pathname;
|
|
137
|
+
const params = routerInstance.extractParams(routeDef, currentPath);
|
|
138
|
+
return {
|
|
139
|
+
name: routeDef.name,
|
|
140
|
+
path: routeDef.path,
|
|
141
|
+
fullPath: currentPath,
|
|
142
|
+
component: routeDef.component,
|
|
143
|
+
meta: routeDef.meta || {},
|
|
144
|
+
beforeEnter: routeDef.beforeEnter,
|
|
145
|
+
params,
|
|
146
|
+
query: parseQuery(document.location.search)
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function parseQuery(search) {
|
|
150
|
+
const query = {};
|
|
151
|
+
if (!search || search === '?')
|
|
152
|
+
return query;
|
|
153
|
+
const params = new URLSearchParams(search.substring(1));
|
|
154
|
+
for (const [key, value] of params) {
|
|
155
|
+
const existing = query[key];
|
|
156
|
+
if (existing) {
|
|
157
|
+
if (Array.isArray(existing)) {
|
|
158
|
+
existing.push(value);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
query[key] = [existing, value];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
query[key] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return query;
|
|
169
|
+
}
|
|
170
|
+
async function runGuards(to, from) {
|
|
171
|
+
navigating = true;
|
|
172
|
+
let cancelled = false;
|
|
173
|
+
cancelCurrentNavigation = () => {
|
|
174
|
+
cancelled = true;
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
for (const guard of beforeEachGuards) {
|
|
178
|
+
if (cancelled)
|
|
179
|
+
break;
|
|
180
|
+
const result = await runGuard(guard, to, from);
|
|
181
|
+
if (result === false) {
|
|
182
|
+
throw new NavigationCancelled();
|
|
183
|
+
}
|
|
184
|
+
if (typeof result === 'string') {
|
|
185
|
+
throw new NavigationRedirect(result);
|
|
186
|
+
}
|
|
187
|
+
if (result && typeof result === 'object' && 'path' in result && typeof result.path === 'string') {
|
|
188
|
+
throw new NavigationRedirect(result.path);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (to.beforeEnter && !cancelled) {
|
|
192
|
+
const result = await runGuard(to.beforeEnter, to, from);
|
|
193
|
+
if (result === false) {
|
|
194
|
+
throw new NavigationCancelled();
|
|
195
|
+
}
|
|
196
|
+
if (typeof result === 'string') {
|
|
197
|
+
throw new NavigationRedirect(result);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
navigating = false;
|
|
203
|
+
cancelCurrentNavigation = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function runGuard(guard, to, from) {
|
|
207
|
+
return await new Promise((resolve, reject) => {
|
|
208
|
+
const next = (result) => {
|
|
209
|
+
if (result instanceof Error) {
|
|
210
|
+
reject(result);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
resolve(result);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
const guardResult = guard(to, from, next);
|
|
218
|
+
if (guardResult && typeof guardResult.then === 'function') {
|
|
219
|
+
;
|
|
220
|
+
guardResult.then(resolve).catch(reject);
|
|
221
|
+
}
|
|
222
|
+
else if (guardResult !== undefined) {
|
|
223
|
+
resolve(guardResult);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
reject(error);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
export function resetRouter() {
|
|
232
|
+
beforeEachGuards.length = 0;
|
|
233
|
+
afterEachHooks.length = 0;
|
|
234
|
+
navigating = false;
|
|
235
|
+
cancelCurrentNavigation = null;
|
|
236
|
+
currentRoute = null;
|
|
237
|
+
previousRoute = null;
|
|
238
|
+
spaNavigationCallback = null;
|
|
239
|
+
spaEnabled = false;
|
|
240
|
+
}
|
|
241
|
+
class NavigationCancelled extends Error {
|
|
242
|
+
constructor() {
|
|
243
|
+
super('Navigation cancelled');
|
|
244
|
+
this.name = 'NavigationCancelled';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
class NavigationRedirect extends Error {
|
|
248
|
+
path;
|
|
249
|
+
constructor(path) {
|
|
250
|
+
super('Navigation redirect');
|
|
251
|
+
this.name = 'NavigationRedirect';
|
|
252
|
+
this.path = path;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function isNavigationRedirect(error) {
|
|
256
|
+
return error instanceof Error && error.name === 'NavigationRedirect' && 'path' in error;
|
|
257
|
+
}
|
|
258
|
+
export function beforeEach(guard) {
|
|
259
|
+
beforeEachGuards.push(guard);
|
|
260
|
+
return () => {
|
|
261
|
+
const index = beforeEachGuards.indexOf(guard);
|
|
262
|
+
if (index > -1)
|
|
263
|
+
beforeEachGuards.splice(index, 1);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export function afterEach(hook) {
|
|
267
|
+
afterEachHooks.push(hook);
|
|
268
|
+
return () => {
|
|
269
|
+
const index = afterEachHooks.indexOf(hook);
|
|
270
|
+
if (index > -1)
|
|
271
|
+
afterEachHooks.splice(index, 1);
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
export function getCurrentRoute() {
|
|
275
|
+
return currentRoute;
|
|
276
|
+
}
|
|
277
|
+
export function getPreviousRoute() {
|
|
278
|
+
return previousRoute;
|
|
279
|
+
}
|
|
280
|
+
export function isNavigating() {
|
|
281
|
+
return navigating;
|
|
282
|
+
}
|
|
283
|
+
export function cancelNavigation() {
|
|
284
|
+
if (cancelCurrentNavigation) {
|
|
285
|
+
cancelCurrentNavigation();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export function _setSpaNavigationCallback(callback) {
|
|
289
|
+
spaNavigationCallback = callback;
|
|
290
|
+
}
|
|
291
|
+
export function setSpaMode(enabled) {
|
|
292
|
+
spaEnabled = enabled;
|
|
293
|
+
}
|
|
294
|
+
export function isSpaMode() {
|
|
295
|
+
return spaEnabled;
|
|
296
|
+
}
|
|
297
|
+
export async function navigateTo(path, options = {}) {
|
|
298
|
+
const { replace = false } = options;
|
|
299
|
+
if (!spaEnabled || !spaNavigationCallback) {
|
|
300
|
+
if (replace) {
|
|
301
|
+
window.location.replace(path);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
window.location.href = path;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
if (replace) {
|
|
310
|
+
window.history.replaceState({ path }, '', path);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
window.history.pushState({ path }, '', path);
|
|
314
|
+
}
|
|
315
|
+
await spaNavigationCallback(path);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
console.error('[metaowl] SPA navigation failed:', error);
|
|
320
|
+
window.location.href = path;
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export function navigate(path, options = {}) {
|
|
325
|
+
const { replace = false, reload = true } = options;
|
|
326
|
+
if (reload || !spaEnabled) {
|
|
327
|
+
if (replace) {
|
|
328
|
+
window.location.replace(path);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
window.location.href = path;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
void navigateTo(path, { replace });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
export function push(path) {
|
|
339
|
+
void navigateTo(path, { replace: false });
|
|
340
|
+
}
|
|
341
|
+
export function replace(path) {
|
|
342
|
+
void navigateTo(path, { replace: true });
|
|
343
|
+
}
|
|
344
|
+
export function back() {
|
|
345
|
+
window.history.back();
|
|
346
|
+
}
|
|
347
|
+
export function forward() {
|
|
348
|
+
window.history.forward();
|
|
349
|
+
}
|
|
350
|
+
export function go(n) {
|
|
351
|
+
window.history.go(n);
|
|
352
|
+
}
|
|
353
|
+
export const router = {
|
|
354
|
+
beforeEach,
|
|
355
|
+
afterEach,
|
|
356
|
+
get currentRoute() { return getCurrentRoute(); },
|
|
357
|
+
get previousRoute() { return getPreviousRoute(); },
|
|
358
|
+
get isNavigating() { return isNavigating(); },
|
|
359
|
+
cancel: cancelNavigation,
|
|
360
|
+
push,
|
|
361
|
+
replace,
|
|
362
|
+
back,
|
|
363
|
+
forward,
|
|
364
|
+
go,
|
|
365
|
+
navigate,
|
|
366
|
+
navigateTo,
|
|
367
|
+
setSpaMode,
|
|
368
|
+
isSpaMode
|
|
369
|
+
};
|
|
370
|
+
function injectSystemRoutes(route, path) {
|
|
371
|
+
if (path === '/') {
|
|
372
|
+
if (!route.path.includes('/index.html'))
|
|
373
|
+
route.path.push('/index.html');
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
if (!route.path.includes(`${path}.html`))
|
|
377
|
+
route.path.push(`${path}.html`);
|
|
378
|
+
if (!route.path.includes(`${path}/`))
|
|
379
|
+
route.path.push(`${path}/`);
|
|
380
|
+
if (!route.path.includes(`${path}/index.html`))
|
|
381
|
+
route.path.push(`${path}/index.html`);
|
|
382
|
+
}
|
|
383
|
+
return route;
|
|
384
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module SEO
|
|
3
|
+
*
|
|
4
|
+
* SEO utilities for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
const VALID_CHANGE_FREQUENCIES = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
|
|
7
|
+
export function generateSitemap(entries, options = {}) {
|
|
8
|
+
const { baseUrl } = options;
|
|
9
|
+
if (!baseUrl) {
|
|
10
|
+
throw new Error('[SEO] baseUrl is required for sitemap generation');
|
|
11
|
+
}
|
|
12
|
+
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
13
|
+
const urls = entries.map((entry) => {
|
|
14
|
+
const routeUrl = entry.url ?? '';
|
|
15
|
+
const location = routeUrl.startsWith('http')
|
|
16
|
+
? routeUrl
|
|
17
|
+
: `${normalizedBase}${routeUrl.startsWith('/') ? routeUrl : '/' + routeUrl}`;
|
|
18
|
+
let urlXml = ` <url>\n <loc>${escapeXml(location)}</loc>\n`;
|
|
19
|
+
if (entry.lastmod) {
|
|
20
|
+
urlXml += ` <lastmod>${entry.lastmod}</lastmod>\n`;
|
|
21
|
+
}
|
|
22
|
+
if (entry.changefreq && VALID_CHANGE_FREQUENCIES.includes(entry.changefreq)) {
|
|
23
|
+
urlXml += ` <changefreq>${entry.changefreq}</changefreq>\n`;
|
|
24
|
+
}
|
|
25
|
+
if (entry.priority !== undefined) {
|
|
26
|
+
const priority = Math.max(0, Math.min(1, entry.priority)).toFixed(1);
|
|
27
|
+
urlXml += ` <priority>${priority}</priority>\n`;
|
|
28
|
+
}
|
|
29
|
+
if (entry.image) {
|
|
30
|
+
urlXml += ' <image:image xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n';
|
|
31
|
+
urlXml += ` <image:loc>${escapeXml(entry.image)}</image:loc>\n`;
|
|
32
|
+
urlXml += ' </image:image>\n';
|
|
33
|
+
}
|
|
34
|
+
urlXml += ' </url>';
|
|
35
|
+
return urlXml;
|
|
36
|
+
});
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`;
|
|
38
|
+
}
|
|
39
|
+
export function generateRobotsTxt(config = {}) {
|
|
40
|
+
const configs = Array.isArray(config) ? config : [config];
|
|
41
|
+
const sections = configs.map((cfg) => {
|
|
42
|
+
const { userAgent = '*', allow = [], disallow = [], crawlDelay } = cfg;
|
|
43
|
+
let section = `User-agent: ${userAgent}\n`;
|
|
44
|
+
for (const path of allow) {
|
|
45
|
+
section += `Allow: ${path}\n`;
|
|
46
|
+
}
|
|
47
|
+
for (const path of disallow) {
|
|
48
|
+
section += `Disallow: ${path}\n`;
|
|
49
|
+
}
|
|
50
|
+
if (crawlDelay !== undefined && crawlDelay > 0) {
|
|
51
|
+
section += `Crawl-delay: ${crawlDelay}\n`;
|
|
52
|
+
}
|
|
53
|
+
return section.trim();
|
|
54
|
+
});
|
|
55
|
+
const globalConfig = configs.find((cfg) => cfg.sitemap || cfg.host);
|
|
56
|
+
if (globalConfig?.sitemap) {
|
|
57
|
+
sections.push(`Sitemap: ${globalConfig.sitemap}`);
|
|
58
|
+
}
|
|
59
|
+
if (globalConfig?.host) {
|
|
60
|
+
sections.push(`Host: ${globalConfig.host}`);
|
|
61
|
+
}
|
|
62
|
+
return sections.join('\n\n');
|
|
63
|
+
}
|
|
64
|
+
export function jsonLd(schema) {
|
|
65
|
+
const fullSchema = {
|
|
66
|
+
'@context': 'https://schema.org',
|
|
67
|
+
...schema
|
|
68
|
+
};
|
|
69
|
+
return JSON.stringify(fullSchema, null, 2);
|
|
70
|
+
}
|
|
71
|
+
export function createCanonicalUrl(baseUrl, path, options = {}) {
|
|
72
|
+
const { removeQueryParams = false, allowedParams = [] } = options;
|
|
73
|
+
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
74
|
+
const [pathname, queryString] = path.split('?');
|
|
75
|
+
const normalizedPath = pathname.startsWith('/') ? pathname : '/' + pathname;
|
|
76
|
+
if (!queryString || removeQueryParams) {
|
|
77
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
78
|
+
}
|
|
79
|
+
if (allowedParams.length > 0) {
|
|
80
|
+
const params = new URLSearchParams(queryString);
|
|
81
|
+
const filtered = new URLSearchParams();
|
|
82
|
+
for (const key of allowedParams) {
|
|
83
|
+
const value = params.get(key);
|
|
84
|
+
if (value !== null) {
|
|
85
|
+
filtered.set(key, value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const filteredQuery = filtered.toString();
|
|
89
|
+
return filteredQuery
|
|
90
|
+
? `${normalizedBase}${normalizedPath}?${filteredQuery}`
|
|
91
|
+
: `${normalizedBase}${normalizedPath}`;
|
|
92
|
+
}
|
|
93
|
+
return `${normalizedBase}${normalizedPath}?${queryString}`;
|
|
94
|
+
}
|
|
95
|
+
export function generateOpenGraph(data) {
|
|
96
|
+
const { title, description, type = 'website', url, image, siteName } = data;
|
|
97
|
+
const tags = {
|
|
98
|
+
'og:title': title,
|
|
99
|
+
'og:type': type
|
|
100
|
+
};
|
|
101
|
+
if (description)
|
|
102
|
+
tags['og:description'] = description;
|
|
103
|
+
if (url)
|
|
104
|
+
tags['og:url'] = url;
|
|
105
|
+
if (image)
|
|
106
|
+
tags['og:image'] = image;
|
|
107
|
+
if (siteName)
|
|
108
|
+
tags['og:site_name'] = siteName;
|
|
109
|
+
return tags;
|
|
110
|
+
}
|
|
111
|
+
export function generateTwitterCard(data) {
|
|
112
|
+
const { title, description, card = 'summary_large_image', image, site } = data;
|
|
113
|
+
const tags = {
|
|
114
|
+
'twitter:card': card,
|
|
115
|
+
'twitter:title': title
|
|
116
|
+
};
|
|
117
|
+
if (description)
|
|
118
|
+
tags['twitter:description'] = description;
|
|
119
|
+
if (image)
|
|
120
|
+
tags['twitter:image'] = image;
|
|
121
|
+
if (site)
|
|
122
|
+
tags['twitter:site'] = site;
|
|
123
|
+
return tags;
|
|
124
|
+
}
|
|
125
|
+
export function validateSitemap(entries) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
for (let index = 0; index < entries.length; index++) {
|
|
128
|
+
const entry = entries[index];
|
|
129
|
+
if (!entry.url) {
|
|
130
|
+
errors.push(`Entry ${index}: Missing required 'url'`);
|
|
131
|
+
}
|
|
132
|
+
if (entry.priority !== undefined && (entry.priority < 0 || entry.priority > 1)) {
|
|
133
|
+
errors.push(`Entry ${index}: Priority must be between 0 and 1`);
|
|
134
|
+
}
|
|
135
|
+
if (entry.changefreq && !VALID_CHANGE_FREQUENCIES.includes(entry.changefreq)) {
|
|
136
|
+
errors.push(`Entry ${index}: Invalid changefreq '${entry.changefreq}'`);
|
|
137
|
+
}
|
|
138
|
+
if (entry.lastmod) {
|
|
139
|
+
const date = new Date(entry.lastmod);
|
|
140
|
+
if (Number.isNaN(date.getTime())) {
|
|
141
|
+
errors.push(`Entry ${index}: Invalid lastmod date '${entry.lastmod}'`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
valid: errors.length === 0,
|
|
147
|
+
errors
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function getPriorityByDepth(url, options = {}) {
|
|
151
|
+
const { maxDepth = 3 } = options;
|
|
152
|
+
const depth = url.split('/').filter(Boolean).length;
|
|
153
|
+
const priority = Math.max(0.1, 1 - (depth / maxDepth) * 0.3);
|
|
154
|
+
return Math.round(priority * 10) / 10;
|
|
155
|
+
}
|
|
156
|
+
export function generateSitemapIndex(sitemaps) {
|
|
157
|
+
const entries = sitemaps.map((sitemap) => {
|
|
158
|
+
let entry = ` <sitemap>\n <loc>${escapeXml(sitemap.loc)}</loc>\n`;
|
|
159
|
+
if (sitemap.lastmod) {
|
|
160
|
+
entry += ` <lastmod>${sitemap.lastmod}</lastmod>\n`;
|
|
161
|
+
}
|
|
162
|
+
entry += ' </sitemap>';
|
|
163
|
+
return entry;
|
|
164
|
+
});
|
|
165
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join('\n')}\n</sitemapindex>`;
|
|
166
|
+
}
|
|
167
|
+
function escapeXml(str) {
|
|
168
|
+
return str
|
|
169
|
+
.replace(/&/g, '&')
|
|
170
|
+
.replace(/</g, '<')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/"/g, '"')
|
|
173
|
+
.replace(/'/g, ''');
|
|
174
|
+
}
|
|
175
|
+
export const SEO = {
|
|
176
|
+
generateSitemap,
|
|
177
|
+
generateRobotsTxt,
|
|
178
|
+
jsonLd,
|
|
179
|
+
createCanonicalUrl,
|
|
180
|
+
generateOpenGraph,
|
|
181
|
+
generateTwitterCard,
|
|
182
|
+
validateSitemap,
|
|
183
|
+
getPriorityByDepth,
|
|
184
|
+
generateSitemapIndex
|
|
185
|
+
};
|
|
186
|
+
export default SEO;
|