@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/AGENTS.md ADDED
@@ -0,0 +1,102 @@
1
+ # Project Agent Guide
2
+
3
+ This repository follows a calm engineering style: changes should be explicit, reusable, typed, documented, tested, and easy to reason about.
4
+
5
+ Use this guide as the default behavior for AI agents and contributors working in this repository. Prefer local package conventions when they are more specific than this document.
6
+
7
+ ## Core Principles
8
+
9
+ - Prefer simple modules over clever frameworks or hidden conventions.
10
+ - Keep package and project boundaries explicit.
11
+ - Avoid project-specific coupling in reusable code.
12
+ - Extract only proven reusable behavior.
13
+ - Keep public APIs small, typed, documented, and stable.
14
+ - Prefer SSR-friendly and SSG-friendly code paths when working with frontend or shared code.
15
+ - Keep integrations replaceable and avoid hard coupling to a CMS, framework, vendor, or runtime host.
16
+ - Prefer infrastructure assumptions that are easy to deploy, observe, and replace.
17
+ - Prefer the option that is simpler to explain, easier to evolve, and calmer to maintain.
18
+
19
+ ## File Shape
20
+
21
+ - Prefer one exported runtime method, component, helper, or class per production file when it stays readable.
22
+ - Prefer one matching test file per production file, for example `feature.ts` and `feature.test.ts`.
23
+ - Use focused folders when behavior naturally splits into several related files.
24
+ - Keep `index.ts` as a public re-export surface only. Do not place implementation logic in it.
25
+ - Use relative import and export specifiers that match the package module style.
26
+ - Use `.js` relative specifiers in TypeScript source for ESM/NodeNext packages.
27
+ - Add `types.ts` when public shared types are part of the package contract.
28
+ - Keep constants near the code that owns them unless they are shared or clarify repeated behavior.
29
+
30
+ ## Public Surface
31
+
32
+ - Every new public export must be re-exported from the package or module public entry point.
33
+ - Add or update public-surface tests when exports change.
34
+ - Add JSDoc for public exports when behavior, parameters, return values, or usage expectations need explanation.
35
+ - Avoid exporting internal helpers only to make tests easier.
36
+ - Do not hand-maintain package `exports` maps unless the project has a real custom publishing need.
37
+
38
+ ## Tests
39
+
40
+ - Cover public behavior and meaningful edge cases.
41
+ - Prefer behavior-focused tests over private implementation lock-in.
42
+ - Keep tests deterministic.
43
+ - Avoid real network, filesystem, timers, browser, or cloud dependencies unless the behavior specifically requires them.
44
+ - When mocking modules, install mocks before loading the module under test.
45
+ - Use focused validation when changing behavior.
46
+
47
+ Example validation commands:
48
+
49
+ ```bash
50
+ yarn test
51
+ ```
52
+
53
+ For workspaces, prefer the project convention, for example:
54
+
55
+ ```bash
56
+ yarn workspace <package-name> test
57
+ ```
58
+
59
+ For Jest-based packages, focused validation may look like:
60
+
61
+ ```bash
62
+ yarn jest packages/<package> --runInBand --coverage=false
63
+ ```
64
+
65
+ ## Documentation
66
+
67
+ - Keep `README.md` concise and usage-oriented.
68
+ - Start package READMEs with `# <package>`.
69
+ - Document real public exports, supported options, and examples that actually work.
70
+ - Update docs when public behavior changes.
71
+ - Keep generated docs wrappers, such as `doc.mdx`, aligned with the README when the project uses them.
72
+ - For component packages, include visual documentation or stories for supported states and common usage.
73
+
74
+ ## Components
75
+
76
+ - Prefer lightweight React components with TypeScript when working in React packages.
77
+ - Keep components SSR-friendly and avoid browser globals during render.
78
+ - Prefer composable props and predictable ergonomics.
79
+ - Put each public component in its own file with a matching test.
80
+ - Add stories or examples when a component has visual states, variants, or interaction states.
81
+ - Keep styling explicit and reusable. Avoid hidden theme assumptions unless they are part of the package contract.
82
+
83
+ ## Change Discipline
84
+
85
+ - Keep changes scoped to the requested behavior.
86
+ - Avoid unrelated refactors and metadata churn.
87
+ - Sync implementation, tests, docs, examples, and public re-exports together.
88
+ - Do not introduce new dependencies unless they clearly reduce complexity or are already part of the project direction.
89
+ - Prefer small, reviewable changes over broad rewrites.
90
+ - Preserve existing conventions unless there is a clear reason to change them.
91
+
92
+ ## Before Finishing
93
+
94
+ Check that the change is complete:
95
+
96
+ - Public exports are updated.
97
+ - Public-surface tests are updated when exports change.
98
+ - Matching unit tests exist for new behavior.
99
+ - README examples still match the real API.
100
+ - Visual docs, stories, or examples are updated for visible component behavior.
101
+ - TypeScript imports follow the package module style.
102
+ - No unrelated files, formatting churn, or generated artifacts were changed.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vyriy contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # @vyriy/ssg
2
+
3
+ Part of [Vyriy](https://vyriy.dev) - a calm architecture toolkit for TypeScript, React, SSR, SSG, APIs, and cloud-ready apps.
4
+
5
+ Full documentation: https://vyriy.dev/docs/ssg/
6
+
7
+ Static Markdown site generator for Vyriy-style content sites.
8
+
9
+ ## Purpose
10
+
11
+ This package builds a static site from Markdown `README.md` files. It is intended for content-first sites that need HTML pages, section catalogs, MiniSearch data, featured home content, related links, sitemap, robots, and copied public assets without adopting a full framework.
12
+
13
+ Markdown rendering uses `react-markdown` with GitHub-flavored Markdown and `rehype-highlight`, so content supports tables, task lists, autolinks, and highlighted fenced code blocks.
14
+
15
+ ## CLI
16
+
17
+ Build a site from `site` into `dist`:
18
+
19
+ ```bash
20
+ vyriy-ssg site --output dist
21
+ ```
22
+
23
+ The package also exposes the short `ssg` command:
24
+
25
+ ```bash
26
+ ssg site -o dist
27
+ ```
28
+
29
+ Useful options:
30
+
31
+ - `--site-url <url>` sets canonical URLs and sitemap locations.
32
+ - `--site-name <name>` sets the built-in theme name.
33
+ - `--stylesheet <href>` links a stylesheet instead of using the built-in CSS.
34
+ - `--stylesheet-file <path>` inlines a stylesheet file.
35
+ - `--ga <id>` adds a Google Analytics measurement ID.
36
+
37
+ ## Content
38
+
39
+ By default, the generator expects this shape:
40
+
41
+ ```txt
42
+ site/
43
+ home/README.md
44
+ consulting/README.md
45
+ docs/README.md
46
+ blog/<slug>/README.md
47
+ docs/<slug>/README.md
48
+ examples/<slug>/README.md
49
+ public/
50
+ ```
51
+
52
+ Markdown files can include simple frontmatter:
53
+
54
+ ```md
55
+ ---
56
+ title: Calm deployment
57
+ description: Deployment notes for calm static sites.
58
+ date: 2026-06-16
59
+ published: true
60
+ homePage: true
61
+ tags:
62
+ - ssg
63
+ - deployment
64
+ ---
65
+
66
+ # Calm deployment
67
+
68
+ Page content.
69
+ ```
70
+
71
+ ## API
72
+
73
+ ```ts
74
+ import { buildStaticSite } from '@vyriy/ssg';
75
+
76
+ await buildStaticSite({
77
+ contentPath: 'site',
78
+ outputPath: 'dist',
79
+ siteUrl: 'https://vyriy.dev',
80
+ });
81
+ ```
82
+
83
+ Custom sections are supported:
84
+
85
+ ```ts
86
+ await buildStaticSite({
87
+ sections: [
88
+ {
89
+ path: 'articles',
90
+ title: 'Articles',
91
+ },
92
+ ],
93
+ });
94
+ ```
95
+
96
+ Set `index: false` when a section should generate individual pages and search data without its own paginated catalog.
97
+
98
+ ## Exports
99
+
100
+ - `buildStaticSite(options)` builds the static site.
101
+ - `runSsgCli(args)` runs the CLI programmatically.
102
+ - `parsePage(markdown)` parses page frontmatter and fallback metadata.
103
+ - `renderMarkdown(markdown)` renders Markdown through `react-markdown`, `remark-gfm`, and `rehype-highlight`.
104
+ - `renderSitemap(urls, siteUrl)` renders sitemap XML.
105
+ - `renderRobotsTxt(siteUrl)` renders robots.txt.
package/bin/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runSsgCli } from '../index.js';
3
+ await runSsgCli(process.argv.slice(2));
package/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { BuildStaticSiteOptions } from './types.js';
2
+ type CliOptions = BuildStaticSiteOptions & {
3
+ readonly help?: boolean;
4
+ };
5
+ export declare const parseSsgCliArgs: (args: readonly string[]) => Promise<CliOptions>;
6
+ export declare const runSsgCli: (args?: readonly string[]) => Promise<void>;
7
+ export {};
package/cli.js ADDED
@@ -0,0 +1,84 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { buildStaticSite } from './ssg.js';
3
+ const helpText = `Usage:
4
+ ssg <content> [options]
5
+ vyriy-ssg <content> [options]
6
+
7
+ Options:
8
+ -o, --output <path> Output directory. Defaults to dist.
9
+ --site-url <url> Absolute site URL for canonical links and sitemap.
10
+ --site-name <name> Site name used by the default theme.
11
+ --stylesheet <href> Stylesheet URL. Omit to use the built-in CSS.
12
+ --stylesheet-file <path> Inline CSS from a local file.
13
+ --ga <id> Google Analytics measurement ID.
14
+ -h, --help Show this help.
15
+ `;
16
+ const readValue = (args, index, flag) => {
17
+ const value = args[index + 1];
18
+ if (!value || value.startsWith('-')) {
19
+ throw new Error(`Missing value for ${flag}.`);
20
+ }
21
+ return value;
22
+ };
23
+ const optionHandlers = {
24
+ '--ga': (args, index, options, arg) => {
25
+ options.googleAnalyticsMeasurementId = readValue(args, index, arg);
26
+ },
27
+ '--output': (args, index, options, arg) => {
28
+ options.outputPath = readValue(args, index, arg);
29
+ },
30
+ '--site-name': (args, index, options, arg) => {
31
+ options.siteName = readValue(args, index, arg);
32
+ },
33
+ '--site-url': (args, index, options, arg) => {
34
+ options.siteUrl = readValue(args, index, arg);
35
+ },
36
+ '--stylesheet': (args, index, options, arg) => {
37
+ options.stylesheetHref = readValue(args, index, arg);
38
+ },
39
+ '--stylesheet-file': async (args, index, options, arg) => {
40
+ options.stylesheetContent = await readFile(readValue(args, index, arg), 'utf8');
41
+ },
42
+ '-o': (args, index, options, arg) => {
43
+ options.outputPath = readValue(args, index, arg);
44
+ },
45
+ };
46
+ export const parseSsgCliArgs = async (args) => {
47
+ const options = {};
48
+ let contentPath;
49
+ let index = 0;
50
+ while (index < args.length) {
51
+ const arg = args[index] ?? '';
52
+ if (arg === '-h' || arg === '--help') {
53
+ return {
54
+ help: true,
55
+ };
56
+ }
57
+ const optionHandler = optionHandlers[arg];
58
+ if (optionHandler) {
59
+ await optionHandler(args, index, options, arg);
60
+ index += 2;
61
+ continue;
62
+ }
63
+ if (arg.startsWith('-')) {
64
+ throw new Error(`Unknown option: ${arg}`);
65
+ }
66
+ if (contentPath) {
67
+ throw new Error(`Unexpected argument: ${arg}`);
68
+ }
69
+ contentPath = arg;
70
+ index += 1;
71
+ }
72
+ return {
73
+ ...options,
74
+ ...(contentPath ? { contentPath } : {}),
75
+ };
76
+ };
77
+ export const runSsgCli = async (args = process.argv.slice(2)) => {
78
+ const options = await parseSsgCliArgs(args);
79
+ if (options.help) {
80
+ process.stdout.write(helpText);
81
+ return;
82
+ }
83
+ await buildStaticSite(options);
84
+ };
package/content.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { BuildStaticSiteOptions, ContentEntry, ContentSectionBuildResult, HomePageFeaturedContentItem, RelatedDocumentsMap, SearchDocument, StaticSiteSection } from './types.js';
2
+ type ContentDataSection = {
3
+ readonly entries: readonly ContentEntry[];
4
+ readonly section: string;
5
+ };
6
+ type RenderOptions = Required<Pick<BuildStaticSiteOptions, 'defaultTitle' | 'footerText' | 'siteName'>> & Pick<BuildStaticSiteOptions, 'googleAnalyticsMeasurementId' | 'siteUrl' | 'stylesheetContent' | 'stylesheetHref'>;
7
+ export declare const contentSearchOptions: {
8
+ boost: {
9
+ content: number;
10
+ description: number;
11
+ tags: number;
12
+ title: number;
13
+ };
14
+ fuzzy: number;
15
+ prefix: boolean;
16
+ };
17
+ export declare const contentMiniSearchOptions: {
18
+ fields: string[];
19
+ storeFields: string[];
20
+ searchOptions: {
21
+ boost: {
22
+ content: number;
23
+ description: number;
24
+ tags: number;
25
+ title: number;
26
+ };
27
+ fuzzy: number;
28
+ prefix: boolean;
29
+ };
30
+ };
31
+ export declare const findReadmePaths: (directory: string) => Promise<readonly string[]>;
32
+ export declare const buildContentEntries: (section: StaticSiteSection, contentPath: string, defaultTitle?: string) => Promise<ContentEntry[]>;
33
+ export declare const buildContentSection: (section: StaticSiteSection, contentPath: string, outputDirectory: string, renderOptions: RenderOptions) => Promise<ContentSectionBuildResult>;
34
+ export declare const writeContentEntryDocuments: (section: StaticSiteSection, entries: readonly ContentEntry[], outputDirectory: string, renderOptions: RenderOptions & {
35
+ readonly relatedDocuments?: RelatedDocumentsMap;
36
+ }) => Promise<void>;
37
+ export declare const getSearchDocuments: (section: string, entries: readonly ContentEntry[]) => readonly SearchDocument[];
38
+ export declare const getSiteSearchDocuments: (sections: readonly ContentDataSection[]) => readonly SearchDocument[];
39
+ export declare const getMiniSearchIndexJson: (documents: readonly SearchDocument[]) => unknown;
40
+ export declare const getRelatedDocumentsMap: (documents: readonly SearchDocument[]) => RelatedDocumentsMap;
41
+ export declare const getHomePageFeaturedContent: (sections: readonly ContentDataSection[]) => readonly HomePageFeaturedContentItem[];
42
+ export declare const writeContentData: (sections: readonly ContentDataSection[], outputDirectory: string) => Promise<void>;
43
+ export {};
package/content.js ADDED
@@ -0,0 +1,237 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import MiniSearch from 'minisearch';
4
+ import { renderContentIndex, renderPage } from './html.js';
5
+ import { getPlainTextFromMarkdown } from './markdown.js';
6
+ import { parsePage } from './parse-page.js';
7
+ const relatedDocumentCount = 4;
8
+ const homePageFeaturedContentCount = 4;
9
+ const minimumRelatedScore = 10;
10
+ export const contentSearchOptions = {
11
+ boost: {
12
+ content: 1,
13
+ description: 2,
14
+ tags: 3,
15
+ title: 4,
16
+ },
17
+ fuzzy: 0.2,
18
+ prefix: true,
19
+ };
20
+ export const contentMiniSearchOptions = {
21
+ fields: [
22
+ 'title',
23
+ 'description',
24
+ 'tags',
25
+ 'content',
26
+ ],
27
+ storeFields: [
28
+ 'title',
29
+ 'description',
30
+ 'section',
31
+ 'slug',
32
+ 'url',
33
+ 'tags',
34
+ 'date',
35
+ ],
36
+ searchOptions: contentSearchOptions,
37
+ };
38
+ const isNodeError = (error) => {
39
+ return typeof error === 'object' && error !== null && 'code' in error;
40
+ };
41
+ export const findReadmePaths = async (directory) => {
42
+ let entries;
43
+ try {
44
+ entries = await readdir(directory, {
45
+ withFileTypes: true,
46
+ });
47
+ }
48
+ catch (error) {
49
+ if (isNodeError(error) && error.code === 'ENOENT') {
50
+ return [];
51
+ }
52
+ throw error;
53
+ }
54
+ const paths = await Promise.all(entries.map(async (entry) => {
55
+ const entryPath = join(directory, entry.name);
56
+ if (entry.isDirectory()) {
57
+ return findReadmePaths(entryPath);
58
+ }
59
+ return entry.name === 'README.md' ? [entryPath] : [];
60
+ }));
61
+ return paths.flat();
62
+ };
63
+ const getSlug = (sectionDirectory, readmePath) => {
64
+ return dirname(relative(sectionDirectory, readmePath))
65
+ .split(sep)
66
+ .filter((segment) => segment && segment !== '.')
67
+ .join('/');
68
+ };
69
+ const writeDocument = async (outputPath, document) => {
70
+ await mkdir(dirname(outputPath), {
71
+ recursive: true,
72
+ });
73
+ await writeFile(outputPath, document);
74
+ };
75
+ const getContentIndexHref = (sectionPath, page) => {
76
+ return page <= 1 ? `/${sectionPath}/` : `/${sectionPath}/${page}/`;
77
+ };
78
+ const getContentIndexOutputPath = (outputDirectory, sectionPath, page) => {
79
+ return page <= 1
80
+ ? join(outputDirectory, sectionPath, 'index.html')
81
+ : join(outputDirectory, sectionPath, String(page), 'index.html');
82
+ };
83
+ export const buildContentEntries = async (section, contentPath, defaultTitle = 'Vyriy') => {
84
+ const sectionDirectory = join(contentPath, section.path);
85
+ const readmePaths = await findReadmePaths(sectionDirectory);
86
+ const entries = (await Promise.all(readmePaths.map(async (readmePath) => {
87
+ const slug = getSlug(sectionDirectory, readmePath);
88
+ const page = parsePage(await readFile(readmePath, 'utf8'), defaultTitle);
89
+ if (!slug || !page.published) {
90
+ return undefined;
91
+ }
92
+ return {
93
+ ...page,
94
+ href: `/${section.path}/${slug}/`,
95
+ section: section.path,
96
+ slug,
97
+ };
98
+ })))
99
+ .filter((entry) => Boolean(entry))
100
+ .sort((left, right) => right.date.localeCompare(left.date) || left.title.localeCompare(right.title));
101
+ return entries;
102
+ };
103
+ export const buildContentSection = async (section, contentPath, outputDirectory, renderOptions) => {
104
+ const entries = await buildContentEntries(section, contentPath, renderOptions.defaultTitle);
105
+ const pageSize = section.pageSize ?? 10;
106
+ const pages = Math.max(1, Math.ceil(entries.length / pageSize));
107
+ const indexPaths = Array.from({ length: pages }, (_value, index) => getContentIndexHref(section.path, index + 1));
108
+ await Promise.all(indexPaths.map((_path, index) => {
109
+ const page = index + 1;
110
+ const pageEntries = entries.slice(index * pageSize, page * pageSize);
111
+ return writeDocument(getContentIndexOutputPath(outputDirectory, section.path, page), renderContentIndex(pageEntries, {
112
+ ...renderOptions,
113
+ page,
114
+ pages,
115
+ sectionPath: section.path,
116
+ sectionTitle: section.title,
117
+ }));
118
+ }));
119
+ return {
120
+ entries,
121
+ indexPaths,
122
+ };
123
+ };
124
+ export const writeContentEntryDocuments = async (section, entries, outputDirectory, renderOptions) => {
125
+ await Promise.all(entries.map((entry) => writeDocument(join(outputDirectory, section.path, entry.slug, 'index.html'), renderPage(entry, {
126
+ ...renderOptions,
127
+ canonicalPath: entry.href,
128
+ related: renderOptions.relatedDocuments?.[`${section.path}:${entry.slug}`] ?? [],
129
+ showTags: true,
130
+ }))));
131
+ };
132
+ const getOptionalDate = (date) => date || undefined;
133
+ export const getSearchDocuments = (section, entries) => {
134
+ return entries.map((entry) => ({
135
+ content: getPlainTextFromMarkdown(entry.content),
136
+ date: getOptionalDate(entry.date),
137
+ description: entry.description,
138
+ id: `${section}:${entry.slug}`,
139
+ section,
140
+ slug: entry.slug,
141
+ tags: entry.tags,
142
+ title: entry.title,
143
+ updatedAt: entry.updatedAt,
144
+ url: entry.href,
145
+ }));
146
+ };
147
+ export const getSiteSearchDocuments = (sections) => {
148
+ return sections.flatMap((section) => getSearchDocuments(section.section, section.entries));
149
+ };
150
+ export const getMiniSearchIndexJson = (documents) => {
151
+ const miniSearch = new MiniSearch(contentMiniSearchOptions);
152
+ miniSearch.addAll([...documents]);
153
+ return miniSearch.toJSON();
154
+ };
155
+ const wordPattern = /[\p{L}\p{N}]+/gu;
156
+ const getKeywords = (text) => {
157
+ return new Set((text.toLowerCase().match(wordPattern) ?? []).map((word) => word.trim()).filter((word) => word.length >= 4));
158
+ };
159
+ const countKeywordMatches = (keywords, text) => {
160
+ const targetKeywords = getKeywords(text);
161
+ let matches = 0;
162
+ for (const keyword of keywords) {
163
+ if (targetKeywords.has(keyword)) {
164
+ matches += 1;
165
+ }
166
+ }
167
+ return matches;
168
+ };
169
+ const getSharedTagCount = (leftTags, rightTags) => {
170
+ const rightTagSet = new Set(rightTags.map((tag) => tag.toLowerCase()));
171
+ return leftTags.filter((tag) => rightTagSet.has(tag.toLowerCase())).length;
172
+ };
173
+ const getRelatedScore = (document, candidate) => {
174
+ const keywords = getKeywords(`${document.title} ${document.description}`);
175
+ return (getSharedTagCount(document.tags, candidate.tags) * 10 +
176
+ countKeywordMatches(keywords, candidate.title) * 4 +
177
+ countKeywordMatches(keywords, candidate.description) * 2 +
178
+ countKeywordMatches(keywords, candidate.content));
179
+ };
180
+ export const getRelatedDocumentsMap = (documents) => {
181
+ return Object.fromEntries(documents.map((document) => [
182
+ document.id,
183
+ documents
184
+ .filter((candidate) => candidate.id !== document.id)
185
+ .map((candidate) => ({
186
+ description: candidate.description,
187
+ score: getRelatedScore(document, candidate),
188
+ section: candidate.section,
189
+ slug: candidate.slug,
190
+ tags: candidate.tags,
191
+ title: candidate.title,
192
+ url: candidate.url,
193
+ }))
194
+ .filter((candidate) => candidate.score >= minimumRelatedScore)
195
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
196
+ .slice(0, relatedDocumentCount),
197
+ ]));
198
+ };
199
+ export const getHomePageFeaturedContent = (sections) => {
200
+ return sections
201
+ .flatMap((section) => section.entries
202
+ .filter((entry) => entry.homePage)
203
+ .map((entry) => ({
204
+ entry,
205
+ section: section.section,
206
+ })))
207
+ .sort((left, right) => (left.entry.homePageOrder ?? Number.MAX_SAFE_INTEGER) -
208
+ (right.entry.homePageOrder ?? Number.MAX_SAFE_INTEGER) ||
209
+ right.entry.date.localeCompare(left.entry.date) ||
210
+ left.entry.title.localeCompare(right.entry.title))
211
+ .map(({ entry, section }) => ({
212
+ date: getOptionalDate(entry.date),
213
+ description: entry.description,
214
+ homePageOrder: entry.homePageOrder,
215
+ section,
216
+ slug: entry.slug,
217
+ tags: entry.tags,
218
+ title: entry.title,
219
+ url: entry.href,
220
+ }))
221
+ .slice(0, homePageFeaturedContentCount);
222
+ };
223
+ const writeJson = async (path, value) => {
224
+ await mkdir(dirname(path), {
225
+ recursive: true,
226
+ });
227
+ await writeFile(path, `${JSON.stringify(value, undefined, 2)}\n`);
228
+ };
229
+ export const writeContentData = async (sections, outputDirectory) => {
230
+ const documents = getSiteSearchDocuments(sections);
231
+ await Promise.all([
232
+ writeJson(join(outputDirectory, 'search/documents.json'), documents),
233
+ writeJson(join(outputDirectory, 'search/minisearch-index.json'), getMiniSearchIndexJson(documents)),
234
+ writeJson(join(outputDirectory, 'search/related.json'), getRelatedDocumentsMap(documents)),
235
+ writeJson(join(outputDirectory, 'home-featured.json'), getHomePageFeaturedContent(sections)),
236
+ ]);
237
+ };
package/html.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { ContentEntry, HomePageFeaturedContentItem, PageData, RelatedDocument } from './types.js';
2
+ type RenderDocumentOptions = {
3
+ readonly canonicalPath?: string;
4
+ readonly footerText: string;
5
+ readonly googleAnalyticsMeasurementId?: string;
6
+ readonly robotsDirective?: string;
7
+ readonly siteName: string;
8
+ readonly siteUrl?: string;
9
+ readonly stylesheetContent?: string;
10
+ readonly stylesheetHref?: string;
11
+ };
12
+ type RenderPageOptions = RenderDocumentOptions & {
13
+ readonly featured?: readonly HomePageFeaturedContentItem[];
14
+ readonly related?: readonly RelatedDocument[];
15
+ readonly showTags?: boolean;
16
+ };
17
+ type RenderContentIndexOptions = RenderDocumentOptions & {
18
+ readonly page?: number;
19
+ readonly pages?: number;
20
+ readonly sectionPath: string;
21
+ readonly sectionTitle: string;
22
+ };
23
+ export declare const defaultStylesheet: string;
24
+ export declare const renderPage: (page: PageData, options: RenderPageOptions) => string;
25
+ export declare const renderContentIndex: (entries: readonly ContentEntry[], { page, pages, sectionPath, sectionTitle, ...documentOptions }: RenderContentIndexOptions) => string;
26
+ export declare const renderNotFoundPage: (options: RenderDocumentOptions) => string;
27
+ export declare const searchScript: string;
28
+ export declare const renderSearchPage: (options: RenderDocumentOptions) => string;
29
+ export {};