@clidey/dory 1.0.8 → 1.10.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 +59 -164
- package/bin/chat-server.ts +270 -0
- package/bin/dist/analytics-server.d.ts +11 -0
- package/bin/dist/analytics-server.d.ts.map +1 -0
- package/bin/dist/analytics-server.js +121 -0
- package/bin/dist/analytics-server.js.map +1 -0
- package/bin/dist/automation.d.ts +35 -0
- package/bin/dist/automation.d.ts.map +1 -0
- package/bin/dist/automation.js +259 -0
- package/bin/dist/automation.js.map +1 -0
- package/bin/dist/chat-server.d.ts +8 -0
- package/bin/dist/chat-server.d.ts.map +1 -0
- package/bin/dist/chat-server.js +322 -0
- package/bin/dist/chat-server.js.map +1 -0
- package/bin/dist/check.d.ts +16 -0
- package/bin/dist/check.d.ts.map +1 -0
- package/bin/dist/check.js +247 -0
- package/bin/dist/check.js.map +1 -0
- package/bin/dist/dependency-overlay.d.ts +7 -0
- package/bin/dist/dependency-overlay.d.ts.map +1 -0
- package/bin/dist/dependency-overlay.js +137 -0
- package/bin/dist/dependency-overlay.js.map +1 -0
- package/bin/dist/dory.js +254 -402
- package/bin/dist/dory.js.map +1 -1
- package/bin/dist/i18n-init.d.ts +6 -0
- package/bin/dist/i18n-init.d.ts.map +1 -0
- package/bin/dist/i18n-init.js +56 -0
- package/bin/dist/i18n-init.js.map +1 -0
- package/bin/dist/mcp-http.d.ts +8 -0
- package/bin/dist/mcp-http.d.ts.map +1 -0
- package/bin/dist/mcp-http.js +72 -0
- package/bin/dist/mcp-http.js.map +1 -0
- package/bin/dist/mcp.d.ts +2 -0
- package/bin/dist/mcp.d.ts.map +1 -0
- package/bin/dist/mcp.js +81 -0
- package/bin/dist/mcp.js.map +1 -0
- package/bin/dist/preview-entry.d.ts +2 -0
- package/bin/dist/preview-entry.d.ts.map +1 -0
- package/bin/dist/preview-entry.js +6 -0
- package/bin/dist/preview-entry.js.map +1 -0
- package/bin/dist/preview-server.d.ts +5 -0
- package/bin/dist/preview-server.d.ts.map +1 -0
- package/bin/dist/preview-server.js +146 -0
- package/bin/dist/preview-server.js.map +1 -0
- package/bin/dist/preview-static.d.ts +7 -0
- package/bin/dist/preview-static.d.ts.map +1 -0
- package/bin/dist/preview-static.js +70 -0
- package/bin/dist/preview-static.js.map +1 -0
- package/bin/dist/project-config.d.ts +22 -0
- package/bin/dist/project-config.d.ts.map +1 -0
- package/bin/dist/project-config.js +44 -0
- package/bin/dist/project-config.js.map +1 -0
- package/dist/index.html +57 -0
- package/dist/runtime/dory-runtime.css +2 -0
- package/dist/runtime/dory-runtime.html +14 -0
- package/dist/runtime/dory-runtime.js +5490 -0
- package/docs/api-playground-demo.mdx +119 -0
- package/docs/api-reference/asyncapi.json +324 -0
- package/docs/api-reference/openapi.json +366 -0
- package/docs/api-reference/operations/create.mdx +5 -0
- package/docs/api-reference/operations/delete.mdx +5 -0
- package/docs/api-reference/operations/get.mdx +5 -0
- package/docs/api-reference/operations/update.mdx +5 -0
- package/docs/api-reference/websocket-operations/receive-chat-message.mdx +5 -0
- package/docs/api-reference/websocket-operations/receive-notifications.mdx +5 -0
- package/docs/api-reference/websocket-operations/receive-presence-update.mdx +5 -0
- package/docs/api-reference/websocket-operations/send-chat-message.mdx +5 -0
- package/docs/api-reference/websocket-operations/send-presence-update.mdx +5 -0
- package/docs/assets/dory.jpeg +0 -0
- package/docs/components/accordion.mdx +145 -0
- package/docs/components/callouts.mdx +134 -0
- package/docs/components/cards.mdx +153 -0
- package/docs/components/code-blocks.mdx +281 -0
- package/docs/components/expandable.mdx +189 -0
- package/docs/components/icons.mdx +71 -0
- package/docs/components/images.mdx +82 -0
- package/docs/components/latex.mdx +148 -0
- package/docs/components/layout.mdx +178 -0
- package/docs/components/lists.mdx +134 -0
- package/docs/components/mermaid.mdx +149 -0
- package/docs/components/overview.mdx +81 -0
- package/docs/components/source.mdx +130 -0
- package/docs/components/steps.mdx +122 -0
- package/docs/components/tables.mdx +114 -0
- package/docs/components/tags.mdx +179 -0
- package/docs/components/typography.mdx +185 -0
- package/docs/configuration/analytics.mdx +45 -0
- package/docs/configuration/chat.mdx +59 -0
- package/docs/configuration/dory-json.mdx +278 -0
- package/docs/configuration/embed.mdx +73 -0
- package/docs/configuration/fonts.mdx +86 -0
- package/docs/configuration/internationalization.mdx +61 -0
- package/docs/configuration/navigation.mdx +217 -0
- package/docs/configuration/theming.mdx +166 -0
- package/docs/contributing.mdx +47 -0
- package/docs/dory.json +136 -0
- package/docs/embed-example.html +176 -0
- package/docs/favicon.svg +18 -0
- package/docs/features.mdx +440 -0
- package/docs/getting-started/automation.mdx +76 -0
- package/docs/getting-started/configuration.mdx +95 -0
- package/docs/getting-started/custom-components.mdx +60 -0
- package/docs/getting-started/installation.mdx +88 -0
- package/docs/getting-started/quick-start.mdx +53 -0
- package/docs/introduction.mdx +50 -0
- package/docs/logo/logo.svg +18 -0
- package/docs/playgrounds/api-playground.mdx +122 -0
- package/docs/playgrounds/websocket-playground.mdx +98 -0
- package/docs/social-card.png +0 -0
- package/docs/social-card.svg +17 -0
- package/docs/websocket-playground-demo.mdx +135 -0
- package/package.json +23 -17
- package/scripts/verify-component-examples.mjs +126 -0
- package/scripts/verify-runtime-parity.mjs +147 -0
- package/src/app.css +22 -1
- package/src/assets/fonts/HankenGrotesk.ttf +0 -0
- package/src/assets/fonts/OFL.txt +94 -0
- package/src/components/chat-assistant.tsx +229 -0
- package/src/components/dropdown.tsx +3 -3
- package/src/components/error-boundary.tsx +2 -2
- package/src/components/hooks.tsx +20 -1
- package/src/components/navigation.tsx +82 -51
- package/src/components/notification.tsx +2 -3
- package/src/components/routes.tsx +17 -19
- package/src/components/search-index.test.ts +15 -0
- package/src/components/search-index.ts +49 -48
- package/src/components/search.tsx +208 -104
- package/src/components/store.ts +27 -9
- package/src/config/mdx-common.ts +11 -0
- package/src/config/mdx.ts +10 -11
- package/src/embed/loader.ts +0 -3
- package/src/embed/types.ts +0 -2
- package/src/embed/widget-iframe.ts +19 -21
- package/src/embedded-runtime/config.ts +138 -0
- package/src/embedded-runtime/index.tsx +680 -0
- package/src/embedded-runtime/runtime.html +14 -0
- package/src/entry-server.tsx +46 -124
- package/src/main.tsx +17 -5
- package/src/mdx/accordion.tsx +3 -3
- package/src/mdx/api-playground.tsx +18 -16
- package/src/mdx/async-api.tsx +2 -2
- package/src/mdx/card.tsx +3 -2
- package/src/mdx/code.tsx +11 -9
- package/src/mdx/fence.tsx +6 -5
- package/src/mdx/icon-grid.tsx +4 -1
- package/src/mdx/icon-names.test.ts +15 -0
- package/src/mdx/icon-names.ts +60 -0
- package/src/mdx/icon-source-variants.ts +102 -0
- package/src/mdx/icon.tsx +12 -73
- package/src/mdx/latex.test.ts +38 -0
- package/src/mdx/latex.tsx +42 -64
- package/src/mdx/mdx.tsx +1 -1
- package/src/mdx/mermaid.tsx +12 -6
- package/src/mdx/open-api.tsx +130 -74
- package/src/mdx/preprocess.test.ts +16 -0
- package/src/mdx/preprocess.ts +61 -0
- package/src/mdx/processor.test.ts +135 -37
- package/src/mdx/processor.ts +54 -92
- package/src/mdx/source.tsx +5 -4
- package/src/mdx/specimen.tsx +57 -43
- package/src/mdx/tabs.tsx +32 -21
- package/src/mdx/text.tsx +11 -11
- package/src/mdx/try-it-panel.tsx +284 -0
- package/src/mdx/type-scale.tsx +7 -7
- package/src/mdx/websocket-playground.tsx +21 -14
- package/src/plugins/analytics-injector.ts +8 -0
- package/src/plugins/chat-dev-server.ts +32 -0
- package/src/plugins/docs-assets.test.ts +25 -0
- package/src/plugins/docs-assets.ts +6 -2
- package/src/plugins/frontmatter-dev-server.ts +2 -2
- package/src/plugins/frontmatter-generator.ts +1 -1
- package/src/plugins/html-files-middleware.test.ts +21 -1
- package/src/plugins/html-files-middleware.ts +26 -6
- package/src/plugins/html-metadata-injector.ts +24 -4
- package/src/plugins/llm-txt-dev-server.ts +2 -2
- package/src/plugins/llm-txt-generator.ts +21 -4
- package/src/plugins/prerender.ts +47 -20
- package/src/plugins/robots-generator.ts +1 -1
- package/src/plugins/sanitize.ts +6 -5
- package/src/plugins/shared.ts +6 -18
- package/src/plugins/sitemap-generator.ts +1 -1
- package/src/plugins/social-preview.test.ts +71 -0
- package/src/plugins/social-preview.ts +87 -0
- package/src/plugins/unified-mdx-plugin.ts +1 -1
- package/src/theme.scss +630 -207
- package/src/types/config.ts +53 -1
- package/src/ui/breadcrumb.tsx +17 -0
- package/src/ui/docs-header-view.tsx +32 -0
- package/src/ui/docs-navigation-view.tsx +69 -0
- package/src/ui/docs-page-frame.tsx +50 -0
- package/src/ui/dory-mark.tsx +9 -0
- package/src/ui/header.tsx +33 -90
- package/src/ui/layout.tsx +99 -153
- package/src/ui/mobile-navigation.tsx +10 -8
- package/src/ui/page-actions.tsx +67 -0
- package/src/ui/page-feedback.tsx +50 -0
- package/src/ui/page-footer.tsx +23 -0
- package/src/ui/prev-next-link.tsx +15 -69
- package/src/ui/prev-next-view.tsx +27 -0
- package/src/ui/table-of-content-view.tsx +167 -0
- package/src/ui/table-of-content.tsx +25 -183
- package/src/utils/analytics.test.ts +36 -0
- package/src/utils/analytics.ts +35 -0
- package/src/utils/colors.ts +7 -0
- package/src/utils/content-scope.test.ts +26 -0
- package/src/utils/content-scope.ts +39 -0
- package/src/utils/search-content.ts +12 -0
- package/src/utils/theme.ts +1 -7
- package/vite.config.ssr.ts +4 -4
- package/vite.config.ts +22 -16
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
import { readDoryConfig, escapeHtml, applyTitleTemplate, getBasePath, getSiteUrl } from './shared';
|
|
2
|
+
import { readDoryConfig, escapeHtml, applyTitleTemplate, getBasePath, getSiteUrl } from './shared.ts';
|
|
3
|
+
import { socialPreviewPath } from './social-preview.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Vite plugin that injects site metadata from dory.json into index.html.
|
|
@@ -25,7 +26,8 @@ export function htmlMetadataInjector(): Plugin {
|
|
|
25
26
|
const twitterHandle: string = config.twitter || '';
|
|
26
27
|
|
|
27
28
|
// Resolve image to absolute URL
|
|
28
|
-
const
|
|
29
|
+
const generateImages = seo.generateSocialImages !== false;
|
|
30
|
+
const rawImage: string = generateImages ? socialPreviewPath('/') : seo.ogImage || config.image || '';
|
|
29
31
|
const siteImage = rawImage && siteUrl && !rawImage.startsWith('http')
|
|
30
32
|
? `${siteUrl}${rawImage.startsWith('/') ? '' : '/'}${rawImage}`
|
|
31
33
|
: rawImage;
|
|
@@ -48,6 +50,24 @@ export function htmlMetadataInjector(): Plugin {
|
|
|
48
50
|
html = html.replace('</head>', () => ` ${tag}\n </head>`);
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
// A single site URL is enough for clients to find the MDX index.
|
|
54
|
+
injectHeadTag(`<link rel="alternate" type="text/plain" title="LLM documentation index" href="${escapeHtml(basePath)}/llms.txt" />`);
|
|
55
|
+
|
|
56
|
+
const siteSchema = JSON.stringify({
|
|
57
|
+
'@context': 'https://schema.org',
|
|
58
|
+
'@graph': [
|
|
59
|
+
{
|
|
60
|
+
'@type': 'WebSite', name: siteName, description: defaultDescription,
|
|
61
|
+
...(siteUrl ? { '@id': `${siteUrl}/#website`, url: `${siteUrl}/` } : {}),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
'@type': 'WebPage', name: defaultTitle, description: defaultDescription,
|
|
65
|
+
...(siteUrl ? { url: `${siteUrl}/`, isPartOf: { '@id': `${siteUrl}/#website` } } : {}),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}).replace(/</g, '\\u003c');
|
|
69
|
+
injectHeadTag(`<script type="application/ld+json" data-dory-site-schema>${siteSchema}</script>`);
|
|
70
|
+
|
|
51
71
|
// Title
|
|
52
72
|
setTag(/<title>.*?<\/title>/, `<title>${escapeHtml(defaultTitle)}</title>`);
|
|
53
73
|
|
|
@@ -128,10 +148,10 @@ export function htmlMetadataInjector(): Plugin {
|
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
// Twitter tags
|
|
131
|
-
if (seo.twitterCard) {
|
|
151
|
+
if (generateImages || seo.twitterCard) {
|
|
132
152
|
setTag(
|
|
133
153
|
/<meta name="twitter:card" content=".*?" \/>/,
|
|
134
|
-
`<meta name="twitter:card" content="${escapeHtml(seo.twitterCard)}" />`
|
|
154
|
+
`<meta name="twitter:card" content="${generateImages ? 'summary_large_image' : escapeHtml(seo.twitterCard)}" />`
|
|
135
155
|
);
|
|
136
156
|
}
|
|
137
157
|
setTag(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { generateLlmsIndex, generateLlmContent } from './llm-txt-generator';
|
|
4
|
-
import { getBasePath, readDoryConfig } from './shared';
|
|
3
|
+
import { generateLlmsIndex, generateLlmContent } from './llm-txt-generator.ts';
|
|
4
|
+
import { getBasePath, readDoryConfig } from './shared.ts';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Dev server plugin that serves llms.txt and llms-full.txt on demand,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
findMdxFilesMap,
|
|
9
9
|
pathFromFilename,
|
|
10
10
|
getSiteUrl,
|
|
11
|
-
} from './shared';
|
|
11
|
+
} from './shared.ts';
|
|
12
12
|
|
|
13
13
|
interface LlmTxtGeneratorOptions {
|
|
14
14
|
docsDir?: string;
|
|
@@ -44,28 +44,34 @@ export function generateLlmsIndex(docsDir: string): string {
|
|
|
44
44
|
const siteName: string = config.name || 'Documentation';
|
|
45
45
|
const baseUrl: string = getSiteUrl(config);
|
|
46
46
|
const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
|
|
47
|
+
const listed = new Set<string>();
|
|
47
48
|
|
|
48
49
|
let output = `# ${siteName}\n`;
|
|
49
50
|
if (config.description) {
|
|
50
51
|
output += `\n> ${config.description}\n`;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
output += `\nThis site publishes its MDX source. Fetch the .mdx links below for individual pages, or ${baseUrl || ''}/llms-full.txt for one combined text file. Start from this URL to discover documentation without guessing repository paths.\n`;
|
|
55
|
+
output += `\nIf this site is served by Dory's Node preview server, a read-only MCP endpoint is available at ${baseUrl || ''}/mcp. MCP clients require a one-time connection to that endpoint; the page URL alone is sufficient for clients that can browse these links.\n`;
|
|
56
|
+
|
|
53
57
|
const tabs: any[] = config.navigation?.tabs || [];
|
|
54
58
|
for (const tab of tabs) {
|
|
55
59
|
const links: string[] = [];
|
|
56
60
|
const walkPages = (pages: any[]) => {
|
|
57
|
-
for (const
|
|
61
|
+
for (const entry of pages) {
|
|
62
|
+
const page = typeof entry === 'object' && entry && typeof entry.page === 'string' ? entry.page : entry;
|
|
58
63
|
if (typeof page === 'string') {
|
|
59
64
|
const fileKey = resolveFileKey(page, mdxFilesMap);
|
|
60
65
|
if (!fileKey) continue;
|
|
66
|
+
listed.add(fileKey);
|
|
61
67
|
const raw = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
|
|
62
68
|
const { data } = parseFrontmatter(raw);
|
|
63
69
|
const routePath = pathFromFilename(mdxFilesMap[fileKey], docsDir);
|
|
64
70
|
const url = `${baseUrl}${routePath}.mdx`;
|
|
65
71
|
const title = data.title || page;
|
|
66
72
|
links.push(data.description ? `- [${title}](${url}): ${data.description}` : `- [${title}](${url})`);
|
|
67
|
-
} else if (typeof
|
|
68
|
-
walkPages(
|
|
73
|
+
} else if (typeof entry === 'object' && entry.pages) {
|
|
74
|
+
walkPages(entry.pages);
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
};
|
|
@@ -77,6 +83,17 @@ export function generateLlmsIndex(docsDir: string): string {
|
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
const additional = Object.keys(mdxFilesMap).filter(key => !listed.has(key)).sort();
|
|
87
|
+
if (additional.length > 0) {
|
|
88
|
+
output += '\n## Other pages and translations\n\n';
|
|
89
|
+
for (const fileKey of additional) {
|
|
90
|
+
const raw = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
|
|
91
|
+
const { data } = parseFrontmatter(raw);
|
|
92
|
+
const routePath = pathFromFilename(mdxFilesMap[fileKey], docsDir);
|
|
93
|
+
output += `- [${data.title || fileKey}](${baseUrl}${routePath}.mdx)${data.description ? `: ${data.description}` : ''}\n`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
return output.trim() + '\n';
|
|
81
98
|
}
|
|
82
99
|
|
package/src/plugins/prerender.ts
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
applyTitleTemplate,
|
|
12
12
|
getBasePath,
|
|
13
13
|
getSiteUrl,
|
|
14
|
-
} from './shared';
|
|
14
|
+
} from './shared.ts';
|
|
15
|
+
import { contentScope, scopedPath } from '../utils/content-scope.ts';
|
|
16
|
+
import type { DoryConfig } from '../types/config.ts';
|
|
17
|
+
import { renderSocialPreview } from './social-preview.ts';
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Generates per-route HTML files with page-specific meta tags and JSON-LD
|
|
@@ -42,6 +45,7 @@ export function prerender(): Plugin {
|
|
|
42
45
|
const baseHtml = readFileSync(indexHtmlPath, 'utf-8');
|
|
43
46
|
|
|
44
47
|
const siteConfig = readDoryConfig() || {};
|
|
48
|
+
const typedConfig = siteConfig as DoryConfig;
|
|
45
49
|
const seo = siteConfig.seo || {};
|
|
46
50
|
const baseUrl: string = getSiteUrl(siteConfig);
|
|
47
51
|
const siteName: string = siteConfig.name || 'Documentation';
|
|
@@ -57,7 +61,6 @@ export function prerender(): Plugin {
|
|
|
57
61
|
const ogImage = rawOgImage && baseUrl && !rawOgImage.startsWith('http')
|
|
58
62
|
? `${baseUrl}${rawOgImage.startsWith('/') ? '' : '/'}${rawOgImage}`
|
|
59
63
|
: rawOgImage;
|
|
60
|
-
const hasImage = Boolean(ogImage || siteConfig.image);
|
|
61
64
|
|
|
62
65
|
// Map route paths to MDX source files for description fallbacks
|
|
63
66
|
const docsDir = resolve(process.cwd(), 'docs');
|
|
@@ -69,6 +72,9 @@ export function prerender(): Plugin {
|
|
|
69
72
|
|
|
70
73
|
const missingDescriptions: string[] = [];
|
|
71
74
|
let count = 0;
|
|
75
|
+
if (seo.generateSocialImages !== false) {
|
|
76
|
+
renderSocialPreview({ route: '/', title: siteConfig.title || siteName, description: siteConfig.description || '', config: typedConfig, docsDir, outDir });
|
|
77
|
+
}
|
|
72
78
|
|
|
73
79
|
for (const page of frontmatter) {
|
|
74
80
|
const routePath = page.path;
|
|
@@ -80,8 +86,21 @@ export function prerender(): Plugin {
|
|
|
80
86
|
missingDescriptions.push(routePath);
|
|
81
87
|
}
|
|
82
88
|
const fullUrl = baseUrl ? `${baseUrl}${routePath}` : '';
|
|
89
|
+
const scope = contentScope(typedConfig, routePath);
|
|
90
|
+
const customImage = typeof page.image === 'string' ? page.image : typeof page.ogImage === 'string' ? page.ogImage : '';
|
|
91
|
+
let image = customImage;
|
|
92
|
+
if (!image && seo.generateSocialImages !== false) {
|
|
93
|
+
image = renderSocialPreview({ route: routePath, title: page.title || siteName, description, config: typedConfig, docsDir, outDir });
|
|
94
|
+
}
|
|
95
|
+
image ||= ogImage || siteConfig.image || '';
|
|
96
|
+
if (image && !/^https?:\/\//i.test(image)) image = `${baseUrl || getBasePath(siteConfig)}/${image.replace(/^\.?\//, '')}`;
|
|
83
97
|
|
|
84
|
-
|
|
98
|
+
// Route-specific structured data replaces the homepage graph.
|
|
99
|
+
let html = baseHtml.replace(/<script type="application\/ld\+json" data-dory-site-schema>[\s\S]*?<\/script>/, '');
|
|
100
|
+
html = html.replace(/<html\b([^>]*)>/, (_match, attrs: string) => {
|
|
101
|
+
const withoutLang = attrs.replace(/\s+lang="[^"]*"/, '');
|
|
102
|
+
return `<html${withoutLang} lang="${escapeHtml(scope.locale)}">`;
|
|
103
|
+
});
|
|
85
104
|
// Function replacements so `$&` etc. in values cannot corrupt output
|
|
86
105
|
const setTag = (re: RegExp, replacement: string) => {
|
|
87
106
|
html = html.replace(re, () => replacement);
|
|
@@ -105,27 +124,30 @@ export function prerender(): Plugin {
|
|
|
105
124
|
/<meta property="og:description" content=".*?" \/>/,
|
|
106
125
|
`<meta property="og:description" content="${escapeHtml(description)}" />`
|
|
107
126
|
);
|
|
127
|
+
if (typedConfig.i18n) {
|
|
128
|
+
setTag(
|
|
129
|
+
/<meta property="og:locale" content=".*?" \/>/,
|
|
130
|
+
`<meta property="og:locale" content="${escapeHtml(scope.locale.replace(/-/g, '_'))}" />`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
108
133
|
setTag(
|
|
109
134
|
/<meta property="og:type" content=".*?" \/>/,
|
|
110
135
|
'<meta property="og:type" content="article" />'
|
|
111
136
|
);
|
|
112
|
-
if (
|
|
137
|
+
if (image) {
|
|
113
138
|
setTag(
|
|
114
139
|
/<meta property="og:image" content=".*?" \/>/,
|
|
115
|
-
`<meta property="og:image" content="${escapeHtml(
|
|
140
|
+
`<meta property="og:image" content="${escapeHtml(image)}" />`
|
|
116
141
|
);
|
|
117
142
|
setTag(
|
|
118
143
|
/<meta name="twitter:image" content=".*?" \/>/,
|
|
119
|
-
`<meta name="twitter:image" content="${escapeHtml(
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
if (!hasImage) {
|
|
123
|
-
// No shareable image — a large-image card would render broken
|
|
124
|
-
setTag(
|
|
125
|
-
/<meta name="twitter:card" content=".*?" \/>/,
|
|
126
|
-
'<meta name="twitter:card" content="summary" />'
|
|
144
|
+
`<meta name="twitter:image" content="${escapeHtml(image)}" />`
|
|
127
145
|
);
|
|
128
146
|
}
|
|
147
|
+
setTag(
|
|
148
|
+
/<meta name="twitter:card" content=".*?" \/>/,
|
|
149
|
+
`<meta name="twitter:card" content="${image ? escapeHtml(seo.twitterCard || 'summary_large_image') : 'summary'}" />`
|
|
150
|
+
);
|
|
129
151
|
|
|
130
152
|
// Twitter tags
|
|
131
153
|
setTag(
|
|
@@ -150,6 +172,14 @@ export function prerender(): Plugin {
|
|
|
150
172
|
);
|
|
151
173
|
}
|
|
152
174
|
|
|
175
|
+
if (baseUrl && typedConfig.i18n) {
|
|
176
|
+
for (const locale of typedConfig.i18n.locales) {
|
|
177
|
+
const alternate = scopedPath(typedConfig, routePath, locale.code, scope.version);
|
|
178
|
+
if (!fileByRoute[alternate]) continue;
|
|
179
|
+
injectHeadTag(`<link rel="alternate" hreflang="${escapeHtml(locale.code)}" href="${escapeHtml(`${baseUrl}${alternate}`)}" />`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
153
183
|
// Per-page robots directive from frontmatter
|
|
154
184
|
if (page.robots) {
|
|
155
185
|
injectHeadTag(`<meta name="robots" content="${escapeHtml(String(page.robots))}" />`);
|
|
@@ -253,14 +283,11 @@ function write404(
|
|
|
253
283
|
let html = baseHtml;
|
|
254
284
|
html = html.replace(/<title>.*?<\/title>/, () => `<title>Page not found — ${escapeHtml(siteName)}</title>`);
|
|
255
285
|
html = html.replace('</head>', () => ' <meta name="robots" content="noindex" />\n </head>');
|
|
286
|
+
// Leave #app empty so the client renders the same UX NotFoundPage used for unknown routes.
|
|
287
|
+
// A separate noscript fallback keeps the static 404 useful without JavaScript.
|
|
256
288
|
html = html.replace(
|
|
257
|
-
'<
|
|
258
|
-
() =>
|
|
259
|
-
'<div id="app"><main style="max-width:36rem;margin:6rem auto;padding:0 1.5rem;font-family:sans-serif;text-align:center">' +
|
|
260
|
-
'<h1>Page not found</h1>' +
|
|
261
|
-
'<p>The page you are looking for does not exist.</p>' +
|
|
262
|
-
`<p><a href="${escapeHtml(firstHref)}">Go to ${escapeHtml(firstTitle)}</a></p>` +
|
|
263
|
-
'</main></div>'
|
|
289
|
+
'<noscript>You need to enable JavaScript to run this app.</noscript>',
|
|
290
|
+
() => `<noscript><main><h1>Page not found</h1><p>The page you are looking for does not exist.</p><p><a href="${escapeHtml(firstHref)}">Go to ${escapeHtml(firstTitle)}</a></p></main></noscript>`
|
|
264
291
|
);
|
|
265
292
|
writeFileSync(resolve(outDir, '404.html'), html, 'utf-8');
|
|
266
293
|
}
|
package/src/plugins/sanitize.ts
CHANGED
|
@@ -26,7 +26,7 @@ const KNOWN_COMPONENTS = [
|
|
|
26
26
|
'Steps', 'Step', 'table', 'th', 'td', 'ul', 'ol', 'li',
|
|
27
27
|
'UnorderedList', 'OrderedList', 'ListItem',
|
|
28
28
|
'APIPlayground', 'WebSocketPlayground', 'AsyncAPI', 'Source', 'Tag',
|
|
29
|
-
'Specimen', 'SpecimenProps', 'Chip', 'ChipGroup', 'Tabs', 'Tab', 'IconGrid', 'TypeScale',
|
|
29
|
+
'Specimen', 'SpecimenProps', 'Prop', 'Chip', 'ChipGroup', 'Tabs', 'Tab', 'IconGrid', 'TypeScale',
|
|
30
30
|
// Common HTML elements that should be preserved
|
|
31
31
|
'div', 'span', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's',
|
|
32
32
|
'blockquote', 'nav', 'section', 'article', 'header', 'footer', 'main',
|
|
@@ -117,7 +117,8 @@ function hasMatchingClosingTag(code: string, tagName: string, openingTagOffset:
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// Allow any <[a-z][a-z0-9]*> tag inside code blocks to be left as-is (not parsed as JSX).
|
|
120
|
-
export function preprocessMdxTags() {
|
|
120
|
+
export function preprocessMdxTags(importedComponents: readonly string[] = []) {
|
|
121
|
+
const knownComponents = new Set([...KNOWN_COMPONENTS, ...importedComponents]);
|
|
121
122
|
return {
|
|
122
123
|
name: 'preprocess-mdx-tags',
|
|
123
124
|
enforce: 'pre' as const,
|
|
@@ -137,7 +138,7 @@ export function preprocessMdxTags() {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
// If unknown component, treat as text
|
|
140
|
-
if (!
|
|
141
|
+
if (!knownComponents.has(tag)) {
|
|
141
142
|
return `\`${match}\``;
|
|
142
143
|
}
|
|
143
144
|
return match;
|
|
@@ -152,7 +153,7 @@ export function preprocessMdxTags() {
|
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
// If known component, check if it has a closing tag
|
|
155
|
-
if (
|
|
156
|
+
if (knownComponents.has(tag)) {
|
|
156
157
|
// If it doesn't have a matching closing tag, treat it as text
|
|
157
158
|
if (!hasMatchingClosingTag(processed, tag, offset)) {
|
|
158
159
|
return `\`<${tag}>\``;
|
|
@@ -173,7 +174,7 @@ export function preprocessMdxTags() {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// If known component, check if it has a closing tag (only for opening tags)
|
|
176
|
-
if (
|
|
177
|
+
if (knownComponents.has(tag)) {
|
|
177
178
|
// Only check for matching closing tag for opening tags
|
|
178
179
|
if (!match.startsWith('</')) {
|
|
179
180
|
if (!hasMatchingClosingTag(processed, tag, offset)) {
|
package/src/plugins/shared.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { normalizeBasePath } from '../utils/base-path';
|
|
4
|
+
import { normalizeBasePath } from '../utils/base-path.ts';
|
|
5
|
+
export { stripMdxToText } from '../utils/search-content.ts';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Shared helpers for build plugins that read docs/*.mdx and docs/dory.json.
|
|
@@ -62,6 +63,8 @@ export function getNavigationOrder(docsDir: string): string[] {
|
|
|
62
63
|
for (const page of pages) {
|
|
63
64
|
if (typeof page === 'string') {
|
|
64
65
|
order.push(page);
|
|
66
|
+
} else if (typeof page === 'object' && typeof page.page === 'string') {
|
|
67
|
+
order.push(page.page);
|
|
65
68
|
} else if (typeof page === 'object' && page.pages) {
|
|
66
69
|
walkPages(page.pages);
|
|
67
70
|
}
|
|
@@ -87,12 +90,13 @@ export function getNavigationOrder(docsDir: string): string[] {
|
|
|
87
90
|
* e.g. docs/getting-started/installation.mdx -> /getting-started/installation.
|
|
88
91
|
*/
|
|
89
92
|
export function pathFromFilename(filename: string, docsDir: string): string {
|
|
90
|
-
|
|
93
|
+
const route = filename
|
|
91
94
|
.replace(docsDir, '')
|
|
92
95
|
.replace(/\/?index\.mdx$/, '/')
|
|
93
96
|
.replace(/\.mdx$/, '')
|
|
94
97
|
.replace(/\\/g, '/')
|
|
95
98
|
.toLowerCase();
|
|
99
|
+
return route.length > 1 ? route.replace(/\/$/, '') : route;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
/**
|
|
@@ -117,22 +121,6 @@ export function findMdxFilesMap(dir: string, baseDir: string): Record<string, st
|
|
|
117
121
|
return files;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
/**
|
|
121
|
-
* Strips MDX/JSX syntax to extract plain text (for search indexing and
|
|
122
|
-
* meta description fallbacks).
|
|
123
|
-
*/
|
|
124
|
-
export function stripMdxToText(content: string): string {
|
|
125
|
-
return content
|
|
126
|
-
.replace(/^import\s+.*$/gm, '') // import statements
|
|
127
|
-
.replace(/^export\s+.*$/gm, '') // export statements
|
|
128
|
-
.replace(/```[\s\S]*?```/g, '') // fenced code blocks
|
|
129
|
-
.replace(/<[^>]+>/g, '') // JSX/HTML tags
|
|
130
|
-
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // markdown links -> text
|
|
131
|
-
.replace(/[#*_~`>|]/g, '') // markdown syntax chars
|
|
132
|
-
.replace(/\n{3,}/g, '\n\n') // collapse blank lines
|
|
133
|
-
.trim();
|
|
134
|
-
}
|
|
135
|
-
|
|
136
124
|
/**
|
|
137
125
|
* Escapes a string for safe use in HTML/XML text and attribute values.
|
|
138
126
|
*/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { renderSocialPreview, socialPreviewPath, wrapSocialText } from './social-preview';
|
|
6
|
+
|
|
7
|
+
const folders: string[] = [];
|
|
8
|
+
afterEach(() => { for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true }); });
|
|
9
|
+
|
|
10
|
+
test('image paths distinguish nested routes and cannot escape the output directory', () => {
|
|
11
|
+
expect(socialPreviewPath('/guide/start')).not.toBe(socialPreviewPath('/reference/start'));
|
|
12
|
+
expect(socialPreviewPath('/../../private')).toMatch(/^\/_dory\/social\/page-[a-f0-9]+\.png$/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('long titles wrap into bounded lines without cutting Unicode characters', () => {
|
|
16
|
+
const lines = wrapSocialText('A'.repeat(200), 30, 3);
|
|
17
|
+
expect(lines).toHaveLength(3);
|
|
18
|
+
expect(lines.every(line => Array.from(line).length <= 30)).toBe(true);
|
|
19
|
+
expect(lines[2]).toMatch(/…$/);
|
|
20
|
+
expect(wrapSocialText('😀'.repeat(12), 6, 2)).toEqual(['😀'.repeat(6), '😀'.repeat(6)]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('renders distinct 1200 by 630 PNGs with no system font or browser dependency', () => {
|
|
24
|
+
const root = mkdtempSync(resolve(tmpdir(), 'dory-social-'));
|
|
25
|
+
folders.push(root);
|
|
26
|
+
const docsDir = resolve(root, 'docs');
|
|
27
|
+
mkdirSync(docsDir);
|
|
28
|
+
const options = { config: { name: 'Example & Docs' }, docsDir, outDir: root, description: 'A guide for your team.' };
|
|
29
|
+
const first = renderSocialPreview({ ...options, route: '/install', title: 'Installation' });
|
|
30
|
+
const second = renderSocialPreview({ ...options, route: '/start', title: 'Getting started <safely>' });
|
|
31
|
+
const png = readFileSync(resolve(root, first.slice(1)));
|
|
32
|
+
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
33
|
+
expect(png.readUInt32BE(16)).toBe(1200);
|
|
34
|
+
expect(png.readUInt32BE(20)).toBe(630);
|
|
35
|
+
expect(png).not.toEqual(readFileSync(resolve(root, second.slice(1))));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('page overrides and shared-image opt-out produce correct base-prefixed metadata', async () => {
|
|
39
|
+
const { prerender } = await import('./prerender');
|
|
40
|
+
const { vi } = await import('vitest');
|
|
41
|
+
const { copyFileSync, writeFileSync } = await import('node:fs');
|
|
42
|
+
const root = mkdtempSync(resolve(tmpdir(), 'dory-social-metadata-'));
|
|
43
|
+
folders.push(root);
|
|
44
|
+
const dist = resolve(root, 'dist');
|
|
45
|
+
mkdirSync(resolve(root, 'docs'), { recursive: true });
|
|
46
|
+
mkdirSync(resolve(root, 'src/assets/fonts'), { recursive: true });
|
|
47
|
+
mkdirSync(dist);
|
|
48
|
+
copyFileSync(resolve('src/assets/fonts/HankenGrotesk.ttf'), resolve(root, 'src/assets/fonts/HankenGrotesk.ttf'));
|
|
49
|
+
const config = { name: 'Example', url: 'https://docs.example.com', basePath: '/manual', image: '/shared.png', navigation: { tabs: [] } };
|
|
50
|
+
const template = '<html lang="en"><head><title>Docs</title><meta name="description" content="" /><meta property="og:image" content="" /><meta name="twitter:image" content="" /><meta name="twitter:card" content="summary" /></head><body><div id="app"></div></body></html>';
|
|
51
|
+
writeFileSync(resolve(dist, 'frontmatter.json'), JSON.stringify([
|
|
52
|
+
{ path: '/start', title: 'Start', description: 'Get started.' },
|
|
53
|
+
{ path: '/custom', title: 'Custom', description: 'Custom page.', image: '/art/custom.png' },
|
|
54
|
+
]));
|
|
55
|
+
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root);
|
|
56
|
+
try {
|
|
57
|
+
for (const enabled of [true, false]) {
|
|
58
|
+
writeFileSync(resolve(root, 'docs/dory.json'), JSON.stringify({ ...config, seo: { generateSocialImages: enabled } }));
|
|
59
|
+
writeFileSync(resolve(dist, 'index.html'), template);
|
|
60
|
+
const hooks = prerender() as { configResolved(config: unknown): void; closeBundle(): void };
|
|
61
|
+
hooks.configResolved({ build: { outDir: dist } });
|
|
62
|
+
hooks.closeBundle();
|
|
63
|
+
const start = readFileSync(resolve(dist, 'start/index.html'), 'utf8');
|
|
64
|
+
const custom = readFileSync(resolve(dist, 'custom/index.html'), 'utf8');
|
|
65
|
+
const image = enabled ? socialPreviewPath('/start') : '/shared.png';
|
|
66
|
+
expect(start).toContain(`content="https://docs.example.com/manual${image}"`);
|
|
67
|
+
expect(custom).toContain('content="https://docs.example.com/manual/art/custom.png"');
|
|
68
|
+
if (enabled) expect(readFileSync(resolve(dist, image.slice(1))).readUInt32BE(16)).toBe(1200);
|
|
69
|
+
}
|
|
70
|
+
} finally { cwd.mockRestore(); }
|
|
71
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
5
|
+
import type { DoryConfig } from '../types/config.ts';
|
|
6
|
+
import { escapeHtml } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
export function socialPreviewPath(route: string): string {
|
|
9
|
+
return route === '/' ? '/_dory/social/home.png' : `/_dory/social/page-${createHash('sha256').update(route).digest('hex').slice(0, 20)}.png`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function localAsset(docsDir: string, source?: string): string | undefined {
|
|
13
|
+
if (!source || /^(?:[a-z]+:|\/\/)/i.test(source)) return;
|
|
14
|
+
const file = resolve(docsDir, source.replace(/^\//, ''));
|
|
15
|
+
if (!existsSync(file)) return;
|
|
16
|
+
const rel = relative(realpathSync(docsDir), realpathSync(file));
|
|
17
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return;
|
|
18
|
+
return file;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Bounded lines, including long words and titles without spaces. */
|
|
22
|
+
export function wrapSocialText(text: string, columns: number, maxLines: number): string[] {
|
|
23
|
+
const words = text.replace(/\s+/g, ' ').trim().split(' ');
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
let line = '';
|
|
26
|
+
for (const word of words) {
|
|
27
|
+
if (line && Array.from(`${line} ${word}`).length > columns) { lines.push(line); line = ''; }
|
|
28
|
+
const characters = Array.from(word);
|
|
29
|
+
while (characters.length > columns) {
|
|
30
|
+
if (line) { lines.push(line); line = ''; }
|
|
31
|
+
lines.push(characters.splice(0, columns).join(''));
|
|
32
|
+
}
|
|
33
|
+
const tail = characters.join('');
|
|
34
|
+
if (tail) line = line ? `${line} ${tail}` : tail;
|
|
35
|
+
}
|
|
36
|
+
if (line) lines.push(line);
|
|
37
|
+
if (lines.length > maxLines) {
|
|
38
|
+
lines.length = maxLines;
|
|
39
|
+
lines[maxLines - 1] = `${Array.from(lines[maxLines - 1]).slice(0, columns - 1).join('').trimEnd()}…`;
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build-only PNG generation: local files, bundled font, no browser or network. */
|
|
45
|
+
export function renderSocialPreview({ route, title, description, config, docsDir, outDir }: {
|
|
46
|
+
route: string; title: string; description: string; config: Partial<DoryConfig>; docsDir: string; outDir: string;
|
|
47
|
+
}): string {
|
|
48
|
+
const name = config.name || 'Documentation';
|
|
49
|
+
const hex = (value: string | undefined, fallback: string) => value && /^#[a-f\d]{3,8}$/i.test(value) ? value : fallback;
|
|
50
|
+
const background = hex(config.colors?.background, '#15243e');
|
|
51
|
+
const primary = hex(config.colors?.primary, '#5c8cf4');
|
|
52
|
+
const foreground = hex(config.colors?.foreground, '#f8f5ed');
|
|
53
|
+
const muted = hex(config.colors?.mutedForeground, '#b2bdd0');
|
|
54
|
+
const logo = localAsset(docsDir, config.logo?.dark ?? config.logo?.light);
|
|
55
|
+
const imageTypes: Record<string, string> = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
56
|
+
const logoType = logo && imageTypes[extname(logo).toLowerCase()];
|
|
57
|
+
const logoData = logo && logoType ? `data:${logoType};base64,${readFileSync(logo).toString('base64')}` : undefined;
|
|
58
|
+
const font = localAsset(docsDir, config.fonts?.heading?.source ?? config.fonts?.source);
|
|
59
|
+
const customFont = font && /\.(ttf|otf)$/i.test(font) ? font : undefined;
|
|
60
|
+
const family = customFont ? config.fonts?.heading?.family ?? config.fonts?.family ?? 'Hanken Grotesk' : 'Hanken Grotesk';
|
|
61
|
+
const size = title.length > 85 ? 56 : 64;
|
|
62
|
+
const lines = wrapSocialText(title, Math.floor(1050 / (size * 0.62)), 3);
|
|
63
|
+
const text = (value: string, x: number, y: number, fontSize: number, color: string, weight = 400) => `<text x="${x}" y="${y}" font-size="${fontSize}" fill="${color}" font-weight="${weight}">${escapeHtml(value)}</text>`;
|
|
64
|
+
const subtitleY = Math.max(350, 225 + (lines.length - 1) * 76 + 65);
|
|
65
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
|
66
|
+
<rect width="1200" height="630" fill="${background}"/><rect width="1200" height="8" fill="${primary}"/>
|
|
67
|
+
<circle cx="1170" cy="360" r="280" fill="${primary}" opacity="0.06"/>
|
|
68
|
+
<g font-family="${escapeHtml(family)}, sans-serif">
|
|
69
|
+
${logoData ? `<image href="${logoData}" x="72" y="62" width="46" height="50"/>` : `<rect x="72" y="64" width="44" height="44" rx="10" fill="${primary}"/>`}
|
|
70
|
+
${text(wrapSocialText(name, 55, 1)[0] ?? '', 136, 100, 32, foreground, 700)}
|
|
71
|
+
${lines.map((line, i) => text(line, 72, 225 + i * 76, size, foreground, 700)).join('')}
|
|
72
|
+
${wrapSocialText(description, 78, 2).map((line, i) => text(line, 76, subtitleY + i * 36, 26, muted)).join('')}
|
|
73
|
+
<path d="M76 518H1124" stroke="${foreground}" opacity="0.15"/>
|
|
74
|
+
${text('DOCUMENTATION', 76, 565, 17, muted)}
|
|
75
|
+
<text x="1124" y="565" text-anchor="end" font-size="20" fill="${primary}">${escapeHtml(wrapSocialText(config.url?.replace(/^https?:\/\//, '') ?? name, 60, 1)[0] ?? '')}</text>
|
|
76
|
+
</g></svg>`;
|
|
77
|
+
const path = socialPreviewPath(route);
|
|
78
|
+
const file = resolve(outDir, path.slice(1));
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
const renderer = new Resvg(svg, { font: {
|
|
81
|
+
loadSystemFonts: false,
|
|
82
|
+
fontFiles: [resolve(process.cwd(), 'src/assets/fonts/HankenGrotesk.ttf'), ...(customFont ? [customFont] : [])],
|
|
83
|
+
defaultFontFamily: family,
|
|
84
|
+
} });
|
|
85
|
+
writeFileSync(file, renderer.render().asPng());
|
|
86
|
+
return path;
|
|
87
|
+
}
|