@stati/core 1.6.3 → 1.7.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/README.md +2 -2
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +45 -8
- package/dist/core/content.d.ts.map +1 -1
- package/dist/core/content.js +1 -2
- package/dist/core/dev.d.ts.map +1 -1
- package/dist/core/dev.js +2 -5
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +12 -0
- package/dist/core/invalidate.d.ts.map +1 -1
- package/dist/core/invalidate.js +13 -7
- package/dist/core/isg/build-lock.js +1 -1
- package/dist/core/isg/deps.d.ts.map +1 -1
- package/dist/core/isg/deps.js +1 -3
- package/dist/core/isg/hash.js +1 -1
- package/dist/core/isg/index.d.ts +16 -0
- package/dist/core/isg/index.d.ts.map +1 -0
- package/dist/core/isg/index.js +22 -0
- package/dist/core/isg/manifest.js +1 -1
- package/dist/core/preview.d.ts.map +1 -1
- package/dist/core/preview.js +1 -2
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +4 -7
- package/dist/core/utils/index.d.ts +16 -0
- package/dist/core/utils/index.d.ts.map +1 -0
- package/dist/core/utils/index.js +22 -0
- package/dist/core/utils/partial-validation.d.ts.map +1 -1
- package/dist/core/utils/partial-validation.js +2 -1
- package/dist/index.d.ts +6 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/seo/auto-inject.d.ts +48 -0
- package/dist/seo/auto-inject.d.ts.map +1 -0
- package/dist/seo/auto-inject.js +108 -0
- package/dist/seo/generator.d.ts +77 -0
- package/dist/seo/generator.d.ts.map +1 -0
- package/dist/seo/generator.js +320 -0
- package/dist/seo/index.d.ts +12 -0
- package/dist/seo/index.d.ts.map +1 -0
- package/dist/seo/index.js +15 -0
- package/dist/seo/robots.d.ts +84 -0
- package/dist/seo/robots.d.ts.map +1 -0
- package/dist/seo/robots.js +165 -0
- package/dist/seo/sitemap.d.ts +37 -0
- package/dist/seo/sitemap.d.ts.map +1 -0
- package/dist/seo/sitemap.js +320 -0
- package/dist/seo/utils/escape-and-validation.d.ts +99 -0
- package/dist/seo/utils/escape-and-validation.d.ts.map +1 -0
- package/dist/seo/utils/escape-and-validation.js +319 -0
- package/dist/seo/utils/index.d.ts +7 -0
- package/dist/seo/utils/index.d.ts.map +1 -0
- package/dist/seo/utils/index.js +8 -0
- package/dist/seo/utils/url.d.ts +46 -0
- package/dist/seo/utils/url.d.ts.map +1 -0
- package/dist/seo/utils/url.js +66 -0
- package/dist/seo/utils.d.ts +94 -0
- package/dist/seo/utils.d.ts.map +1 -0
- package/dist/seo/utils.js +304 -0
- package/dist/types/config.d.ts +58 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/content.d.ts +181 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/index.d.ts +5 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/seo.d.ts +69 -0
- package/dist/types/seo.d.ts.map +1 -0
- package/dist/types/seo.js +36 -0
- package/dist/types/sitemap.d.ts +94 -0
- package/dist/types/sitemap.d.ts.map +1 -0
- package/dist/types/sitemap.js +4 -0
- package/package.json +1 -1
- package/dist/core/utils/partials.d.ts +0 -24
- package/dist/core/utils/partials.d.ts.map +0 -1
- package/dist/core/utils/partials.js +0 -85
- package/dist/tests/utils/test-mocks.d.ts +0 -69
- package/dist/tests/utils/test-mocks.d.ts.map +0 -1
- package/dist/tests/utils/test-mocks.js +0 -125
- package/dist/types.d.ts +0 -543
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic SEO tag injection utilities
|
|
3
|
+
* @module seo/auto-inject
|
|
4
|
+
*/
|
|
5
|
+
import { detectExistingSEOTags } from './utils/index.js';
|
|
6
|
+
import { generateSEOMetadata } from './generator.js';
|
|
7
|
+
/**
|
|
8
|
+
* Helper function to conditionally log debug messages for SEO auto-injection.
|
|
9
|
+
* Checks both the explicit debug flag and the config-level debug setting.
|
|
10
|
+
*
|
|
11
|
+
* @param message - Debug message to log
|
|
12
|
+
* @param options - Object containing debug flag, config, and logger
|
|
13
|
+
*/
|
|
14
|
+
function logDebug(message, options) {
|
|
15
|
+
if (options.debug || options.config.seo?.debug) {
|
|
16
|
+
const logMessage = `[SEO Auto-Inject] ${message}`;
|
|
17
|
+
options.logger.warning(logMessage);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Finds the position to inject SEO tags (before </head>)
|
|
22
|
+
* @param html - HTML content
|
|
23
|
+
* @returns Position index or -1 if not found
|
|
24
|
+
*/
|
|
25
|
+
function findHeadClosePosition(html) {
|
|
26
|
+
// Case-insensitive search for </head>
|
|
27
|
+
const match = html.match(/<\/head>/i);
|
|
28
|
+
return match ? (match.index ?? -1) : -1;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Automatically injects SEO metadata into HTML if not already present
|
|
32
|
+
* @param html - Rendered HTML content
|
|
33
|
+
* @param options - Auto-injection options
|
|
34
|
+
* @returns HTML with injected SEO tags
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* const html = '<html><head><title>Page</title></head><body>Content</body></html>';
|
|
39
|
+
* const enhanced = autoInjectSEO(html, {
|
|
40
|
+
* page: pageModel,
|
|
41
|
+
* config: statiConfig,
|
|
42
|
+
* siteUrl: 'https://example.com'
|
|
43
|
+
* });
|
|
44
|
+
* // Returns HTML with additional SEO meta tags injected
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function autoInjectSEO(html, options) {
|
|
48
|
+
const { page, config, siteUrl, debug, logger } = options;
|
|
49
|
+
// Check if auto-injection is enabled (default: true)
|
|
50
|
+
const autoInjectEnabled = config.seo?.autoInject !== false;
|
|
51
|
+
if (!autoInjectEnabled) {
|
|
52
|
+
logDebug(`Skipped for ${page.url} (disabled in config)`, { debug, config, logger });
|
|
53
|
+
return html;
|
|
54
|
+
}
|
|
55
|
+
// Detect existing SEO tags in the HTML
|
|
56
|
+
const existingTags = detectExistingSEOTags(html);
|
|
57
|
+
logDebug(`Existing tags in ${page.url}: ${Array.from(existingTags).join(', ')}`, {
|
|
58
|
+
debug,
|
|
59
|
+
config,
|
|
60
|
+
logger,
|
|
61
|
+
});
|
|
62
|
+
// Build context with optional exclude parameter and logger
|
|
63
|
+
const context = {
|
|
64
|
+
page,
|
|
65
|
+
config,
|
|
66
|
+
siteUrl,
|
|
67
|
+
logger,
|
|
68
|
+
};
|
|
69
|
+
// Only add exclude if we have existing tags
|
|
70
|
+
if (existingTags.size > 0) {
|
|
71
|
+
context.exclude = existingTags;
|
|
72
|
+
}
|
|
73
|
+
// Generate SEO metadata excluding existing tags
|
|
74
|
+
const seoMetadata = generateSEOMetadata(context);
|
|
75
|
+
// If no SEO metadata was generated (all tags exist), return original HTML
|
|
76
|
+
if (!seoMetadata || seoMetadata.trim().length === 0) {
|
|
77
|
+
logDebug(`No tags to inject for ${page.url} (all exist)`, { debug, config, logger });
|
|
78
|
+
return html;
|
|
79
|
+
}
|
|
80
|
+
// Find position to inject (before </head>)
|
|
81
|
+
const headClosePos = findHeadClosePosition(html);
|
|
82
|
+
if (headClosePos === -1) {
|
|
83
|
+
logDebug(`No </head> tag found in ${page.url}, skipping injection`, { debug, config, logger });
|
|
84
|
+
return html;
|
|
85
|
+
}
|
|
86
|
+
// Inject SEO metadata before </head>
|
|
87
|
+
const before = html.substring(0, headClosePos);
|
|
88
|
+
const after = html.substring(headClosePos);
|
|
89
|
+
// Add proper indentation (2 spaces) and newline
|
|
90
|
+
const injected = `${before} ${seoMetadata}\n${after}`;
|
|
91
|
+
logDebug(`Injected ${existingTags.size === 0 ? 'all' : 'missing'} SEO tags into ${page.url}`, {
|
|
92
|
+
debug,
|
|
93
|
+
config,
|
|
94
|
+
logger,
|
|
95
|
+
});
|
|
96
|
+
return injected;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Checks if auto-injection is enabled for a page
|
|
100
|
+
* @param config - Site configuration
|
|
101
|
+
* @param _page - Page model (reserved for future page-level overrides)
|
|
102
|
+
* @returns true if auto-injection should run
|
|
103
|
+
*/
|
|
104
|
+
export function shouldAutoInject(config, _page) {
|
|
105
|
+
// Check global config
|
|
106
|
+
const globalEnabled = config.seo?.autoInject !== false;
|
|
107
|
+
return globalEnabled;
|
|
108
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO metadata generation module
|
|
3
|
+
* Generates meta tags, Open Graph tags, Twitter Cards, and structured data
|
|
4
|
+
*/
|
|
5
|
+
import type { SEOContext } from '../types/seo.js';
|
|
6
|
+
import type { PageModel } from '../types/content.js';
|
|
7
|
+
import type { StatiConfig, SiteConfig } from '../types/config.js';
|
|
8
|
+
import { SEOTagType } from '../types/seo.js';
|
|
9
|
+
/**
|
|
10
|
+
* Generate complete SEO metadata for a page.
|
|
11
|
+
* Supports both whitelist (include) and blacklist (exclude) modes for selective tag generation.
|
|
12
|
+
*
|
|
13
|
+
* Note: Validation errors are logged as warnings rather than throwing to allow builds to
|
|
14
|
+
* continue with degraded SEO. This prevents a single SEO issue from blocking the entire build.
|
|
15
|
+
*
|
|
16
|
+
* @param ctx - SEO context containing page, config, siteUrl, and optional include/exclude sets
|
|
17
|
+
* @returns HTML string containing all generated SEO tags
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const seoTags = generateSEOMetadata({
|
|
22
|
+
* page,
|
|
23
|
+
* config,
|
|
24
|
+
* siteUrl: 'https://example.com',
|
|
25
|
+
* exclude: new Set([SEOTagType.Twitter]) // Skip Twitter tags
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function generateSEOMetadata(ctx: SEOContext): string;
|
|
30
|
+
/**
|
|
31
|
+
* Generate Open Graph protocol meta tags.
|
|
32
|
+
* Implements fallback chains for all OG properties to ensure complete metadata.
|
|
33
|
+
*
|
|
34
|
+
* @param ctx - SEO context containing page, config, and siteUrl
|
|
35
|
+
* @returns Array of Open Graph meta tag strings
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const ogTags = generateOpenGraphTags(ctx);
|
|
40
|
+
* // Returns: ['<meta property="og:title" content="...">', ...]
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function generateOpenGraphTags(ctx: SEOContext): string[];
|
|
44
|
+
/**
|
|
45
|
+
* Generate Twitter Card meta tags.
|
|
46
|
+
* Implements fallback chains to ensure complete card metadata.
|
|
47
|
+
*
|
|
48
|
+
* @param ctx - SEO context containing page, config, and siteUrl
|
|
49
|
+
* @returns Array of Twitter Card meta tag strings
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* const twitterTags = generateTwitterCardTags(ctx);
|
|
54
|
+
* // Returns: ['<meta name="twitter:card" content="summary_large_image">', ...]
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function generateTwitterCardTags(ctx: SEOContext): string[];
|
|
58
|
+
/**
|
|
59
|
+
* Template helper function for generating SEO tags in Eta templates.
|
|
60
|
+
* Provides a convenient API for selective SEO tag generation.
|
|
61
|
+
*
|
|
62
|
+
* @param context - Object containing page, config, and optional site
|
|
63
|
+
* @param tags - Optional array of tag types to generate (strings or SEOTagType enums)
|
|
64
|
+
* @returns HTML string containing generated SEO tags
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```eta
|
|
68
|
+
* <%~ stati.generateSEO(stati) %>
|
|
69
|
+
* <%~ stati.generateSEO(stati, ['title', 'description', 'opengraph']) %>
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function generateSEO(context: {
|
|
73
|
+
page: PageModel;
|
|
74
|
+
config: StatiConfig;
|
|
75
|
+
site?: SiteConfig;
|
|
76
|
+
}, tags?: Array<SEOTagType | string>): string;
|
|
77
|
+
//# sourceMappingURL=generator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/seo/generator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAElE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAI7C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAsH3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,EAAE,CAyE/D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,EAAE,CAuDjE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE;IACP,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB,EACD,IAAI,CAAC,EAAE,KAAK,CAAC,UAAU,GAAG,MAAM,CAAC,GAChC,MAAM,CA6CR"}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO metadata generation module
|
|
3
|
+
* Generates meta tags, Open Graph tags, Twitter Cards, and structured data
|
|
4
|
+
*/
|
|
5
|
+
import { SEOTagType } from '../types/seo.js';
|
|
6
|
+
import { escapeHtml, validateSEOMetadata, generateRobotsContent } from './utils/index.js';
|
|
7
|
+
import { sanitizeStructuredData } from './utils/escape-and-validation.js';
|
|
8
|
+
/**
|
|
9
|
+
* Generate complete SEO metadata for a page.
|
|
10
|
+
* Supports both whitelist (include) and blacklist (exclude) modes for selective tag generation.
|
|
11
|
+
*
|
|
12
|
+
* Note: Validation errors are logged as warnings rather than throwing to allow builds to
|
|
13
|
+
* continue with degraded SEO. This prevents a single SEO issue from blocking the entire build.
|
|
14
|
+
*
|
|
15
|
+
* @param ctx - SEO context containing page, config, siteUrl, and optional include/exclude sets
|
|
16
|
+
* @returns HTML string containing all generated SEO tags
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const seoTags = generateSEOMetadata({
|
|
21
|
+
* page,
|
|
22
|
+
* config,
|
|
23
|
+
* siteUrl: 'https://example.com',
|
|
24
|
+
* exclude: new Set([SEOTagType.Twitter]) // Skip Twitter tags
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function generateSEOMetadata(ctx) {
|
|
29
|
+
const { page, config, siteUrl, exclude, include, logger } = ctx;
|
|
30
|
+
const seo = page.frontMatter.seo || {};
|
|
31
|
+
// Validate SEO metadata
|
|
32
|
+
const validation = validateSEOMetadata(seo, page.url);
|
|
33
|
+
// Log validation errors as warnings instead of throwing
|
|
34
|
+
// This allows builds to continue even with SEO issues
|
|
35
|
+
if (!validation.valid) {
|
|
36
|
+
logger.warning(`SEO validation failed for ${page.url}:`);
|
|
37
|
+
validation.errors.forEach((error) => {
|
|
38
|
+
logger.warning(` - ${error}`);
|
|
39
|
+
});
|
|
40
|
+
if (config.seo?.debug) {
|
|
41
|
+
logger.warning('Build will continue, but SEO metadata may be incomplete or invalid.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Log warnings if any and debug is enabled
|
|
45
|
+
if (validation.warnings.length > 0 && config.seo?.debug) {
|
|
46
|
+
logger.warning(`SEO warnings for ${page.url}:`);
|
|
47
|
+
validation.warnings.forEach((warning) => {
|
|
48
|
+
logger.warning(` - ${warning}`);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const meta = [];
|
|
52
|
+
/**
|
|
53
|
+
* Helper function to determine if a specific tag type should be generated.
|
|
54
|
+
*
|
|
55
|
+
* Three modes:
|
|
56
|
+
* 1. Whitelist mode (include set provided): Only generate tags in the include set
|
|
57
|
+
* 2. Blacklist mode (exclude set provided): Generate all tags except those in exclude set
|
|
58
|
+
* 3. Default mode (neither provided): Generate all tags
|
|
59
|
+
*
|
|
60
|
+
* @param tagType - The SEO tag type to check
|
|
61
|
+
* @returns True if the tag should be generated
|
|
62
|
+
*/
|
|
63
|
+
const shouldGenerate = (tagType) => {
|
|
64
|
+
// Whitelist mode: only generate explicitly included tags
|
|
65
|
+
if (include) {
|
|
66
|
+
return include.has(tagType);
|
|
67
|
+
}
|
|
68
|
+
// Blacklist mode: generate unless explicitly excluded
|
|
69
|
+
if (exclude) {
|
|
70
|
+
return !exclude.has(tagType);
|
|
71
|
+
}
|
|
72
|
+
// Default: generate all tags
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
// Title tag
|
|
76
|
+
if (shouldGenerate(SEOTagType.Title)) {
|
|
77
|
+
const title = seo.title || page.frontMatter.title || config.site.title;
|
|
78
|
+
meta.push(`<title>${escapeHtml(title)}</title>`);
|
|
79
|
+
}
|
|
80
|
+
// Description meta tag
|
|
81
|
+
if (shouldGenerate(SEOTagType.Description)) {
|
|
82
|
+
const description = seo.description || page.frontMatter.description;
|
|
83
|
+
if (description) {
|
|
84
|
+
meta.push(`<meta name="description" content="${escapeHtml(description)}">`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Keywords meta tag
|
|
88
|
+
if (shouldGenerate(SEOTagType.Keywords)) {
|
|
89
|
+
const keywords = seo.keywords || page.frontMatter.tags;
|
|
90
|
+
if (keywords && keywords.length > 0) {
|
|
91
|
+
const keywordsArray = Array.isArray(keywords) ? keywords : [keywords];
|
|
92
|
+
meta.push(`<meta name="keywords" content="${keywordsArray.map(escapeHtml).join(', ')}">`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Canonical link
|
|
96
|
+
if (shouldGenerate(SEOTagType.Canonical)) {
|
|
97
|
+
const canonical = seo.canonical || `${siteUrl}${page.url}`;
|
|
98
|
+
meta.push(`<link rel="canonical" href="${escapeHtml(canonical)}">`);
|
|
99
|
+
}
|
|
100
|
+
// Robots meta tag
|
|
101
|
+
if (shouldGenerate(SEOTagType.Robots)) {
|
|
102
|
+
const robotsContent = generateRobotsContent(seo);
|
|
103
|
+
if (robotsContent) {
|
|
104
|
+
meta.push(`<meta name="robots" content="${robotsContent}">`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Author meta tag
|
|
108
|
+
if (shouldGenerate(SEOTagType.Author)) {
|
|
109
|
+
const author = seo.author || config.seo?.defaultAuthor;
|
|
110
|
+
if (author) {
|
|
111
|
+
const authorName = typeof author === 'string' ? author : author.name;
|
|
112
|
+
meta.push(`<meta name="author" content="${escapeHtml(authorName)}">`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Open Graph tags
|
|
116
|
+
if (shouldGenerate(SEOTagType.OpenGraph)) {
|
|
117
|
+
meta.push(...generateOpenGraphTags(ctx));
|
|
118
|
+
}
|
|
119
|
+
// Twitter Card tags
|
|
120
|
+
if (shouldGenerate(SEOTagType.Twitter)) {
|
|
121
|
+
meta.push(...generateTwitterCardTags(ctx));
|
|
122
|
+
}
|
|
123
|
+
// JSON-LD Structured Data
|
|
124
|
+
if (shouldGenerate(SEOTagType.StructuredData) && seo.structuredData) {
|
|
125
|
+
const sanitized = sanitizeStructuredData(seo.structuredData, logger);
|
|
126
|
+
meta.push(`<script type="application/ld+json">${JSON.stringify(sanitized)}</script>`);
|
|
127
|
+
}
|
|
128
|
+
return meta.join('\n ');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Generate Open Graph protocol meta tags.
|
|
132
|
+
* Implements fallback chains for all OG properties to ensure complete metadata.
|
|
133
|
+
*
|
|
134
|
+
* @param ctx - SEO context containing page, config, and siteUrl
|
|
135
|
+
* @returns Array of Open Graph meta tag strings
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* const ogTags = generateOpenGraphTags(ctx);
|
|
140
|
+
* // Returns: ['<meta property="og:title" content="...">', ...]
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export function generateOpenGraphTags(ctx) {
|
|
144
|
+
const { page, config, siteUrl } = ctx;
|
|
145
|
+
const seo = page.frontMatter.seo || {};
|
|
146
|
+
const og = seo.openGraph || {};
|
|
147
|
+
const tags = [];
|
|
148
|
+
// Basic OG tags with fallback chain
|
|
149
|
+
const ogTitle = og.title || seo.title || page.frontMatter.title || config.site.title;
|
|
150
|
+
const ogDescription = og.description || seo.description || page.frontMatter.description;
|
|
151
|
+
const ogUrl = og.url || seo.canonical || `${siteUrl}${page.url}`;
|
|
152
|
+
const ogType = og.type || 'website';
|
|
153
|
+
const ogSiteName = og.siteName || config.site.title;
|
|
154
|
+
tags.push(`<meta property="og:title" content="${escapeHtml(ogTitle)}">`);
|
|
155
|
+
if (ogDescription) {
|
|
156
|
+
tags.push(`<meta property="og:description" content="${escapeHtml(ogDescription)}">`);
|
|
157
|
+
}
|
|
158
|
+
tags.push(`<meta property="og:url" content="${escapeHtml(ogUrl)}">`);
|
|
159
|
+
tags.push(`<meta property="og:type" content="${escapeHtml(ogType)}">`);
|
|
160
|
+
tags.push(`<meta property="og:site_name" content="${escapeHtml(ogSiteName)}">`);
|
|
161
|
+
// OG Image
|
|
162
|
+
if (og.image) {
|
|
163
|
+
const image = typeof og.image === 'string' ? { url: og.image } : og.image;
|
|
164
|
+
const imageUrl = image.url.startsWith('/') ? `${siteUrl}${image.url}` : image.url;
|
|
165
|
+
tags.push(`<meta property="og:image" content="${escapeHtml(imageUrl)}">`);
|
|
166
|
+
if (image.alt) {
|
|
167
|
+
tags.push(`<meta property="og:image:alt" content="${escapeHtml(image.alt)}">`);
|
|
168
|
+
}
|
|
169
|
+
if (image.width) {
|
|
170
|
+
tags.push(`<meta property="og:image:width" content="${image.width}">`);
|
|
171
|
+
}
|
|
172
|
+
if (image.height) {
|
|
173
|
+
tags.push(`<meta property="og:image:height" content="${image.height}">`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// OG Locale
|
|
177
|
+
if (og.locale || config.site.defaultLocale) {
|
|
178
|
+
const locale = og.locale || config.site.defaultLocale;
|
|
179
|
+
if (locale) {
|
|
180
|
+
tags.push(`<meta property="og:locale" content="${escapeHtml(locale)}">`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// OG Article metadata (only for article type)
|
|
184
|
+
if (og.article && ogType === 'article') {
|
|
185
|
+
const article = og.article;
|
|
186
|
+
if (article.publishedTime) {
|
|
187
|
+
tags.push(`<meta property="article:published_time" content="${escapeHtml(article.publishedTime)}">`);
|
|
188
|
+
}
|
|
189
|
+
if (article.modifiedTime) {
|
|
190
|
+
tags.push(`<meta property="article:modified_time" content="${escapeHtml(article.modifiedTime)}">`);
|
|
191
|
+
}
|
|
192
|
+
if (article.author) {
|
|
193
|
+
tags.push(`<meta property="article:author" content="${escapeHtml(article.author)}">`);
|
|
194
|
+
}
|
|
195
|
+
if (article.section) {
|
|
196
|
+
tags.push(`<meta property="article:section" content="${escapeHtml(article.section)}">`);
|
|
197
|
+
}
|
|
198
|
+
if (article.tags && article.tags.length > 0) {
|
|
199
|
+
article.tags.forEach((tag) => {
|
|
200
|
+
tags.push(`<meta property="article:tag" content="${escapeHtml(tag)}">`);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return tags;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Generate Twitter Card meta tags.
|
|
208
|
+
* Implements fallback chains to ensure complete card metadata.
|
|
209
|
+
*
|
|
210
|
+
* @param ctx - SEO context containing page, config, and siteUrl
|
|
211
|
+
* @returns Array of Twitter Card meta tag strings
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```typescript
|
|
215
|
+
* const twitterTags = generateTwitterCardTags(ctx);
|
|
216
|
+
* // Returns: ['<meta name="twitter:card" content="summary_large_image">', ...]
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
export function generateTwitterCardTags(ctx) {
|
|
220
|
+
const { page, config, siteUrl } = ctx;
|
|
221
|
+
const seo = page.frontMatter.seo || {};
|
|
222
|
+
const twitter = seo.twitter || {};
|
|
223
|
+
const tags = [];
|
|
224
|
+
// Card type
|
|
225
|
+
const card = twitter.card || 'summary_large_image';
|
|
226
|
+
tags.push(`<meta name="twitter:card" content="${card}">`);
|
|
227
|
+
// Twitter site (optional)
|
|
228
|
+
if (twitter.site) {
|
|
229
|
+
tags.push(`<meta name="twitter:site" content="${escapeHtml(twitter.site)}">`);
|
|
230
|
+
}
|
|
231
|
+
// Twitter creator with fallback chain
|
|
232
|
+
if (twitter.creator) {
|
|
233
|
+
tags.push(`<meta name="twitter:creator" content="${escapeHtml(twitter.creator)}">`);
|
|
234
|
+
}
|
|
235
|
+
else if (seo.author) {
|
|
236
|
+
const authorName = typeof seo.author === 'string' ? seo.author : seo.author.name;
|
|
237
|
+
tags.push(`<meta name="twitter:creator" content="${escapeHtml(authorName)}">`);
|
|
238
|
+
}
|
|
239
|
+
else if (config.seo?.defaultAuthor) {
|
|
240
|
+
tags.push(`<meta name="twitter:creator" content="${escapeHtml(config.seo.defaultAuthor.name)}">`);
|
|
241
|
+
}
|
|
242
|
+
// Title and description with fallback chains
|
|
243
|
+
const twitterTitle = twitter.title || seo.title || page.frontMatter.title || config.site.title;
|
|
244
|
+
const twitterDescription = twitter.description || seo.description || page.frontMatter.description;
|
|
245
|
+
tags.push(`<meta name="twitter:title" content="${escapeHtml(twitterTitle)}">`);
|
|
246
|
+
if (twitterDescription) {
|
|
247
|
+
tags.push(`<meta name="twitter:description" content="${escapeHtml(twitterDescription)}">`);
|
|
248
|
+
}
|
|
249
|
+
// Image with fallback to Open Graph image
|
|
250
|
+
const imageUrl = twitter.image ||
|
|
251
|
+
(seo.openGraph?.image
|
|
252
|
+
? typeof seo.openGraph.image === 'string'
|
|
253
|
+
? seo.openGraph.image
|
|
254
|
+
: seo.openGraph.image.url
|
|
255
|
+
: undefined);
|
|
256
|
+
if (imageUrl) {
|
|
257
|
+
const fullImageUrl = imageUrl.startsWith('/') ? `${siteUrl}${imageUrl}` : imageUrl;
|
|
258
|
+
tags.push(`<meta name="twitter:image" content="${escapeHtml(fullImageUrl)}">`);
|
|
259
|
+
if (twitter.imageAlt) {
|
|
260
|
+
tags.push(`<meta name="twitter:image:alt" content="${escapeHtml(twitter.imageAlt)}">`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return tags;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Template helper function for generating SEO tags in Eta templates.
|
|
267
|
+
* Provides a convenient API for selective SEO tag generation.
|
|
268
|
+
*
|
|
269
|
+
* @param context - Object containing page, config, and optional site
|
|
270
|
+
* @param tags - Optional array of tag types to generate (strings or SEOTagType enums)
|
|
271
|
+
* @returns HTML string containing generated SEO tags
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* ```eta
|
|
275
|
+
* <%~ stati.generateSEO(stati) %>
|
|
276
|
+
* <%~ stati.generateSEO(stati, ['title', 'description', 'opengraph']) %>
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
export function generateSEO(context, tags) {
|
|
280
|
+
// Convert tag names to SEOTagType enum values
|
|
281
|
+
let include = undefined;
|
|
282
|
+
if (tags && tags.length > 0) {
|
|
283
|
+
include = new Set();
|
|
284
|
+
for (const tag of tags) {
|
|
285
|
+
if (typeof tag === 'string') {
|
|
286
|
+
// Convert string to enum by checking if the string matches any enum value
|
|
287
|
+
// The enum values are lowercase strings like 'title', 'description', etc.
|
|
288
|
+
const enumEntry = Object.entries(SEOTagType).find(([_, value]) => value === tag);
|
|
289
|
+
if (enumEntry) {
|
|
290
|
+
include.add(enumEntry[1]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
include.add(tag);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Extract site URL from config
|
|
299
|
+
const siteUrl = context.config.site?.baseUrl || context.site?.baseUrl || '';
|
|
300
|
+
// Generate SEO metadata with a no-op logger (warnings won't be shown in template context)
|
|
301
|
+
const noOpLogger = {
|
|
302
|
+
info: () => { },
|
|
303
|
+
success: () => { },
|
|
304
|
+
warning: () => { },
|
|
305
|
+
error: () => { },
|
|
306
|
+
building: () => { },
|
|
307
|
+
processing: () => { },
|
|
308
|
+
stats: () => { },
|
|
309
|
+
};
|
|
310
|
+
const seoContext = {
|
|
311
|
+
page: context.page,
|
|
312
|
+
config: context.config,
|
|
313
|
+
siteUrl,
|
|
314
|
+
logger: noOpLogger,
|
|
315
|
+
};
|
|
316
|
+
if (include) {
|
|
317
|
+
seoContext.include = include;
|
|
318
|
+
}
|
|
319
|
+
return generateSEOMetadata(seoContext);
|
|
320
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SEO functionality exports
|
|
3
|
+
* Barrel file for all Stati SEO functionality including metadata generation,
|
|
4
|
+
* sitemaps, robots.txt, and auto-injection.
|
|
5
|
+
*/
|
|
6
|
+
export { generateSEOMetadata, generateSEO, generateOpenGraphTags, generateTwitterCardTags, } from './generator.js';
|
|
7
|
+
export { generateSitemap, generateSitemapEntry, generateSitemapXml, generateSitemapIndexXml, } from './sitemap.js';
|
|
8
|
+
export { generateRobotsTxt, generateRobotsTxtFromConfig } from './robots.js';
|
|
9
|
+
export { autoInjectSEO, shouldAutoInject } from './auto-inject.js';
|
|
10
|
+
export type { AutoInjectOptions } from './auto-inject.js';
|
|
11
|
+
export { escapeHtml, generateRobotsContent, validateSEOMetadata, detectExistingSEOTags, normalizeUrlPath, resolveAbsoluteUrl, isValidUrl, } from './utils/index.js';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/seo/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,mBAAmB,EACnB,WAAW,EACX,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,iBAAiB,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAG7E,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACnE,YAAY,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,EACL,UAAU,EACV,qBAAqB,EACrB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,UAAU,GACX,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SEO functionality exports
|
|
3
|
+
* Barrel file for all Stati SEO functionality including metadata generation,
|
|
4
|
+
* sitemaps, robots.txt, and auto-injection.
|
|
5
|
+
*/
|
|
6
|
+
// SEO metadata generation
|
|
7
|
+
export { generateSEOMetadata, generateSEO, generateOpenGraphTags, generateTwitterCardTags, } from './generator.js';
|
|
8
|
+
// Sitemap generation
|
|
9
|
+
export { generateSitemap, generateSitemapEntry, generateSitemapXml, generateSitemapIndexXml, } from './sitemap.js';
|
|
10
|
+
// Robots.txt generation
|
|
11
|
+
export { generateRobotsTxt, generateRobotsTxtFromConfig } from './robots.js';
|
|
12
|
+
// Auto-injection
|
|
13
|
+
export { autoInjectSEO, shouldAutoInject } from './auto-inject.js';
|
|
14
|
+
// SEO utilities
|
|
15
|
+
export { escapeHtml, generateRobotsContent, validateSEOMetadata, detectExistingSEOTags, normalizeUrlPath, resolveAbsoluteUrl, isValidUrl, } from './utils/index.js';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robots.txt generation utilities for Stati
|
|
3
|
+
* @module seo/robots
|
|
4
|
+
*/
|
|
5
|
+
import type { RobotsTxtConfig } from '../types/config.js';
|
|
6
|
+
/**
|
|
7
|
+
* User agent rule entry for robots.txt
|
|
8
|
+
*/
|
|
9
|
+
export interface UserAgentRule {
|
|
10
|
+
/** User agent name (e.g., 'Googlebot', '*') */
|
|
11
|
+
userAgent: string;
|
|
12
|
+
/** Paths to allow */
|
|
13
|
+
allow?: string[];
|
|
14
|
+
/** Paths to disallow */
|
|
15
|
+
disallow?: string[];
|
|
16
|
+
/** Crawl delay in seconds */
|
|
17
|
+
crawlDelay?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Options for generating robots.txt content
|
|
21
|
+
*/
|
|
22
|
+
export interface RobotsTxtOptions {
|
|
23
|
+
/** User agent rules */
|
|
24
|
+
rules?: UserAgentRule[];
|
|
25
|
+
/** Sitemap URLs to include */
|
|
26
|
+
sitemaps?: string[];
|
|
27
|
+
/** Additional custom directives */
|
|
28
|
+
custom?: string[];
|
|
29
|
+
/** Site base URL for resolving sitemap paths */
|
|
30
|
+
siteUrl?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generates robots.txt content from options
|
|
34
|
+
* @param options - Robots.txt generation options
|
|
35
|
+
* @returns Generated robots.txt content
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const content = generateRobotsTxt({
|
|
40
|
+
* rules: [
|
|
41
|
+
* {
|
|
42
|
+
* userAgent: 'Googlebot',
|
|
43
|
+
* allow: ['/'],
|
|
44
|
+
* crawlDelay: 1
|
|
45
|
+
* },
|
|
46
|
+
* {
|
|
47
|
+
* userAgent: '*',
|
|
48
|
+
* disallow: ['/admin/', '/api/']
|
|
49
|
+
* }
|
|
50
|
+
* ],
|
|
51
|
+
* sitemaps: ['https://example.com/sitemap.xml'],
|
|
52
|
+
* custom: ['# Custom comment']
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function generateRobotsTxt(options?: RobotsTxtOptions): string;
|
|
57
|
+
/**
|
|
58
|
+
* Converts Stati RobotsTxtConfig to RobotsTxtOptions
|
|
59
|
+
* @param config - Stati robots.txt configuration
|
|
60
|
+
* @param siteUrl - Site base URL
|
|
61
|
+
* @returns Robots.txt generation options
|
|
62
|
+
*/
|
|
63
|
+
export declare function configToOptions(config: RobotsTxtConfig, siteUrl?: string): RobotsTxtOptions;
|
|
64
|
+
/**
|
|
65
|
+
* Generates robots.txt content from Stati configuration
|
|
66
|
+
* @param config - Stati robots.txt configuration
|
|
67
|
+
* @param siteUrl - Site base URL for resolving sitemap paths
|
|
68
|
+
* @returns Generated robots.txt content
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* const content = generateRobotsTxtFromConfig(
|
|
73
|
+
* {
|
|
74
|
+
* rules: [
|
|
75
|
+
* { userAgent: '*', allow: ['/'] }
|
|
76
|
+
* ],
|
|
77
|
+
* sitemaps: ['/sitemap.xml']
|
|
78
|
+
* },
|
|
79
|
+
* 'https://example.com'
|
|
80
|
+
* );
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function generateRobotsTxtFromConfig(config: RobotsTxtConfig, siteUrl?: string): string;
|
|
84
|
+
//# sourceMappingURL=robots.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"robots.d.ts","sourceRoot":"","sources":["../../src/seo/robots.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAG1D;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,qBAAqB;IACrB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,wBAAwB;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,6BAA6B;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,uBAAuB;IACvB,KAAK,CAAC,EAAE,aAAa,EAAE,CAAC;IACxB,8BAA8B;IAC9B,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAcD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,gBAAqB,GAAG,MAAM,CA2CxE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAqE3F;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAG7F"}
|