@vyriy/ssg 0.9.1 → 0.9.3
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 +4 -0
- package/README.md +43 -93
- package/build-static-site.js +191 -0
- package/components.d.ts +40 -0
- package/components.js +343 -0
- package/content-data.d.ts +69 -0
- package/{content.js → content-data.js} +17 -100
- package/content-section.d.ts +11 -0
- package/content-section.js +104 -0
- package/index.d.ts +12 -8
- package/index.js +8 -7
- package/json-ld.d.ts +11 -0
- package/json-ld.js +36 -0
- package/llm.d.ts +12 -0
- package/llm.js +59 -0
- package/markdown-page.d.ts +5 -0
- package/markdown-page.js +55 -0
- package/minify-html.d.ts +1 -0
- package/minify-html.js +51 -0
- package/package.json +109 -62
- package/parse-page.d.ts +1 -1
- package/parse-page.js +9 -9
- package/paths.d.ts +11 -0
- package/paths.js +16 -0
- package/render-page.d.ts +40 -0
- package/render-page.js +193 -0
- package/robots.js +1 -0
- package/sitemap.js +31 -11
- package/types.d.ts +2 -54
- package/bin/index.js +0 -3
- package/cli.d.ts +0 -7
- package/cli.js +0 -84
- package/content.d.ts +0 -43
- package/html.d.ts +0 -29
- package/html.js +0 -228
- package/markdown.d.ts +0 -3
- package/markdown.js +0 -31
- package/ssg.js +0 -199
- /package/{ssg.d.ts → build-static-site.d.ts} +0 -0
- /package/{plain.d.ts → markdown-plain-text.d.ts} +0 -0
- /package/{plain.js → markdown-plain-text.js} +0 -0
package/AGENTS.md
CHANGED
|
@@ -85,6 +85,8 @@ Documentation must move with code. Do not finish a package change while docs des
|
|
|
85
85
|
- Keep `doc.mdx` aligned with the README and package title when packages are added, renamed, or reorganized.
|
|
86
86
|
- Add or update JSDoc for public exports when behavior, parameters, return values, errors, side effects, or usage expectations need explanation.
|
|
87
87
|
- For component packages, include visual documentation, stories, or examples for supported states, variants, and interaction states.
|
|
88
|
+
- Keep reusable components as dumb as practical: push data shaping, stateful orchestration, and demo-specific logic into the entry point or owning composition layer.
|
|
89
|
+
- Store demo data in stories instead of embedding it in reusable component source.
|
|
88
90
|
- Avoid broad architecture essays in package READMEs; keep architectural direction in root-level docs unless it is necessary to use the package.
|
|
89
91
|
|
|
90
92
|
## Tests Are Required
|
|
@@ -133,6 +135,8 @@ yarn prettier --check <changed files or package>
|
|
|
133
135
|
yarn jest <changed tests or package> --runInBand
|
|
134
136
|
```
|
|
135
137
|
|
|
138
|
+
Run ESLint after YAML/YML changes too, for example `yarn eslint <changed.yml>`, because the repo ESLint setup validates YAML files as well.
|
|
139
|
+
|
|
136
140
|
Also run `yarn build:dist` for package export, manifest, build-shape, or generated-dist behavior changes. After larger package or library changes, run the full Jest suite with coverage and prefer building the affected library/package as an additional confidence check.
|
|
137
141
|
|
|
138
142
|
If a required validation command cannot be run, state why and report the remaining risk.
|
package/README.md
CHANGED
|
@@ -1,105 +1,55 @@
|
|
|
1
1
|
# @vyriy/ssg
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Static generation helpers for `vyriy.dev`.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
5
|
+
## Usage
|
|
72
6
|
|
|
73
7
|
```ts
|
|
74
8
|
import { buildStaticSite } from '@vyriy/ssg';
|
|
75
9
|
|
|
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
|
-
});
|
|
10
|
+
await buildStaticSite();
|
|
94
11
|
```
|
|
95
12
|
|
|
96
|
-
|
|
13
|
+
By default, `buildStaticSite` renders:
|
|
14
|
+
|
|
15
|
+
- `site/home/README.md` to `dist/index.html`
|
|
16
|
+
- `site/docs/README.md` to `dist/docs/index.html`
|
|
17
|
+
- `site/docs/**/README.md` to `dist/docs/**/index.html`
|
|
18
|
+
- `site/blog/**/README.md` to `dist/blog/**/index.html`
|
|
19
|
+
- `site/examples/**/README.md` to `dist/examples/**/index.html`
|
|
20
|
+
- generated docs, blog, and example entries to sibling `.md` files such as
|
|
21
|
+
`dist/docs/ssg.md`, `dist/blog/post.md`, and `dist/examples/demo.md`
|
|
22
|
+
- `site/consulting/README.md` to `dist/consulting/index.html`
|
|
23
|
+
- `dist/blog/index.html` and `dist/examples/index.html` catalog pages, plus
|
|
24
|
+
numbered catalog pages when pagination is needed
|
|
25
|
+
- `dist/404.html`
|
|
26
|
+
- `dist/sitemap.xml`, `dist/robots.txt`, and `dist/llms.txt`
|
|
27
|
+
|
|
28
|
+
When the script is executed from `dist`, it reads content from the parent project
|
|
29
|
+
directory and writes into the current `dist` directory. Blog, documentation, and
|
|
30
|
+
example entries with `published: false` are skipped.
|
|
31
|
+
|
|
32
|
+
Vyriy SSG can emit `.md` versions of generated content pages next to HTML
|
|
33
|
+
pages. This gives agents, LLM tools, and crawlers a clean text representation of
|
|
34
|
+
docs, blog posts, and examples while preserving the normal human-facing HTML
|
|
35
|
+
site. Generated content HTML pages also include a
|
|
36
|
+
`<link rel="alternate" type="text/markdown">` tag that points to the Markdown
|
|
37
|
+
artifact. The generated home page includes lightweight discovery `<link>` tags
|
|
38
|
+
for `/llms.txt`, `/docs/`, and `/sitemap.xml`.
|
|
97
39
|
|
|
98
40
|
## Exports
|
|
99
41
|
|
|
100
|
-
- `buildStaticSite
|
|
101
|
-
|
|
102
|
-
- `
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
- `
|
|
42
|
+
- `buildStaticSite` builds the static home page, documentation page, consulting
|
|
43
|
+
page, 404 page, blog pages, example pages, and section index pages.
|
|
44
|
+
- `renderRobotsTxt` and `writeRobotsTxt` create a crawler policy that allows all
|
|
45
|
+
indexing, points to the sitemap, and emits permissive content signals for
|
|
46
|
+
search and AI usage.
|
|
47
|
+
- `renderLlmTxt` and `writeLlmTxt` create a readable LLM index for main pages
|
|
48
|
+
and published content sections.
|
|
49
|
+
- `getWebPageJsonLd` and `renderJsonLdScript` create safe WebPage JSON-LD for
|
|
50
|
+
rendered HTML documents.
|
|
51
|
+
- `renderMarkdownPage`, `writeMarkdownPage`, and `getMarkdownOutputPath` create
|
|
52
|
+
agent-readable Markdown artifacts for generated content pages.
|
|
53
|
+
- `BuildStaticSiteOptions` configures source, output, stylesheet, Google
|
|
54
|
+
Analytics measurement ID, and current working directory paths.
|
|
55
|
+
- `PageData` describes parsed Markdown page content and metadata.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getHomePageFeaturedContent, getRelatedDocumentsMap, getSiteSearchDocuments, writeContentData, } from './content-data.js';
|
|
4
|
+
import { buildContentEntries, buildContentSection, writeContentEntryDocuments } from './content-section.js';
|
|
5
|
+
import { writeLlmTxt } from './llm.js';
|
|
6
|
+
import { parsePage } from './parse-page.js';
|
|
7
|
+
import { getStaticSitePaths } from './paths.js';
|
|
8
|
+
import { renderNotFoundPage, renderPage, renderSearchPage } from './render-page.js';
|
|
9
|
+
import { writeRobotsTxt } from './robots.js';
|
|
10
|
+
import { writeSitemap } from './sitemap.js';
|
|
11
|
+
const homeSocialMetadata = {
|
|
12
|
+
description: 'Calm architecture for cloud-ready applications. Small explicit pieces, clear boundaries, predictable behavior.',
|
|
13
|
+
imageAlt: 'Vyriy calm architecture diagram with modular cloud-ready software blocks.',
|
|
14
|
+
imagePath: '/assets/vyriy-calm-architecture.png',
|
|
15
|
+
siteName: 'Vyriy',
|
|
16
|
+
title: 'Vyriy - Calm Architecture',
|
|
17
|
+
};
|
|
18
|
+
export const buildStaticSite = async (options = {}) => {
|
|
19
|
+
const paths = getStaticSitePaths(options.cwd ?? process.cwd());
|
|
20
|
+
const sourcePath = options.sourcePath ?? paths.sourcePath;
|
|
21
|
+
const outputPath = options.outputPath ?? paths.outputPath;
|
|
22
|
+
const page = parsePage(await readFile(sourcePath, 'utf8'));
|
|
23
|
+
const consultingPage = parsePage(await readFile(paths.consultingSourcePath, 'utf8'));
|
|
24
|
+
const docPage = parsePage(await readFile(paths.docSourcePath, 'utf8'));
|
|
25
|
+
const consultingDocument = renderPage(consultingPage, {
|
|
26
|
+
canonicalPath: '/consulting/',
|
|
27
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
28
|
+
siteUrl: options.siteUrl,
|
|
29
|
+
stylesheetContent: options.stylesheetContent,
|
|
30
|
+
stylesheetHref: options.stylesheetHref,
|
|
31
|
+
});
|
|
32
|
+
const docDocument = renderPage(docPage, {
|
|
33
|
+
canonicalPath: '/docs/',
|
|
34
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
35
|
+
siteUrl: options.siteUrl,
|
|
36
|
+
stylesheetContent: options.stylesheetContent,
|
|
37
|
+
stylesheetHref: options.stylesheetHref,
|
|
38
|
+
});
|
|
39
|
+
const notFoundDocument = renderNotFoundPage({
|
|
40
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
41
|
+
siteUrl: options.siteUrl,
|
|
42
|
+
stylesheetContent: options.stylesheetContent,
|
|
43
|
+
stylesheetHref: options.stylesheetHref,
|
|
44
|
+
});
|
|
45
|
+
const searchDocument = renderSearchPage({
|
|
46
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
47
|
+
siteUrl: options.siteUrl,
|
|
48
|
+
stylesheetContent: options.stylesheetContent,
|
|
49
|
+
stylesheetHref: options.stylesheetHref,
|
|
50
|
+
});
|
|
51
|
+
const searchOutputPath = join(paths.outputDirectory, 'search/index.html');
|
|
52
|
+
await mkdir(dirname(outputPath), {
|
|
53
|
+
recursive: true,
|
|
54
|
+
});
|
|
55
|
+
await mkdir(dirname(paths.consultingOutputPath), {
|
|
56
|
+
recursive: true,
|
|
57
|
+
});
|
|
58
|
+
await mkdir(dirname(paths.docOutputPath), {
|
|
59
|
+
recursive: true,
|
|
60
|
+
});
|
|
61
|
+
await mkdir(paths.outputDirectory, {
|
|
62
|
+
recursive: true,
|
|
63
|
+
});
|
|
64
|
+
await mkdir(dirname(searchOutputPath), {
|
|
65
|
+
recursive: true,
|
|
66
|
+
});
|
|
67
|
+
await writeFile(paths.consultingOutputPath, consultingDocument);
|
|
68
|
+
await writeFile(paths.docOutputPath, docDocument);
|
|
69
|
+
await writeFile(join(paths.outputDirectory, '404.html'), notFoundDocument);
|
|
70
|
+
await writeFile(searchOutputPath, searchDocument);
|
|
71
|
+
const blogSection = await buildContentSection('blog', paths.projectRoot, paths.outputDirectory, options.stylesheetHref, options.siteUrl, options.stylesheetContent, options.googleAnalyticsMeasurementId);
|
|
72
|
+
const examplesSection = await buildContentSection('examples', paths.projectRoot, paths.outputDirectory, options.stylesheetHref, options.siteUrl, options.stylesheetContent, options.googleAnalyticsMeasurementId);
|
|
73
|
+
const docsEntries = await buildContentEntries('docs', paths.projectRoot);
|
|
74
|
+
const docsRootEntry = {
|
|
75
|
+
...docPage,
|
|
76
|
+
href: '/docs/',
|
|
77
|
+
slug: '',
|
|
78
|
+
};
|
|
79
|
+
const contentSections = [
|
|
80
|
+
{
|
|
81
|
+
entries: blogSection.entries,
|
|
82
|
+
section: 'blog',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
entries: [
|
|
86
|
+
docsRootEntry,
|
|
87
|
+
...docsEntries,
|
|
88
|
+
],
|
|
89
|
+
section: 'docs',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
entries: examplesSection.entries,
|
|
93
|
+
section: 'examples',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
const relatedDocuments = getRelatedDocumentsMap(getSiteSearchDocuments(contentSections));
|
|
97
|
+
const featured = getHomePageFeaturedContent(contentSections);
|
|
98
|
+
const document = renderPage(page, {
|
|
99
|
+
canonicalPath: '/',
|
|
100
|
+
discoveryLinks: [
|
|
101
|
+
{
|
|
102
|
+
href: '/llms.txt',
|
|
103
|
+
rel: 'describedby',
|
|
104
|
+
type: 'text/markdown',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
href: '/docs/',
|
|
108
|
+
rel: 'service-doc',
|
|
109
|
+
type: 'text/html',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
href: '/sitemap.xml',
|
|
113
|
+
rel: 'sitemap',
|
|
114
|
+
type: 'application/xml',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
featured,
|
|
118
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
119
|
+
siteUrl: options.siteUrl,
|
|
120
|
+
socialMetadata: homeSocialMetadata,
|
|
121
|
+
stylesheetContent: options.stylesheetContent,
|
|
122
|
+
stylesheetHref: options.stylesheetHref,
|
|
123
|
+
});
|
|
124
|
+
await writeFile(outputPath, document);
|
|
125
|
+
await Promise.all([
|
|
126
|
+
writeContentEntryDocuments('blog', blogSection.entries, paths.outputDirectory, {
|
|
127
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
128
|
+
relatedDocuments,
|
|
129
|
+
siteUrl: options.siteUrl,
|
|
130
|
+
stylesheetContent: options.stylesheetContent,
|
|
131
|
+
stylesheetHref: options.stylesheetHref,
|
|
132
|
+
}),
|
|
133
|
+
writeContentEntryDocuments('examples', examplesSection.entries, paths.outputDirectory, {
|
|
134
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
135
|
+
relatedDocuments,
|
|
136
|
+
siteUrl: options.siteUrl,
|
|
137
|
+
stylesheetContent: options.stylesheetContent,
|
|
138
|
+
stylesheetHref: options.stylesheetHref,
|
|
139
|
+
}),
|
|
140
|
+
writeContentEntryDocuments('docs', docsEntries, paths.outputDirectory, {
|
|
141
|
+
googleAnalyticsMeasurementId: options.googleAnalyticsMeasurementId,
|
|
142
|
+
relatedDocuments,
|
|
143
|
+
siteUrl: options.siteUrl,
|
|
144
|
+
stylesheetContent: options.stylesheetContent,
|
|
145
|
+
stylesheetHref: options.stylesheetHref,
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
await writeContentData(contentSections, paths.outputDirectory);
|
|
149
|
+
await writeSitemap(paths.outputDirectory, [
|
|
150
|
+
{
|
|
151
|
+
path: '/',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
path: '/consulting/',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
path: '/docs/',
|
|
158
|
+
},
|
|
159
|
+
...docsEntries.map((entry) => ({
|
|
160
|
+
path: entry.href,
|
|
161
|
+
})),
|
|
162
|
+
...blogSection.indexPaths.map((path) => ({
|
|
163
|
+
path,
|
|
164
|
+
})),
|
|
165
|
+
...blogSection.entries.map((entry) => ({
|
|
166
|
+
path: entry.href,
|
|
167
|
+
})),
|
|
168
|
+
...examplesSection.indexPaths.map((path) => ({
|
|
169
|
+
path,
|
|
170
|
+
})),
|
|
171
|
+
...examplesSection.entries.map((entry) => ({
|
|
172
|
+
path: entry.href,
|
|
173
|
+
})),
|
|
174
|
+
], options.siteUrl);
|
|
175
|
+
await writeRobotsTxt(paths.outputDirectory, options.siteUrl);
|
|
176
|
+
await writeLlmTxt(paths.outputDirectory, {
|
|
177
|
+
pages: [
|
|
178
|
+
{
|
|
179
|
+
description: page.description,
|
|
180
|
+
path: '/',
|
|
181
|
+
title: page.title,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
description: consultingPage.description,
|
|
185
|
+
path: '/consulting/',
|
|
186
|
+
title: consultingPage.title,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
sections: contentSections,
|
|
190
|
+
}, options.siteUrl);
|
|
191
|
+
};
|
package/components.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentProps, FC, ReactNode } from 'react';
|
|
2
|
+
type CardProps = ComponentProps<'article'> & {
|
|
3
|
+
readonly date?: string;
|
|
4
|
+
readonly description: string;
|
|
5
|
+
readonly href: string;
|
|
6
|
+
readonly tags?: readonly string[];
|
|
7
|
+
readonly title: string;
|
|
8
|
+
};
|
|
9
|
+
type CatalogProps = {
|
|
10
|
+
readonly content: ReactNode;
|
|
11
|
+
readonly paginate: {
|
|
12
|
+
readonly getHref?: (page: number) => string;
|
|
13
|
+
readonly page: number;
|
|
14
|
+
readonly pages: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
type PageRelatedItem = {
|
|
18
|
+
readonly description: string;
|
|
19
|
+
readonly href: string;
|
|
20
|
+
readonly title: string;
|
|
21
|
+
};
|
|
22
|
+
type PageProps = {
|
|
23
|
+
readonly content: ReactNode;
|
|
24
|
+
readonly featured?: readonly PageRelatedItem[];
|
|
25
|
+
readonly related?: readonly PageRelatedItem[];
|
|
26
|
+
readonly tags?: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
type SearchPageProps = {
|
|
29
|
+
readonly documentsUrl?: string;
|
|
30
|
+
readonly indexUrl?: string;
|
|
31
|
+
readonly miniSearchScriptUrl?: string;
|
|
32
|
+
};
|
|
33
|
+
export declare const Card: FC<CardProps>;
|
|
34
|
+
export declare const Page: FC<PageProps>;
|
|
35
|
+
export declare const Catalog: FC<CatalogProps>;
|
|
36
|
+
export declare const NotFoundPage: FC<{
|
|
37
|
+
readonly homeHref?: string;
|
|
38
|
+
}>;
|
|
39
|
+
export declare const SearchPage: FC<SearchPageProps>;
|
|
40
|
+
export {};
|