@vyriy/ssg 0.8.7

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/html.js ADDED
@@ -0,0 +1,228 @@
1
+ import { escapeHtml, renderMarkdown } from './markdown.js';
2
+ import { getAbsoluteUrl } from './sitemap.js';
3
+ const escapedStyleCloseTag = ['<', String.fromCodePoint(92), '/style'].join('');
4
+ const googleAnalyticsMeasurementIdPattern = /^G-[A-Z0-9]+$/;
5
+ export const defaultStylesheet = String.raw `
6
+ :root{color-scheme:light;--vyriy-bg:#f8faf7;--vyriy-surface:#ffffff;--vyriy-text:#17211b;--vyriy-muted:#5b675f;--vyriy-line:#dbe3dd;--vyriy-accent:#176f5b;--vyriy-accent-soft:#e2f3ee;--vyriy-code:#10201a}*{box-sizing:border-box}body{margin:0;background:var(--vyriy-bg);color:var(--vyriy-text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}a{color:var(--vyriy-accent);text-decoration-thickness:.08em;text-underline-offset:.18em}img{max-width:100%;height:auto}.vyriy-layout{min-height:100vh;display:flex;flex-direction:column}.vyriy-header{border-bottom:1px solid var(--vyriy-line);background:rgba(248,250,247,.92);backdrop-filter:blur(12px)}.vyriy-header__inner{max-width:1120px;margin:0 auto;padding:1rem 1.5rem;display:flex;align-items:center;justify-content:space-between;gap:1rem}.vyriy-header__brand{display:inline-flex;align-items:center;gap:.75rem;color:var(--vyriy-text);font-weight:800;font-size:1.2rem;text-decoration:none}.vyriy-header__logo{width:44px;height:44px;object-fit:contain}.vyriy-nav{display:flex;gap:1rem;flex-wrap:wrap}.vyriy-nav a{color:var(--vyriy-muted);font-weight:650;text-decoration:none}.vyriy-nav a:hover{color:var(--vyriy-accent)}.vyriy-main{width:100%;max-width:1120px;margin:0 auto;padding:3rem 1.5rem 4rem;flex:1}.vyriy-page{max-width:820px}.vyriy-page__content h1{font-size:clamp(2.2rem,5vw,4.2rem);line-height:1.05;margin:0 0 1.25rem}.vyriy-page__content h2{font-size:1.8rem;line-height:1.2;margin:2.5rem 0 .75rem}.vyriy-page__content h3{font-size:1.25rem;margin:2rem 0 .5rem}.vyriy-page__content p,.vyriy-page__content li{font-size:1.05rem}.vyriy-page__content pre{overflow:auto;padding:1rem;border-radius:8px;background:var(--vyriy-code);color:#f4fff9}.vyriy-page__content code{font-size:.92em}.vyriy-page__content :not(pre)>code{padding:.12rem .35rem;border-radius:5px;background:var(--vyriy-accent-soft);color:var(--vyriy-code)}.vyriy-page__content table{width:100%;border-collapse:collapse;margin:1.5rem 0;display:block;overflow:auto}.vyriy-page__content th,.vyriy-page__content td{border:1px solid var(--vyriy-line);padding:.65rem .75rem;text-align:left}.vyriy-page__content blockquote{margin:1.5rem 0;padding:.25rem 1rem;border-left:4px solid var(--vyriy-accent);color:var(--vyriy-muted);background:var(--vyriy-surface)}.vyriy-card{border:1px solid var(--vyriy-line);border-radius:8px;background:var(--vyriy-surface)}.vyriy-card__link{display:block;height:100%;padding:1rem;color:inherit;text-decoration:none}.vyriy-card__title{font-size:1.1rem;line-height:1.25;margin:0 0 .5rem}.vyriy-card__description{margin:0;color:var(--vyriy-muted)}.vyriy-card__meta{margin-top:.85rem;display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;color:var(--vyriy-muted);font-size:.9rem}.vyriy-card__tags,.vyriy-tags{display:flex;gap:.4rem;flex-wrap:wrap;list-style:none;padding:0;margin:0}.vyriy-card__tag,.vyriy-tag{display:inline-flex;border-radius:999px;background:var(--vyriy-accent-soft);color:var(--vyriy-accent);padding:.15rem .5rem;font-size:.78rem;text-decoration:none}.vyriy-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem;margin-top:1.5rem}.vyriy-catalog__content{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem}.vyriy-pagination{margin-top:2rem;display:flex;gap:.5rem;flex-wrap:wrap}.vyriy-pagination a,.vyriy-pagination span{min-width:2.25rem;padding:.4rem .7rem;border:1px solid var(--vyriy-line);border-radius:6px;text-align:center;text-decoration:none}.vyriy-pagination [aria-current=page]{background:var(--vyriy-accent);color:#fff;border-color:var(--vyriy-accent)}.vyriy-footer{border-top:1px solid var(--vyriy-line);color:var(--vyriy-muted);padding:1.5rem;text-align:center}.vyriy-search{max-width:720px}.vyriy-search__form{display:flex;gap:.5rem;margin:1.5rem 0}.vyriy-search__form input{flex:1;min-width:0;padding:.7rem .85rem;border:1px solid var(--vyriy-line);border-radius:6px}.vyriy-search__form button{padding:.7rem 1rem;border:0;border-radius:6px;background:var(--vyriy-accent);color:#fff;font-weight:700}.vyriy-search__result{padding:1rem 0;border-top:1px solid var(--vyriy-line)}@media (max-width:720px){.vyriy-header__inner{align-items:flex-start;flex-direction:column}.vyriy-main{padding-top:2rem}.vyriy-search__form{flex-direction:column}}
7
+ `;
8
+ const renderStylesheet = (stylesheetHref, stylesheetContent) => {
9
+ if (stylesheetContent) {
10
+ return `<style>${stylesheetContent.trim().replaceAll('</style', escapedStyleCloseTag)}</style>`;
11
+ }
12
+ return `<link href="${escapeHtml(stylesheetHref ?? '/styles.css')}" rel="stylesheet" />`;
13
+ };
14
+ const renderGoogleAnalyticsTag = (measurementId) => {
15
+ if (!measurementId) {
16
+ return '';
17
+ }
18
+ if (!googleAnalyticsMeasurementIdPattern.test(measurementId)) {
19
+ throw new Error(`Invalid Google Analytics measurement ID: ${measurementId}`);
20
+ }
21
+ return [
22
+ `<script async src="https://www.googletagmanager.com/gtag/js?id=${escapeHtml(measurementId)}"></script>`,
23
+ '<script>',
24
+ 'window.dataLayer = window.dataLayer || [];',
25
+ 'function gtag(){dataLayer.push(arguments);}',
26
+ "gtag('js', new Date());",
27
+ `gtag('config', '${escapeHtml(measurementId)}');`,
28
+ '</script>',
29
+ ].join('');
30
+ };
31
+ const minifyHtml = (document) => document.trim().replaceAll(/>\s+</gu, '><');
32
+ const renderHeader = (siteName) => {
33
+ return [
34
+ '<header class="vyriy-header"><div class="vyriy-header__inner">',
35
+ `<a class="vyriy-header__brand" href="/"><img class="vyriy-header__logo" src="/assets/vyriy-v-wings.png" alt="" width="72" height="46" />${escapeHtml(siteName)}</a>`,
36
+ '<nav class="vyriy-nav" aria-label="Primary"><a href="/blog/">Blog</a><a href="/docs/">Docs</a><a href="/examples/">Examples</a><a href="/search/">Search</a></nav>',
37
+ '</div></header>',
38
+ ].join('');
39
+ };
40
+ const renderLayout = (body, siteName, footerText) => {
41
+ return `<div class="vyriy-layout">${renderHeader(siteName)}<main class="vyriy-main">${body}</main><footer class="vyriy-footer">${escapeHtml(footerText)}</footer></div>`;
42
+ };
43
+ const renderDocument = (title, description, body, { canonicalPath, footerText, googleAnalyticsMeasurementId, robotsDirective, siteName, siteUrl, stylesheetContent, stylesheetHref, }) => {
44
+ return minifyHtml([
45
+ '<!DOCTYPE html><html lang="en"><head>',
46
+ '<meta charset="utf-8" />',
47
+ '<meta name="viewport" content="width=device-width, initial-scale=1" />',
48
+ `<title>${escapeHtml(title)}</title>`,
49
+ `<meta name="description" content="${escapeHtml(description)}" />`,
50
+ robotsDirective ? `<meta name="robots" content="${escapeHtml(robotsDirective)}" />` : '',
51
+ renderStylesheet(stylesheetHref, stylesheetContent),
52
+ canonicalPath ? `<link href="${escapeHtml(getAbsoluteUrl(canonicalPath, siteUrl))}" rel="canonical" />` : '',
53
+ '<link href="/favicon.ico" rel="icon" sizes="any" />',
54
+ '<link href="/favicon-16x16.png" rel="icon" sizes="16x16" />',
55
+ '<link href="/favicon-32x32.png" rel="icon" sizes="32x32" />',
56
+ '<link href="/apple-touch-icon.png" rel="apple-touch-icon" />',
57
+ renderGoogleAnalyticsTag(googleAnalyticsMeasurementId),
58
+ '</head><body>',
59
+ renderLayout(body, siteName, footerText),
60
+ '</body></html>',
61
+ ].join(''));
62
+ };
63
+ const renderCard = (entry) => {
64
+ return [
65
+ '<article class="vyriy-card">',
66
+ `<a aria-label="${escapeHtml(entry.title)}" class="vyriy-card__link" href="${escapeHtml(entry.href)}">`,
67
+ `<h2 class="vyriy-card__title">${escapeHtml(entry.title)}</h2>`,
68
+ `<p class="vyriy-card__description">${escapeHtml(entry.description)}</p>`,
69
+ entry.date || entry.tags.length > 0
70
+ ? [
71
+ '<div class="vyriy-card__meta">',
72
+ entry.date
73
+ ? `<time class="vyriy-card__date" datetime="${escapeHtml(entry.date)}">${escapeHtml(entry.date)}</time>`
74
+ : '',
75
+ entry.tags.length > 0
76
+ ? `<ul class="vyriy-card__tags" aria-label="Tags">${entry.tags
77
+ .map((tag) => `<li class="vyriy-card__tag">${escapeHtml(tag)}</li>`)
78
+ .join('')}</ul>`
79
+ : '',
80
+ '</div>',
81
+ ].join('')
82
+ : '',
83
+ '</a></article>',
84
+ ].join('');
85
+ };
86
+ export const renderPage = (page, options) => {
87
+ const featured = options.featured ?? [];
88
+ const related = options.related ?? [];
89
+ const tags = options.showTags ? page.tags : [];
90
+ const body = [
91
+ '<section class="vyriy-page"><div class="vyriy-page__content">',
92
+ renderMarkdown(page.content),
93
+ '</div>',
94
+ featured.length > 0
95
+ ? `<section aria-label="Featured posts" class="vyriy-grid">${featured
96
+ .map((item) => renderCard({
97
+ date: item.date ?? '',
98
+ description: item.description,
99
+ href: item.url,
100
+ tags: item.tags,
101
+ title: item.title,
102
+ }))
103
+ .join('')}</section>`
104
+ : '',
105
+ tags.length > 0
106
+ ? `<nav aria-label="Post tags" class="vyriy-tags">${tags
107
+ .map((tag) => `<a class="vyriy-tag" href="/search/?tag=${encodeURIComponent(tag)}">${escapeHtml(tag)}</a>`)
108
+ .join('')}</nav>`
109
+ : '',
110
+ related.length > 0
111
+ ? `<aside class="vyriy-page__related"><h2>Related posts</h2><div class="vyriy-grid">${related
112
+ .map((item) => renderCard({
113
+ date: '',
114
+ description: item.description,
115
+ href: item.url,
116
+ tags: item.tags,
117
+ title: item.title,
118
+ }))
119
+ .join('')}</div></aside>`
120
+ : '',
121
+ '</section>',
122
+ ].join('');
123
+ return renderDocument(page.title, page.description, body, options);
124
+ };
125
+ export const renderContentIndex = (entries, { page = 1, pages = 1, sectionPath, sectionTitle, ...documentOptions }) => {
126
+ const pageTitle = pages > 1 && page > 1 ? `${sectionTitle} ${page}` : sectionTitle;
127
+ const cards = entries.map(renderCard).join('');
128
+ const pagination = pages > 1
129
+ ? `<nav class="vyriy-pagination" aria-label="${escapeHtml(sectionTitle)} pagination">${Array.from({ length: pages }, (_value, index) => {
130
+ const targetPage = index + 1;
131
+ const href = targetPage <= 1 ? `/${sectionPath}/` : `/${sectionPath}/${targetPage}/`;
132
+ return targetPage === page
133
+ ? `<span aria-current="page">${targetPage}</span>`
134
+ : `<a href="${escapeHtml(href)}">${targetPage}</a>`;
135
+ }).join('')}</nav>`
136
+ : '';
137
+ return renderDocument(pageTitle, documentOptions.siteName === sectionTitle ? sectionTitle : `${sectionTitle} from ${documentOptions.siteName}.`, `<section class="vyriy-catalog"><h1>${escapeHtml(sectionTitle)}</h1><div class="vyriy-catalog__content">${cards}</div>${pagination}</section>`, {
138
+ ...documentOptions,
139
+ canonicalPath: page <= 1 ? `/${sectionPath}/` : `/${sectionPath}/${page}/`,
140
+ });
141
+ };
142
+ export const renderNotFoundPage = (options) => {
143
+ return renderDocument('Page not found', 'The requested page could not be found.', '<section class="vyriy-page"><div class="vyriy-page__content"><h1>Page not found</h1><p>The requested page could not be found.</p></div></section>', {
144
+ ...options,
145
+ canonicalPath: '',
146
+ });
147
+ };
148
+ export const searchScript = String.raw `
149
+ (() => {
150
+ const root = document.getElementById('search-root');
151
+ if (!root) return;
152
+ const documentsUrl = root.dataset.documentsUrl || '/search/documents.json';
153
+ const indexUrl = root.dataset.indexUrl || '/search/minisearch-index.json';
154
+ const parameters = new URLSearchParams(window.location.search);
155
+ const tag = parameters.get('tag');
156
+ const query = (parameters.get('q') || '').trim();
157
+ const input = document.querySelector('.vyriy-search__form input[name="q"]');
158
+ if (input) input.value = parameters.get('q') || '';
159
+ const searchOptions = {
160
+ boost: {
161
+ content: 1,
162
+ description: 2,
163
+ tags: 3,
164
+ title: 4
165
+ },
166
+ fuzzy: 0.2,
167
+ prefix: true
168
+ };
169
+ const miniSearchOptions = {
170
+ fields: ['title', 'description', 'tags', 'content'],
171
+ storeFields: ['title', 'description', 'section', 'slug', 'url', 'tags', 'date'],
172
+ searchOptions
173
+ };
174
+ const escapeHtml = (value) => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
175
+ const render = (documents) => {
176
+ if (!documents.length) {
177
+ root.innerHTML = '<p>No matching pages yet.</p>';
178
+ return;
179
+ }
180
+ root.innerHTML = documents.map((document) => '<article class="vyriy-search__result"><a href="' + escapeHtml(document.url) + '"><h2>' + escapeHtml(document.title) + '</h2><p>' + escapeHtml(document.description || '') + '</p></a></article>').join('');
181
+ };
182
+ const getDocumentMap = (documents) => new Map(documents.map((document) => [document.id, document]));
183
+ const getTagMatches = (documents, value) => {
184
+ const normalizedTag = value.trim().toLowerCase();
185
+ return documents.filter((document) => Array.isArray(document.tags) && document.tags.some((candidate) => candidate.trim().toLowerCase() === normalizedTag));
186
+ };
187
+ const getQueryMatches = (documents, indexJson, value) => {
188
+ const MiniSearch = window.MiniSearch;
189
+ if (!MiniSearch) throw new Error('MiniSearch is unavailable.');
190
+ const documentsById = getDocumentMap(documents);
191
+ const search = MiniSearch.loadJSON(JSON.stringify(indexJson), miniSearchOptions);
192
+ return search.search(value).map((result) => documentsById.get(result.id) || result).filter(Boolean);
193
+ };
194
+ if (!tag && !query) {
195
+ root.innerHTML = '<p>Choose a post tag or enter a search query to see matching pages.</p>';
196
+ return;
197
+ }
198
+ const dataRequests = [fetch(documentsUrl)];
199
+ if (query) dataRequests.push(fetch(indexUrl));
200
+ Promise.all(dataRequests)
201
+ .then((responses) => {
202
+ for (const response of responses) {
203
+ if (!response.ok) throw new Error('Search data unavailable.');
204
+ }
205
+ return Promise.all(responses.map((response, index) => index === 1 ? response.text() : response.json()));
206
+ })
207
+ .then(([documents, indexText]) => {
208
+ const indexJson = indexText ? JSON.parse(indexText) : undefined;
209
+ const queryMatches = query ? getQueryMatches(documents, indexJson, query) : documents;
210
+ render(tag ? getTagMatches(queryMatches, tag) : queryMatches);
211
+ })
212
+ .catch(() => { root.innerHTML = '<p>Search data could not be loaded.</p>'; });
213
+ })();
214
+ `;
215
+ export const renderSearchPage = (options) => {
216
+ return renderDocument(`${options.siteName} Search`, `Search pages from ${options.siteName}.`, [
217
+ '<section class="vyriy-search"><h1>Search</h1><p>Search by text query or tag.</p>',
218
+ '<form class="vyriy-search__form" action="/search/"><input name="q" placeholder="Search" type="search" /><button type="submit">Search</button></form>',
219
+ '<div data-documents-url="/search/documents.json" data-index-url="/search/minisearch-index.json" id="search-root"></div>',
220
+ '<script src="/assets/minisearch.js"></script>',
221
+ '<script src="/assets/search.js"></script>',
222
+ '</section>',
223
+ ].join(''), {
224
+ ...options,
225
+ canonicalPath: '',
226
+ robotsDirective: 'noindex, follow',
227
+ });
228
+ };
package/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './cli.js';
2
+ export * from './content.js';
3
+ export * from './markdown.js';
4
+ export * from './parse-page.js';
5
+ export * from './robots.js';
6
+ export * from './sitemap.js';
7
+ export * from './ssg.js';
8
+ export type * from './types.js';
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from './cli.js';
2
+ export * from './content.js';
3
+ export * from './markdown.js';
4
+ export * from './parse-page.js';
5
+ export * from './robots.js';
6
+ export * from './sitemap.js';
7
+ export * from './ssg.js';
package/markdown.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare const escapeHtml: (value: string) => string;
2
+ export declare const renderMarkdown: (markdown: string) => string;
3
+ export declare const getPlainTextFromMarkdown: (markdown: string) => string;
package/markdown.js ADDED
@@ -0,0 +1,31 @@
1
+ import { createElement } from 'react';
2
+ import { html as renderReactHtml } from '@vyriy/render/html';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import rehypeHighlight from 'rehype-highlight';
5
+ import remarkGfm from 'remark-gfm';
6
+ import { replaceInlineCode, replaceMarkdownLinks, stripFencedCode, stripHtmlTags } from './plain.js';
7
+ const markdownSyntaxPattern = /[#>*_~|[\](){}\\-]+/gu;
8
+ const whitespacePattern = /\s+/gu;
9
+ const htmlCharacterPattern = /[&<>"']/gu;
10
+ const htmlCharacterEntities = {
11
+ '&': '&amp;',
12
+ '"': '&quot;',
13
+ "'": '&#39;',
14
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ };
17
+ export const escapeHtml = (value) => {
18
+ return value.replaceAll(htmlCharacterPattern, (character) => htmlCharacterEntities[character]);
19
+ };
20
+ export const renderMarkdown = (markdown) => {
21
+ return renderReactHtml(createElement(ReactMarkdown, {
22
+ rehypePlugins: [rehypeHighlight],
23
+ remarkPlugins: [remarkGfm],
24
+ }, markdown));
25
+ };
26
+ export const getPlainTextFromMarkdown = (markdown) => {
27
+ return stripHtmlTags(replaceInlineCode(replaceMarkdownLinks(stripFencedCode(markdown))))
28
+ .replaceAll(markdownSyntaxPattern, ' ')
29
+ .replaceAll(whitespacePattern, ' ')
30
+ .trim();
31
+ };
package/package.json ADDED
@@ -0,0 +1,136 @@
1
+ {
2
+ "name": "@vyriy/ssg",
3
+ "version": "0.8.7",
4
+ "description": "Static Markdown site generator for Vyriy projects.",
5
+ "homepage": "https://vyriy.dev/docs/ssg/",
6
+ "type": "module",
7
+ "bin": {
8
+ "ssg": "./bin/index.js",
9
+ "vyriy-ssg": "./bin/index.js"
10
+ },
11
+ "dependencies": {
12
+ "@types/react": "^19.2.17",
13
+ "@vyriy/render": "0.8.7",
14
+ "minisearch": "^7.2.0",
15
+ "react": "^19.2.7",
16
+ "react-markdown": "^10.1.0",
17
+ "rehype-highlight": "^7.0.2",
18
+ "remark-gfm": "^4.0.1"
19
+ },
20
+ "agents": "./AGENTS.md",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/evheniy/vyriy",
25
+ "directory": "packages/ssg"
26
+ },
27
+ "main": "./index.js",
28
+ "types": "./index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./index.d.ts",
32
+ "import": "./index.js",
33
+ "default": "./index.js"
34
+ },
35
+ "./cli": {
36
+ "types": "./cli.d.ts",
37
+ "import": "./cli.js",
38
+ "default": "./cli.js"
39
+ },
40
+ "./cli.js": {
41
+ "types": "./cli.d.ts",
42
+ "import": "./cli.js",
43
+ "default": "./cli.js"
44
+ },
45
+ "./content": {
46
+ "types": "./content.d.ts",
47
+ "import": "./content.js",
48
+ "default": "./content.js"
49
+ },
50
+ "./content.js": {
51
+ "types": "./content.d.ts",
52
+ "import": "./content.js",
53
+ "default": "./content.js"
54
+ },
55
+ "./html": {
56
+ "types": "./html.d.ts",
57
+ "import": "./html.js",
58
+ "default": "./html.js"
59
+ },
60
+ "./html.js": {
61
+ "types": "./html.d.ts",
62
+ "import": "./html.js",
63
+ "default": "./html.js"
64
+ },
65
+ "./index": {
66
+ "types": "./index.d.ts",
67
+ "import": "./index.js",
68
+ "default": "./index.js"
69
+ },
70
+ "./index.js": {
71
+ "types": "./index.d.ts",
72
+ "import": "./index.js",
73
+ "default": "./index.js"
74
+ },
75
+ "./markdown": {
76
+ "types": "./markdown.d.ts",
77
+ "import": "./markdown.js",
78
+ "default": "./markdown.js"
79
+ },
80
+ "./markdown.js": {
81
+ "types": "./markdown.d.ts",
82
+ "import": "./markdown.js",
83
+ "default": "./markdown.js"
84
+ },
85
+ "./parse-page": {
86
+ "types": "./parse-page.d.ts",
87
+ "import": "./parse-page.js",
88
+ "default": "./parse-page.js"
89
+ },
90
+ "./parse-page.js": {
91
+ "types": "./parse-page.d.ts",
92
+ "import": "./parse-page.js",
93
+ "default": "./parse-page.js"
94
+ },
95
+ "./plain": {
96
+ "types": "./plain.d.ts",
97
+ "import": "./plain.js",
98
+ "default": "./plain.js"
99
+ },
100
+ "./plain.js": {
101
+ "types": "./plain.d.ts",
102
+ "import": "./plain.js",
103
+ "default": "./plain.js"
104
+ },
105
+ "./robots": {
106
+ "types": "./robots.d.ts",
107
+ "import": "./robots.js",
108
+ "default": "./robots.js"
109
+ },
110
+ "./robots.js": {
111
+ "types": "./robots.d.ts",
112
+ "import": "./robots.js",
113
+ "default": "./robots.js"
114
+ },
115
+ "./sitemap": {
116
+ "types": "./sitemap.d.ts",
117
+ "import": "./sitemap.js",
118
+ "default": "./sitemap.js"
119
+ },
120
+ "./sitemap.js": {
121
+ "types": "./sitemap.d.ts",
122
+ "import": "./sitemap.js",
123
+ "default": "./sitemap.js"
124
+ },
125
+ "./ssg": {
126
+ "types": "./ssg.d.ts",
127
+ "import": "./ssg.js",
128
+ "default": "./ssg.js"
129
+ },
130
+ "./ssg.js": {
131
+ "types": "./ssg.d.ts",
132
+ "import": "./ssg.js",
133
+ "default": "./ssg.js"
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,2 @@
1
+ import type { PageData } from './types.js';
2
+ export declare const parsePage: (markdown: string, defaultTitle?: string) => PageData;
package/parse-page.js ADDED
@@ -0,0 +1,125 @@
1
+ import { replaceInlineCode, replaceMarkdownLinks } from './plain.js';
2
+ const markdownSyntaxPattern = /[#>*_~|[\](){}\\-]+/gu;
3
+ const whitespacePattern = /\s+/gu;
4
+ const parseMetadata = (metadata) => {
5
+ const entries = {};
6
+ let listKey = '';
7
+ for (const line of metadata.split('\n')) {
8
+ const trimmedLine = line.trimStart();
9
+ if (trimmedLine.startsWith('- ') && listKey) {
10
+ const value = entries[listKey];
11
+ entries[listKey] = [...(Array.isArray(value) ? value : []), trimmedLine.slice(2).trim()];
12
+ continue;
13
+ }
14
+ const separatorIndex = line.indexOf(':');
15
+ if (separatorIndex === -1) {
16
+ continue;
17
+ }
18
+ const key = line.slice(0, separatorIndex).trim();
19
+ const value = line.slice(separatorIndex + 1).trim();
20
+ listKey = key;
21
+ if (!value) {
22
+ entries[key] = [];
23
+ continue;
24
+ }
25
+ if (value === 'true' || value === 'false') {
26
+ entries[key] = value === 'true';
27
+ continue;
28
+ }
29
+ entries[key] = value;
30
+ }
31
+ return entries;
32
+ };
33
+ const getString = (value) => {
34
+ return typeof value === 'string' ? value : '';
35
+ };
36
+ const getNumber = (value) => {
37
+ if (typeof value !== 'string') {
38
+ return undefined;
39
+ }
40
+ const number = Number(value);
41
+ return Number.isFinite(number) ? number : undefined;
42
+ };
43
+ const getPlainText = (value) => {
44
+ return replaceInlineCode(replaceMarkdownLinks(value))
45
+ .replaceAll(markdownSyntaxPattern, ' ')
46
+ .replaceAll(whitespacePattern, ' ')
47
+ .trim();
48
+ };
49
+ const getFallbackTitle = (markdown, defaultTitle) => {
50
+ for (const line of markdown.split('\n')) {
51
+ if (!line.startsWith('#')) {
52
+ continue;
53
+ }
54
+ const afterMarker = line.slice(1);
55
+ const titleText = afterMarker.trimStart();
56
+ if (afterMarker === titleText) {
57
+ continue;
58
+ }
59
+ const title = getPlainText(titleText);
60
+ if (title) {
61
+ return title;
62
+ }
63
+ }
64
+ return defaultTitle;
65
+ };
66
+ const getFallbackDescription = (markdown) => {
67
+ let insideCodeBlock = false;
68
+ for (const paragraph of markdown.split(/\n\s*\n/u)) {
69
+ const lines = paragraph
70
+ .split('\n')
71
+ .map((line) => line.trim())
72
+ .filter(Boolean);
73
+ const visibleLines = [];
74
+ for (const line of lines) {
75
+ if (line.startsWith('```')) {
76
+ insideCodeBlock = !insideCodeBlock;
77
+ continue;
78
+ }
79
+ if (!insideCodeBlock) {
80
+ visibleLines.push(line);
81
+ }
82
+ }
83
+ const text = visibleLines.join(' ');
84
+ if (!text || text.startsWith('#') || text.startsWith('|')) {
85
+ continue;
86
+ }
87
+ return getPlainText(text);
88
+ }
89
+ return '';
90
+ };
91
+ export const parsePage = (markdown, defaultTitle = 'Vyriy') => {
92
+ const frontmatter = /^---\n(?<metadata>[\s\S]*?)\n---\n(?<content>[\s\S]*)$/u.exec(markdown);
93
+ if (!frontmatter?.groups) {
94
+ return {
95
+ content: markdown,
96
+ date: '',
97
+ description: getFallbackDescription(markdown),
98
+ featured: false,
99
+ homePage: false,
100
+ published: true,
101
+ tags: [],
102
+ title: getFallbackTitle(markdown, defaultTitle),
103
+ };
104
+ }
105
+ const metadata = parseMetadata(frontmatter.groups.metadata);
106
+ const content = frontmatter.groups.content.trim();
107
+ const featured = metadata.featured;
108
+ const homePage = metadata.homePage;
109
+ const homePageOrder = getNumber(metadata.homePageOrder);
110
+ const published = metadata.published;
111
+ const tags = metadata.tags;
112
+ const updatedAt = getString(metadata.updatedAt);
113
+ return {
114
+ content,
115
+ date: getString(metadata.date),
116
+ description: getString(metadata.description) || getFallbackDescription(content),
117
+ featured: typeof featured === 'boolean' ? featured : false,
118
+ homePage: typeof homePage === 'boolean' ? homePage : false,
119
+ ...(homePageOrder === undefined ? {} : { homePageOrder }),
120
+ published: typeof published === 'boolean' ? published : true,
121
+ tags: Array.isArray(tags) ? tags : [],
122
+ title: getString(metadata.title) || getFallbackTitle(content, defaultTitle),
123
+ ...(updatedAt ? { updatedAt } : {}),
124
+ };
125
+ };
package/plain.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare const replaceInlineCode: (value: string) => string;
2
+ export declare const replaceMarkdownLinks: (value: string) => string;
3
+ export declare const stripFencedCode: (value: string) => string;
4
+ export declare const stripHtmlTags: (value: string) => string;
package/plain.js ADDED
@@ -0,0 +1,73 @@
1
+ export const replaceInlineCode = (value) => {
2
+ let result = '';
3
+ let index = 0;
4
+ while (index < value.length) {
5
+ if (value[index] === '`') {
6
+ const endIndex = value.indexOf('`', index + 1);
7
+ if (endIndex !== -1) {
8
+ result += ` ${value.slice(index + 1, endIndex)} `;
9
+ index = endIndex + 1;
10
+ continue;
11
+ }
12
+ }
13
+ result += value[index];
14
+ index += 1;
15
+ }
16
+ return result;
17
+ };
18
+ export const replaceMarkdownLinks = (value) => {
19
+ let result = '';
20
+ let index = 0;
21
+ while (index < value.length) {
22
+ const isImage = value[index] === '!' && value[index + 1] === '[';
23
+ const isLink = value[index] === '[';
24
+ if (isImage || isLink) {
25
+ const labelStartIndex = index + (isImage ? 2 : 1);
26
+ const labelEndIndex = value.indexOf(']', labelStartIndex);
27
+ const urlStartIndex = labelEndIndex + 1;
28
+ if (labelEndIndex !== -1 && value[urlStartIndex] === '(') {
29
+ const urlEndIndex = value.indexOf(')', urlStartIndex + 1);
30
+ if (urlEndIndex !== -1) {
31
+ result += ` ${value.slice(labelStartIndex, labelEndIndex)} `;
32
+ index = urlEndIndex + 1;
33
+ continue;
34
+ }
35
+ }
36
+ }
37
+ result += value[index];
38
+ index += 1;
39
+ }
40
+ return result;
41
+ };
42
+ export const stripFencedCode = (value) => {
43
+ let result = '';
44
+ let index = 0;
45
+ while (index < value.length) {
46
+ if (value.startsWith('```', index)) {
47
+ const endIndex = value.indexOf('```', index + 3);
48
+ result += ' ';
49
+ index = endIndex === -1 ? value.length : endIndex + 3;
50
+ continue;
51
+ }
52
+ result += value[index];
53
+ index += 1;
54
+ }
55
+ return result;
56
+ };
57
+ export const stripHtmlTags = (value) => {
58
+ let result = '';
59
+ let index = 0;
60
+ while (index < value.length) {
61
+ if (value[index] === '<') {
62
+ const endIndex = value.indexOf('>', index + 1);
63
+ if (endIndex !== -1) {
64
+ result += ' ';
65
+ index = endIndex + 1;
66
+ continue;
67
+ }
68
+ }
69
+ result += value[index];
70
+ index += 1;
71
+ }
72
+ return result;
73
+ };
package/robots.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare const renderRobotsTxt: (siteUrl?: string) => string;
2
+ export declare const writeRobotsTxt: (outputDirectory: string, siteUrl?: string) => Promise<void>;
package/robots.js ADDED
@@ -0,0 +1,19 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { getAbsoluteUrl } from './sitemap.js';
4
+ export const renderRobotsTxt = (siteUrl) => {
5
+ return [
6
+ 'User-agent: *',
7
+ 'Allow: /',
8
+ 'Disallow: /storybook/',
9
+ `Sitemap: ${getAbsoluteUrl('/sitemap.xml', siteUrl)}`,
10
+ '',
11
+ ].join('\n');
12
+ };
13
+ export const writeRobotsTxt = async (outputDirectory, siteUrl) => {
14
+ const outputPath = join(outputDirectory, 'robots.txt');
15
+ await mkdir(dirname(outputPath), {
16
+ recursive: true,
17
+ });
18
+ await writeFile(outputPath, renderRobotsTxt(siteUrl));
19
+ };
package/sitemap.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { SitemapUrl } from './types.js';
2
+ export declare const getAbsoluteUrl: (path: string, siteUrl?: string) => string;
3
+ export declare const renderSitemap: (urls: readonly SitemapUrl[], siteUrl?: string) => string;
4
+ export declare const writeSitemap: (outputDirectory: string, urls: readonly SitemapUrl[], siteUrl?: string) => Promise<void>;