metaowl 0.4.0 → 0.5.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 +52 -0
- package/README.md +13 -15
- 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 +141 -0
- package/build/runtime/modules/app-mounter.js +65 -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/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +205 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +167 -0
- package/build/runtime/modules/layouts.js +163 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +264 -0
- package/build/runtime/modules/pwa.js +262 -0
- package/build/runtime/modules/router.js +389 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +196 -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 +183 -0
- package/eslint.js +29 -0
- package/package.json +29 -11
- 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/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 -277
- package/vitest.config.js +0 -8
package/modules/seo.js
DELETED
|
@@ -1,501 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module SEO
|
|
3
|
-
*
|
|
4
|
-
* SEO utilities for MetaOwl applications.
|
|
5
|
-
*
|
|
6
|
-
* Features:
|
|
7
|
-
* - Sitemap generation
|
|
8
|
-
* - robots.txt generation
|
|
9
|
-
* - Meta tag helpers
|
|
10
|
-
* - Structured data (JSON-LD) helpers
|
|
11
|
-
* - Canonical URL generation
|
|
12
|
-
* - Open Graph utilities
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* import { SEO } from 'metaowl'
|
|
16
|
-
*
|
|
17
|
-
* // Generate sitemap
|
|
18
|
-
* const sitemap = SEO.generateSitemap([
|
|
19
|
-
* { url: '/', priority: 1.0, changefreq: 'daily' },
|
|
20
|
-
* { url: '/about', priority: 0.8 }
|
|
21
|
-
* ], {
|
|
22
|
-
* baseUrl: 'https://myapp.com'
|
|
23
|
-
* })
|
|
24
|
-
*
|
|
25
|
-
* // Generate robots.txt
|
|
26
|
-
* const robots = SEO.generateRobotsTxt({
|
|
27
|
-
* userAgent: '*',
|
|
28
|
-
* allow: ['/'],
|
|
29
|
-
* sitemap: 'https://myapp.com/sitemap.xml'
|
|
30
|
-
* })
|
|
31
|
-
*
|
|
32
|
-
* // JSON-LD structured data
|
|
33
|
-
* const schema = SEO.jsonLd({
|
|
34
|
-
* '@type': 'Organization',
|
|
35
|
-
* name: 'My Company',
|
|
36
|
-
* url: 'https://myapp.com'
|
|
37
|
-
* })
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @typedef {Object} SitemapEntry
|
|
42
|
-
* @property {string} url - URL path (e.g., '/about')
|
|
43
|
-
* @property {string} [lastmod] - Last modification date (ISO 8601)
|
|
44
|
-
* @property {number} [priority] - Priority (0.0 to 1.0)
|
|
45
|
-
* @property {string} [changefreq] - Change frequency (always, hourly, daily, weekly, monthly, yearly, never)
|
|
46
|
-
* @property {string} [image] - Image URL
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @typedef {Object} RobotsConfig
|
|
51
|
-
* @property {string} [userAgent='*'] - User agent
|
|
52
|
-
* @property {string[]} [allow=[]] - Allowed paths
|
|
53
|
-
* @property {string[]} [disallow=[]] - Disallowed paths
|
|
54
|
-
* @property {number} [crawlDelay] - Crawl delay in seconds
|
|
55
|
-
* @property {string} [sitemap] - Sitemap URL
|
|
56
|
-
* @property {string} [host] - Preferred host
|
|
57
|
-
*/
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @typedef {Object} JsonLdSchema
|
|
61
|
-
* @property {string} '@context' - Schema context (usually https://schema.org)
|
|
62
|
-
* @property {string} '@type' - Schema type
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Generate XML sitemap.
|
|
67
|
-
*
|
|
68
|
-
* @param {SitemapEntry[]} entries - Sitemap entries
|
|
69
|
-
* @param {Object} options - Options
|
|
70
|
-
* @param {string} options.baseUrl - Base URL for the site
|
|
71
|
-
* @returns {string} XML sitemap content
|
|
72
|
-
*
|
|
73
|
-
* @example
|
|
74
|
-
* const sitemap = generateSitemap([
|
|
75
|
-
* { url: '/', priority: 1.0, changefreq: 'daily' },
|
|
76
|
-
* { url: '/products', priority: 0.8, changefreq: 'weekly' },
|
|
77
|
-
* { url: '/about', priority: 0.5 }
|
|
78
|
-
* ], { baseUrl: 'https://myapp.com' })
|
|
79
|
-
*
|
|
80
|
-
* console.log(sitemap)
|
|
81
|
-
* // <?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
-
* // <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
83
|
-
* // <url>
|
|
84
|
-
* // <loc>https://myapp.com/</loc>
|
|
85
|
-
* // <priority>1.0</priority>
|
|
86
|
-
* // <changefreq>daily</changefreq>
|
|
87
|
-
* // </url>
|
|
88
|
-
* // ...
|
|
89
|
-
* // </urlset>
|
|
90
|
-
*/
|
|
91
|
-
export function generateSitemap(entries, options = {}) {
|
|
92
|
-
const { baseUrl } = options
|
|
93
|
-
|
|
94
|
-
if (!baseUrl) {
|
|
95
|
-
throw new Error('[SEO] baseUrl is required for sitemap generation')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Normalize base URL (remove trailing slash)
|
|
99
|
-
const normalizedBase = baseUrl.replace(/\/$/, '')
|
|
100
|
-
|
|
101
|
-
const urls = entries.map(entry => {
|
|
102
|
-
const loc = entry.url.startsWith('http')
|
|
103
|
-
? entry.url
|
|
104
|
-
: `${normalizedBase}${entry.url.startsWith('/') ? entry.url : '/' + entry.url}`
|
|
105
|
-
|
|
106
|
-
let urlXml = ` <url>\n <loc>${escapeXml(loc)}</loc>\n`
|
|
107
|
-
|
|
108
|
-
if (entry.lastmod) {
|
|
109
|
-
urlXml += ` <lastmod>${entry.lastmod}</lastmod>\n`
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (entry.changefreq) {
|
|
113
|
-
const validFreqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
|
|
114
|
-
if (validFreqs.includes(entry.changefreq)) {
|
|
115
|
-
urlXml += ` <changefreq>${entry.changefreq}</changefreq>\n`
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (entry.priority !== undefined) {
|
|
120
|
-
const priority = Math.max(0, Math.min(1, entry.priority)).toFixed(1)
|
|
121
|
-
urlXml += ` <priority>${priority}</priority>\n`
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (entry.image) {
|
|
125
|
-
urlXml += ` <image:image xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n`
|
|
126
|
-
urlXml += ` <image:loc>${escapeXml(entry.image)}</image:loc>\n`
|
|
127
|
-
urlXml += ` </image:image>\n`
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
urlXml += ' </url>'
|
|
131
|
-
return urlXml
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
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>`
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Generate robots.txt content.
|
|
139
|
-
*
|
|
140
|
-
* @param {RobotsConfig|RobotsConfig[]} config - Robots configuration
|
|
141
|
-
* @returns {string} robots.txt content
|
|
142
|
-
*
|
|
143
|
-
* @example
|
|
144
|
-
* const robots = generateRobotsTxt({
|
|
145
|
-
* userAgent: '*',
|
|
146
|
-
* allow: ['/'],
|
|
147
|
-
* disallow: ['/admin/', '/private/'],
|
|
148
|
-
* sitemap: 'https://myapp.com/sitemap.xml'
|
|
149
|
-
* })
|
|
150
|
-
*
|
|
151
|
-
* console.log(robots)
|
|
152
|
-
* // User-agent: *
|
|
153
|
-
* // Allow: /
|
|
154
|
-
* // Disallow: /admin/
|
|
155
|
-
* // Disallow: /private/
|
|
156
|
-
* //
|
|
157
|
-
* // Sitemap: https://myapp.com/sitemap.xml
|
|
158
|
-
*/
|
|
159
|
-
export function generateRobotsTxt(config = {}) {
|
|
160
|
-
const configs = Array.isArray(config) ? config : [config]
|
|
161
|
-
|
|
162
|
-
const sections = configs.map(cfg => {
|
|
163
|
-
const {
|
|
164
|
-
userAgent = '*',
|
|
165
|
-
allow = [],
|
|
166
|
-
disallow = [],
|
|
167
|
-
crawlDelay,
|
|
168
|
-
sitemap,
|
|
169
|
-
host
|
|
170
|
-
} = cfg
|
|
171
|
-
|
|
172
|
-
let section = `User-agent: ${userAgent}\n`
|
|
173
|
-
|
|
174
|
-
for (const path of allow) {
|
|
175
|
-
section += `Allow: ${path}\n`
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
for (const path of disallow) {
|
|
179
|
-
section += `Disallow: ${path}\n`
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (crawlDelay !== undefined && crawlDelay > 0) {
|
|
183
|
-
section += `Crawl-delay: ${crawlDelay}\n`
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return section.trim()
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
// Add global sitemap and host at the end
|
|
190
|
-
const globalConfig = configs.find(c => c.sitemap || c.host)
|
|
191
|
-
if (globalConfig?.sitemap) {
|
|
192
|
-
sections.push(`Sitemap: ${globalConfig.sitemap}`)
|
|
193
|
-
}
|
|
194
|
-
if (globalConfig?.host) {
|
|
195
|
-
sections.push(`Host: ${globalConfig.host}`)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return sections.join('\n\n')
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Create JSON-LD structured data script tag content.
|
|
203
|
-
*
|
|
204
|
-
* @param {Object} schema - JSON-LD schema object
|
|
205
|
-
* @param {string} [schema['@context']='https://schema.org'] - Schema context
|
|
206
|
-
* @returns {string} JSON-LD script content (without script tags)
|
|
207
|
-
*
|
|
208
|
-
* @example
|
|
209
|
-
* const schema = jsonLd({
|
|
210
|
-
* '@type': 'Organization',
|
|
211
|
-
* name: 'My Company',
|
|
212
|
-
* url: 'https://myapp.com',
|
|
213
|
-
* logo: 'https://myapp.com/logo.png'
|
|
214
|
-
* })
|
|
215
|
-
*
|
|
216
|
-
* // In template:
|
|
217
|
-
* // <script type="application/ld+json">${schema}</script>
|
|
218
|
-
*/
|
|
219
|
-
export function jsonLd(schema) {
|
|
220
|
-
const fullSchema = {
|
|
221
|
-
'@context': 'https://schema.org',
|
|
222
|
-
...schema
|
|
223
|
-
}
|
|
224
|
-
return JSON.stringify(fullSchema, null, 2)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Create canonical URL.
|
|
229
|
-
*
|
|
230
|
-
* @param {string} baseUrl - Base site URL
|
|
231
|
-
* @param {string} path - Path (can include query string)
|
|
232
|
-
* @param {Object} options - Options
|
|
233
|
-
* @param {boolean} [options.removeQueryParams=false] - Remove query parameters
|
|
234
|
-
* @param {string[]} [options.allowedParams=[]] - Query parameters to keep
|
|
235
|
-
* @returns {string} Canonical URL
|
|
236
|
-
*
|
|
237
|
-
* @example
|
|
238
|
-
* const canonical = createCanonicalUrl('https://myapp.com', '/products?id=123&sort=price')
|
|
239
|
-
* // 'https://myapp.com/products?id=123&sort=price'
|
|
240
|
-
*
|
|
241
|
-
* const cleanCanonical = createCanonicalUrl(
|
|
242
|
-
* 'https://myapp.com',
|
|
243
|
-
* '/products?id=123&utm_source=email',
|
|
244
|
-
* { allowedParams: ['id'] }
|
|
245
|
-
* )
|
|
246
|
-
* // 'https://myapp.com/products?id=123'
|
|
247
|
-
*/
|
|
248
|
-
export function createCanonicalUrl(baseUrl, path, options = {}) {
|
|
249
|
-
const { removeQueryParams = false, allowedParams = [] } = options
|
|
250
|
-
|
|
251
|
-
// Normalize base URL
|
|
252
|
-
const normalizedBase = baseUrl.replace(/\/$/, '')
|
|
253
|
-
|
|
254
|
-
// Parse path and query
|
|
255
|
-
const [pathname, queryString] = path.split('?')
|
|
256
|
-
const normalizedPath = pathname.startsWith('/') ? pathname : '/' + pathname
|
|
257
|
-
|
|
258
|
-
if (!queryString || removeQueryParams) {
|
|
259
|
-
return `${normalizedBase}${normalizedPath}`
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Filter query parameters
|
|
263
|
-
if (allowedParams.length > 0) {
|
|
264
|
-
const params = new URLSearchParams(queryString)
|
|
265
|
-
const filtered = new URLSearchParams()
|
|
266
|
-
|
|
267
|
-
for (const key of allowedParams) {
|
|
268
|
-
if (params.has(key)) {
|
|
269
|
-
filtered.set(key, params.get(key))
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const filteredQuery = filtered.toString()
|
|
274
|
-
return filteredQuery
|
|
275
|
-
? `${normalizedBase}${normalizedPath}?${filteredQuery}`
|
|
276
|
-
: `${normalizedBase}${normalizedPath}`
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return `${normalizedBase}${normalizedPath}?${queryString}`
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Generate Open Graph meta tags object.
|
|
284
|
-
*
|
|
285
|
-
* @param {Object} data - Open Graph data
|
|
286
|
-
* @param {string} data.title - Title
|
|
287
|
-
* @param {string} data.description - Description
|
|
288
|
-
* @param {string} [data.type='website'] - Type
|
|
289
|
-
* @param {string} [data.url] - URL
|
|
290
|
-
* @param {string} [data.image] - Image URL
|
|
291
|
-
* @param {string} [data.siteName] - Site name
|
|
292
|
-
* @returns {Object} Open Graph meta tags
|
|
293
|
-
*
|
|
294
|
-
* @example
|
|
295
|
-
* const og = generateOpenGraph({
|
|
296
|
-
* title: 'My Page',
|
|
297
|
-
* description: 'Page description',
|
|
298
|
-
* url: 'https://myapp.com/page',
|
|
299
|
-
* image: 'https://myapp.com/image.png',
|
|
300
|
-
* siteName: 'My App'
|
|
301
|
-
* })
|
|
302
|
-
*
|
|
303
|
-
* // Returns:
|
|
304
|
-
* // {
|
|
305
|
-
* // 'og:title': 'My Page',
|
|
306
|
-
* // 'og:description': 'Page description',
|
|
307
|
-
* // 'og:type': 'website',
|
|
308
|
-
* // 'og:url': 'https://myapp.com/page',
|
|
309
|
-
* // 'og:image': 'https://myapp.com/image.png',
|
|
310
|
-
* // 'og:site_name': 'My App'
|
|
311
|
-
* // }
|
|
312
|
-
*/
|
|
313
|
-
export function generateOpenGraph(data) {
|
|
314
|
-
const {
|
|
315
|
-
title,
|
|
316
|
-
description,
|
|
317
|
-
type = 'website',
|
|
318
|
-
url,
|
|
319
|
-
image,
|
|
320
|
-
siteName
|
|
321
|
-
} = data
|
|
322
|
-
|
|
323
|
-
const tags = {
|
|
324
|
-
'og:title': title,
|
|
325
|
-
'og:type': type
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (description) tags['og:description'] = description
|
|
329
|
-
if (url) tags['og:url'] = url
|
|
330
|
-
if (image) tags['og:image'] = image
|
|
331
|
-
if (siteName) tags['og:site_name'] = siteName
|
|
332
|
-
|
|
333
|
-
return tags
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Generate Twitter Card meta tags object.
|
|
338
|
-
*
|
|
339
|
-
* @param {Object} data - Twitter Card data
|
|
340
|
-
* @param {string} data.title - Title
|
|
341
|
-
* @param {string} data.description - Description
|
|
342
|
-
* @param {string} [data.card='summary_large_image'] - Card type
|
|
343
|
-
* @param {string} [data.image] - Image URL
|
|
344
|
-
* @param {string} [data.site] - Twitter handle
|
|
345
|
-
* @returns {Object} Twitter Card meta tags
|
|
346
|
-
*
|
|
347
|
-
* @example
|
|
348
|
-
* const twitter = generateTwitterCard({
|
|
349
|
-
* title: 'My Page',
|
|
350
|
-
* description: 'Page description',
|
|
351
|
-
* image: 'https://myapp.com/image.png',
|
|
352
|
-
* site: '@myapp'
|
|
353
|
-
* })
|
|
354
|
-
*/
|
|
355
|
-
export function generateTwitterCard(data) {
|
|
356
|
-
const {
|
|
357
|
-
title,
|
|
358
|
-
description,
|
|
359
|
-
card = 'summary_large_image',
|
|
360
|
-
image,
|
|
361
|
-
site
|
|
362
|
-
} = data
|
|
363
|
-
|
|
364
|
-
const tags = {
|
|
365
|
-
'twitter:card': card,
|
|
366
|
-
'twitter:title': title
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (description) tags['twitter:description'] = description
|
|
370
|
-
if (image) tags['twitter:image'] = image
|
|
371
|
-
if (site) tags['twitter:site'] = site
|
|
372
|
-
|
|
373
|
-
return tags
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Validate sitemap entries.
|
|
378
|
-
*
|
|
379
|
-
* @param {SitemapEntry[]} entries - Sitemap entries
|
|
380
|
-
* @returns {Object} Validation result
|
|
381
|
-
* @returns {boolean} result.valid - Whether all entries are valid
|
|
382
|
-
* @returns {string[]} result.errors - List of error messages
|
|
383
|
-
*/
|
|
384
|
-
export function validateSitemap(entries) {
|
|
385
|
-
const errors = []
|
|
386
|
-
|
|
387
|
-
for (let i = 0; i < entries.length; i++) {
|
|
388
|
-
const entry = entries[i]
|
|
389
|
-
|
|
390
|
-
if (!entry.url) {
|
|
391
|
-
errors.push(`Entry ${i}: Missing required 'url'`)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (entry.priority !== undefined) {
|
|
395
|
-
if (entry.priority < 0 || entry.priority > 1) {
|
|
396
|
-
errors.push(`Entry ${i}: Priority must be between 0 and 1`)
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (entry.changefreq) {
|
|
401
|
-
const validFreqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
|
|
402
|
-
if (!validFreqs.includes(entry.changefreq)) {
|
|
403
|
-
errors.push(`Entry ${i}: Invalid changefreq '${entry.changefreq}'`)
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (entry.lastmod) {
|
|
408
|
-
// Validate ISO 8601 date format
|
|
409
|
-
const date = new Date(entry.lastmod)
|
|
410
|
-
if (isNaN(date.getTime())) {
|
|
411
|
-
errors.push(`Entry ${i}: Invalid lastmod date '${entry.lastmod}'`)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
valid: errors.length === 0,
|
|
418
|
-
errors
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Calculate sitemap priority based on URL depth.
|
|
424
|
-
*
|
|
425
|
-
* @param {string} url - URL path
|
|
426
|
-
* @param {Object} options - Options
|
|
427
|
-
* @param {number} [options.maxDepth=3] - Maximum depth for priority calculation
|
|
428
|
-
* @returns {number} Priority (0.1 to 1.0)
|
|
429
|
-
*
|
|
430
|
-
* @example
|
|
431
|
-
* getPriorityByDepth('/') // 1.0
|
|
432
|
-
* getPriorityByDepth('/products') // 0.8
|
|
433
|
-
* getPriorityByDepth('/products/electronics/phones') // 0.5
|
|
434
|
-
*/
|
|
435
|
-
export function getPriorityByDepth(url, options = {}) {
|
|
436
|
-
const { maxDepth = 3 } = options
|
|
437
|
-
|
|
438
|
-
const depth = url.split('/').filter(Boolean).length
|
|
439
|
-
const priority = Math.max(0.1, 1 - (depth / maxDepth) * 0.3)
|
|
440
|
-
|
|
441
|
-
return Math.round(priority * 10) / 10
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Generate sitemap index file.
|
|
446
|
-
*
|
|
447
|
-
* @param {Object[]} sitemaps - Sitemap references
|
|
448
|
-
* @param {string} sitemaps[].loc - Sitemap URL
|
|
449
|
-
* @param {string} [sitemaps[].lastmod] - Last modification date
|
|
450
|
-
* @returns {string} Sitemap index XML
|
|
451
|
-
*
|
|
452
|
-
* @example
|
|
453
|
-
* const index = generateSitemapIndex([
|
|
454
|
-
* { loc: 'https://myapp.com/sitemap-products.xml', lastmod: '2024-01-01' },
|
|
455
|
-
* { loc: 'https://myapp.com/sitemap-blog.xml' }
|
|
456
|
-
* ])
|
|
457
|
-
*/
|
|
458
|
-
export function generateSitemapIndex(sitemaps) {
|
|
459
|
-
const entries = sitemaps.map(sitemap => {
|
|
460
|
-
let entry = ` <sitemap>\n <loc>${escapeXml(sitemap.loc)}</loc>\n`
|
|
461
|
-
if (sitemap.lastmod) {
|
|
462
|
-
entry += ` <lastmod>${sitemap.lastmod}</lastmod>\n`
|
|
463
|
-
}
|
|
464
|
-
entry += ' </sitemap>'
|
|
465
|
-
return entry
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
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>`
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Escape special XML characters.
|
|
473
|
-
*
|
|
474
|
-
* @param {string} str - String to escape
|
|
475
|
-
* @returns {string} Escaped string
|
|
476
|
-
*/
|
|
477
|
-
function escapeXml(str) {
|
|
478
|
-
return str
|
|
479
|
-
.replace(/&/g, '&')
|
|
480
|
-
.replace(/</g, '<')
|
|
481
|
-
.replace(/>/g, '>')
|
|
482
|
-
.replace(/"/g, '"')
|
|
483
|
-
.replace(/'/g, ''')
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* SEO namespace for convenient access.
|
|
488
|
-
*/
|
|
489
|
-
export const SEO = {
|
|
490
|
-
generateSitemap,
|
|
491
|
-
generateRobotsTxt,
|
|
492
|
-
jsonLd,
|
|
493
|
-
createCanonicalUrl,
|
|
494
|
-
generateOpenGraph,
|
|
495
|
-
generateTwitterCard,
|
|
496
|
-
validateSitemap,
|
|
497
|
-
getPriorityByDepth,
|
|
498
|
-
generateSitemapIndex
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export default SEO
|