@kamansoft/vite-plugin-flatwave-react 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Flatwave 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,99 @@
1
+ # vite-plugin-flatwave-react
2
+
3
+ Vite content plugin for Markdown-driven, i18n-aware static React sites.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install vite-plugin-flatwave-react
9
+ ```
10
+
11
+ ## Configure Vite
12
+
13
+ ```ts
14
+ // vite.config.ts
15
+ import { defineConfig } from 'vite';
16
+ import react from '@vitejs/plugin-react';
17
+ import path from 'node:path';
18
+ import { flatwaveContent } from 'vite-plugin-flatwave-react';
19
+
20
+ export default defineConfig({
21
+ plugins: [
22
+ react(),
23
+ flatwaveContent({
24
+ contentDir: path.resolve(__dirname, 'src/content'),
25
+ locales: ['es', 'pt'],
26
+ defaultLocale: 'es',
27
+ strictMissingLocales: false,
28
+ componentsDir: path.resolve(__dirname, 'src/components'),
29
+ sitemap: { hostname: 'https://example.com' },
30
+ }),
31
+ ],
32
+ });
33
+ ```
34
+
35
+ ## Content layout
36
+
37
+ ```text
38
+ src/
39
+ content/
40
+ es/
41
+ index.md
42
+ about.md
43
+ pt/
44
+ index.md
45
+ about.md
46
+ components/
47
+ SimplePage.tsx
48
+ ```
49
+
50
+ Each Markdown file needs baseline frontmatter:
51
+
52
+ ```yaml
53
+ ---
54
+ title: 'Page title'
55
+ slug: 'page-slug'
56
+ id: 'unique-id'
57
+ component: 'SimplePage'
58
+ public: true
59
+ description: 'Short description'
60
+ canonical: '/es/page-slug'
61
+ robots: 'index, follow'
62
+ keywords: ['tag1', 'tag2']
63
+ ---
64
+ Markdown body.
65
+ ```
66
+
67
+ Additional frontmatter fields are preserved in `attributes` and forwarded to the React component.
68
+
69
+ ## React hooks
70
+
71
+ ```ts
72
+ import {
73
+ useFlatwaveContent,
74
+ useFlatwaveRoutes,
75
+ useFlatwaveAlternatives,
76
+ } from 'vite-plugin-flatwave-react/react';
77
+ ```
78
+
79
+ ## Build outputs
80
+
81
+ During `vite build`, the plugin generates:
82
+
83
+ - locale-prefixed static HTML route files
84
+ - `route-manifest.json`
85
+ - `sitemap.xml`
86
+ - `robots.txt`
87
+ - a virtual module for content lookup
88
+
89
+ ## Validation CLI
90
+
91
+ ```bash
92
+ npx flatwave-validate --content-dir src/content --locales es,pt --default-locale es
93
+ ```
94
+
95
+ Use `--strict-missing` to fail when locale variants are missing.
96
+
97
+ ## License
98
+
99
+ MIT © 2026 Flatwave contributors.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { validateContent } from '../content/validator.js';
4
+ const program = new Command();
5
+ program
6
+ .name('flatwave-validate')
7
+ .description('Validate Flatwave Markdown content using the same rules as the Vite plugin.')
8
+ .requiredOption('--content-dir <dir>', 'Markdown content directory, e.g. src/content')
9
+ .requiredOption('--locales <locales>', 'Comma-separated locales, e.g. es,pt')
10
+ .requiredOption('--default-locale <locale>', 'Default locale, e.g. es')
11
+ .option('--components-dir <dirs>', 'Comma-separated component directories', 'src/components,src/pages')
12
+ .option('--strict-missing', 'Fail when locale variants are missing', false)
13
+ .option('--no-validate-components', 'Disable component existence validation')
14
+ .action(async (options) => {
15
+ const locales = options.locales
16
+ .split(',')
17
+ .map((locale) => locale.trim())
18
+ .filter(Boolean);
19
+ const componentsDir = options.componentsDir
20
+ .split(',')
21
+ .map((dir) => dir.trim())
22
+ .filter(Boolean);
23
+ const result = await validateContent({
24
+ contentDir: options.contentDir,
25
+ locales,
26
+ defaultLocale: options.defaultLocale,
27
+ componentsDir,
28
+ strictMissingLocales: options.strictMissing,
29
+ validateComponents: options.validateComponents,
30
+ });
31
+ for (const warning of result.warnings) {
32
+ console.warn(`[WARN] ${warning}`);
33
+ }
34
+ if (result.errors.length > 0) {
35
+ console.error('[ERROR] Flatwave validation failed:');
36
+ for (const error of result.errors) {
37
+ console.error(` - ${error}`);
38
+ }
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ console.log(`Flatwave validation passed for ${locales.join(', ')} with ${result.warnings.length} warning(s).`);
43
+ });
44
+ program.parseAsync(process.argv).catch((error) => {
45
+ console.error(error);
46
+ process.exitCode = 1;
47
+ });
@@ -0,0 +1,2 @@
1
+ import type { FlatwaveContentIndex, FlatwaveContentOptions } from '../types';
2
+ export declare function buildIndex(options: FlatwaveContentOptions): Promise<FlatwaveContentIndex>;
@@ -0,0 +1,36 @@
1
+ import { buildContentIndex } from './routeBuilder.js';
2
+ import { routeForLocaleSlug, scanMarkdownFiles } from './scanner.js';
3
+ export async function buildIndex(options) {
4
+ const parsed = await scanMarkdownFiles(options.contentDir, options.locales);
5
+ const byLocaleAndId = new Map();
6
+ const entries = [];
7
+ for (const file of parsed) {
8
+ const id = String(file.frontmatter.id || file.slug);
9
+ const key = `${file.locale}:${id}`;
10
+ byLocaleAndId.set(key, {
11
+ id,
12
+ locale: file.locale,
13
+ slug: file.slug,
14
+ path: routeForLocaleSlug(file.locale, file.slug),
15
+ file: file.file,
16
+ component: file.frontmatter.component ? String(file.frontmatter.component) : undefined,
17
+ public: file.frontmatter.public !== false &&
18
+ String(file.frontmatter.public ?? 'true').toLowerCase() !== 'false',
19
+ attributes: { ...file.frontmatter },
20
+ frontmatter: file.frontmatter,
21
+ body: file.body,
22
+ route: routeForLocaleSlug(file.locale, file.slug),
23
+ alternatives: {},
24
+ });
25
+ entries.push(byLocaleAndId.get(key));
26
+ }
27
+ const alternatives = {};
28
+ for (const entry of entries) {
29
+ alternatives[entry.id] ??= {};
30
+ alternatives[entry.id][entry.locale] = entry.route;
31
+ }
32
+ for (const entry of entries) {
33
+ entry.alternatives = alternatives[entry.id] ?? {};
34
+ }
35
+ return buildContentIndex(entries);
36
+ }
@@ -0,0 +1,7 @@
1
+ import type { FlatwaveFrontmatter } from '../types';
2
+ export interface MarkdownParseResult {
3
+ body: string;
4
+ attributes: FlatwaveFrontmatter;
5
+ frontmatter: FlatwaveFrontmatter;
6
+ }
7
+ export declare function parseMarkdown(source: string): MarkdownParseResult;
@@ -0,0 +1,11 @@
1
+ import matter from 'gray-matter';
2
+ export function parseMarkdown(source) {
3
+ const parsed = matter(source);
4
+ const frontmatter = parsed.data;
5
+ const attributes = { ...frontmatter };
6
+ return {
7
+ body: parsed.content.trim(),
8
+ attributes,
9
+ frontmatter,
10
+ };
11
+ }
@@ -0,0 +1,9 @@
1
+ import type { FlatwaveContentEntry, FlatwaveFrontmatter, FlatwaveRoute } from '../types';
2
+ export declare function buildContentIndex(entries: FlatwaveContentEntry[]): {
3
+ entries: FlatwaveContentEntry[];
4
+ byId: Record<string, Record<string, FlatwaveContentEntry>>;
5
+ byLocale: Record<string, Record<string, FlatwaveContentEntry>>;
6
+ routes: FlatwaveRoute[];
7
+ };
8
+ export declare function routeFromLocaleAndSlug(locale: string, slug: string): string;
9
+ export declare function isPublicFrontmatter(frontmatter: FlatwaveFrontmatter): boolean;
@@ -0,0 +1,77 @@
1
+ import { isPublicEntry, normalizeSlug } from './scanner.js';
2
+ export function buildContentIndex(entries) {
3
+ const byId = {};
4
+ const byLocale = {};
5
+ const routes = [];
6
+ for (const entry of entries) {
7
+ if (!entry.public)
8
+ continue;
9
+ byId[entry.id] ??= {};
10
+ byLocale[entry.locale] ??= {};
11
+ byId[entry.id][entry.locale] = entry;
12
+ byLocale[entry.locale][entry.id] = entry;
13
+ }
14
+ for (const id of Object.keys(byId).sort()) {
15
+ const locales = Object.keys(byId[id]).sort();
16
+ const alternatives = buildAlternatives(locales, (locale) => byId[id][locale]?.route ?? '');
17
+ for (const locale of locales) {
18
+ const entry = byId[id][locale];
19
+ if (!entry)
20
+ continue;
21
+ routes.push({
22
+ locale: entry.locale,
23
+ path: entry.route,
24
+ contentId: entry.id,
25
+ component: entry.component,
26
+ metadata: buildSeoMetadata(entry.frontmatter, entry.route, entry.locale),
27
+ frontmatter: entry.frontmatter,
28
+ alternatives,
29
+ });
30
+ }
31
+ }
32
+ routes.sort((a, b) => `${a.locale}${a.path}`.localeCompare(`${b.locale}${b.path}`));
33
+ return {
34
+ entries: entries.filter((entry) => entry.public),
35
+ byId,
36
+ byLocale,
37
+ routes,
38
+ };
39
+ }
40
+ function buildAlternatives(locales, getRoute) {
41
+ const alternatives = {};
42
+ for (const locale of locales) {
43
+ const route = getRoute(locale);
44
+ if (route)
45
+ alternatives[locale] = route;
46
+ }
47
+ return alternatives;
48
+ }
49
+ function buildSeoMetadata(frontmatter, route, _locale) {
50
+ return {
51
+ title: String(frontmatter.title || ''),
52
+ description: frontmatter.description ? String(frontmatter.description) : undefined,
53
+ canonical: frontmatter.canonical ? String(frontmatter.canonical) : route,
54
+ image: frontmatter.image ? String(frontmatter.image) : undefined,
55
+ robots: frontmatter.robots ? String(frontmatter.robots) : 'index, follow',
56
+ keywords: Array.isArray(frontmatter.keywords) ? frontmatter.keywords.map(String) : undefined,
57
+ jsonLd: frontmatter.jsonLd,
58
+ og: recordValue(frontmatter.og),
59
+ twitter: recordValue(frontmatter.twitter),
60
+ };
61
+ }
62
+ function recordValue(value) {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value))
64
+ return undefined;
65
+ const record = {};
66
+ for (const [key, item] of Object.entries(value)) {
67
+ if (item !== undefined && item !== null)
68
+ record[key] = String(item);
69
+ }
70
+ return Object.keys(record).length > 0 ? record : undefined;
71
+ }
72
+ export function routeFromLocaleAndSlug(locale, slug) {
73
+ return `/${locale}${normalizeSlug(slug)}`;
74
+ }
75
+ export function isPublicFrontmatter(frontmatter) {
76
+ return isPublicEntry(frontmatter);
77
+ }
@@ -0,0 +1,13 @@
1
+ import type { FlatwaveContentEntry, FlatwaveFrontmatter } from '../types';
2
+ export interface ParsedMarkdownFile {
3
+ file: string;
4
+ locale: string;
5
+ slug: string;
6
+ body: string;
7
+ frontmatter: FlatwaveFrontmatter;
8
+ }
9
+ export declare function scanMarkdownFiles(contentDir: string, locales: string[]): Promise<ParsedMarkdownFile[]>;
10
+ export declare function normalizeSlug(slug: string): string;
11
+ export declare function routeForLocaleSlug(locale: string, slug: string): string;
12
+ export declare function isPublicEntry(frontmatter: FlatwaveFrontmatter): boolean;
13
+ export declare function buildContentEntry(parsed: ParsedMarkdownFile, alternatives: Record<string, string>): FlatwaveContentEntry;
@@ -0,0 +1,64 @@
1
+ import fg from 'fast-glob';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ export async function scanMarkdownFiles(contentDir, locales) {
5
+ const files = [];
6
+ for (const locale of locales) {
7
+ const localeDir = path.resolve(contentDir, locale);
8
+ const matches = await fg('**/*.md', {
9
+ cwd: localeDir,
10
+ onlyFiles: true,
11
+ absolute: true,
12
+ ignore: ['**/node_modules/**'],
13
+ });
14
+ for (const file of matches.sort()) {
15
+ const source = await readFile(file);
16
+ const parsed = matter(source);
17
+ const frontmatter = parsed.data;
18
+ const slug = normalizeSlug(String(frontmatter.slug || path.basename(file, '.md')));
19
+ files.push({
20
+ file,
21
+ locale,
22
+ slug,
23
+ body: parsed.content.trim(),
24
+ frontmatter,
25
+ });
26
+ }
27
+ }
28
+ return files;
29
+ }
30
+ async function readFile(file) {
31
+ const fs = await import('node:fs/promises');
32
+ return fs.readFile(file, 'utf-8');
33
+ }
34
+ export function normalizeSlug(slug) {
35
+ return `/${slug.replace(/^\/+|\/+$/g, '')}`;
36
+ }
37
+ export function routeForLocaleSlug(locale, slug) {
38
+ const normalized = normalizeSlug(slug);
39
+ const isHome = normalized === '/' || normalized === '/index';
40
+ return isHome ? `/${locale}/` : `/${locale}${normalized}`;
41
+ }
42
+ export function isPublicEntry(frontmatter) {
43
+ if (typeof frontmatter.public === 'boolean')
44
+ return frontmatter.public;
45
+ return String(frontmatter.public ?? 'true').toLowerCase() !== 'false';
46
+ }
47
+ export function buildContentEntry(parsed, alternatives) {
48
+ const route = routeForLocaleSlug(parsed.locale, parsed.slug);
49
+ const attributes = { ...parsed.frontmatter };
50
+ return {
51
+ id: String(parsed.frontmatter.id || parsed.slug),
52
+ locale: parsed.locale,
53
+ slug: parsed.slug,
54
+ path: route,
55
+ file: parsed.file,
56
+ component: parsed.frontmatter.component ? String(parsed.frontmatter.component) : undefined,
57
+ public: isPublicEntry(parsed.frontmatter),
58
+ attributes,
59
+ frontmatter: parsed.frontmatter,
60
+ body: parsed.body,
61
+ route,
62
+ alternatives,
63
+ };
64
+ }
@@ -0,0 +1,2 @@
1
+ import type { FlatwaveContentOptions, ValidationResult } from '../types';
2
+ export declare function validateContent(options: FlatwaveContentOptions): Promise<ValidationResult>;
@@ -0,0 +1,168 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { routeForLocaleSlug, scanMarkdownFiles } from './scanner.js';
4
+ import { buildContentIndex } from './routeBuilder.js';
5
+ export async function validateContent(options) {
6
+ const errors = [];
7
+ const warnings = [];
8
+ const requiredFields = options.requiredFields ?? ['title', 'slug', 'id', 'component', 'public'];
9
+ const files = await scanMarkdownFiles(options.contentDir, options.locales);
10
+ await validateRequiredFields(files, requiredFields, errors);
11
+ await validateDuplicateIds(files, errors);
12
+ await validateDuplicateSlugs(files, errors);
13
+ await validateMenuPositions(files, errors);
14
+ await validateComponents(files, options, errors);
15
+ validateMissingLocales(files, options, warnings);
16
+ const entries = files.map((file) => {
17
+ const id = String(file.frontmatter.id || file.slug);
18
+ const route = routeForLocaleSlug(file.locale, file.slug);
19
+ return {
20
+ id,
21
+ locale: file.locale,
22
+ slug: file.slug,
23
+ path: route,
24
+ file: file.file,
25
+ component: file.frontmatter.component ? String(file.frontmatter.component) : undefined,
26
+ public: file.frontmatter.public !== false &&
27
+ String(file.frontmatter.public ?? 'true').toLowerCase() !== 'false',
28
+ attributes: file.frontmatter,
29
+ frontmatter: file.frontmatter,
30
+ body: file.body,
31
+ route,
32
+ alternatives: {},
33
+ };
34
+ });
35
+ const index = buildContentIndex(entries);
36
+ if (index.routes.length === 0) {
37
+ warnings.push(`No public routes were generated from ${options.contentDir}.`);
38
+ }
39
+ if (options.strictMissingLocales &&
40
+ warnings.some((warning) => warning.startsWith('[missing-locale]'))) {
41
+ errors.push(...warnings.filter((warning) => warning.startsWith('[missing-locale]')));
42
+ }
43
+ return { errors, warnings };
44
+ }
45
+ async function validateRequiredFields(files, requiredFields, errors) {
46
+ for (const file of files) {
47
+ for (const field of requiredFields) {
48
+ const value = file.frontmatter[field];
49
+ if (value === undefined || value === null || value === '') {
50
+ errors.push(`[${file.locale}] ${file.file}: Missing required frontmatter field: ${field}`);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ async function validateDuplicateIds(files, errors) {
56
+ const seen = new Map();
57
+ for (const file of files) {
58
+ const id = String(file.frontmatter.id || file.slug);
59
+ const key = `${file.locale}:${id}`;
60
+ const previous = seen.get(key);
61
+ if (previous) {
62
+ errors.push(`[${file.locale}] ${file.file}: Duplicate content id '${id}'. First occurrence: ${previous}`);
63
+ }
64
+ else {
65
+ seen.set(key, file.file);
66
+ }
67
+ }
68
+ }
69
+ async function validateDuplicateSlugs(files, errors) {
70
+ const seen = new Map();
71
+ for (const file of files) {
72
+ const key = `${file.locale}:${file.slug}`;
73
+ const previous = seen.get(key);
74
+ if (previous) {
75
+ errors.push(`[${file.locale}] ${file.file}: Duplicate slug '${file.slug}'. First occurrence: ${previous}`);
76
+ }
77
+ else {
78
+ seen.set(key, file.file);
79
+ }
80
+ }
81
+ }
82
+ async function validateMenuPositions(files, errors) {
83
+ const seen = new Map();
84
+ for (const file of files) {
85
+ const menu = file.frontmatter.menu;
86
+ const position = file.frontmatter.menu_position;
87
+ if (menu === undefined || menu === '' || position === undefined || position === '')
88
+ continue;
89
+ const numeric = Number(position);
90
+ if (Number.isNaN(numeric)) {
91
+ errors.push(`[${file.locale}] ${file.file}: menu_position must be a number when menu is set.`);
92
+ continue;
93
+ }
94
+ const key = `${file.locale}:${menu}:${numeric}`;
95
+ const previous = seen.get(key);
96
+ if (previous) {
97
+ errors.push(`[${file.locale}] ${file.file}: Duplicate menu/menu_position '${menu}/${numeric}'. First occurrence: ${previous}`);
98
+ }
99
+ else {
100
+ seen.set(key, file.file);
101
+ }
102
+ }
103
+ }
104
+ async function validateComponents(files, options, errors) {
105
+ if (options.validateComponents === false)
106
+ return;
107
+ const available = await discoverComponents(options.componentsDir);
108
+ for (const file of files) {
109
+ const component = file.frontmatter.component ? String(file.frontmatter.component) : undefined;
110
+ if (!component)
111
+ continue;
112
+ if (!available.has(component)) {
113
+ errors.push(`[${file.locale}] ${file.file}: Component '${component}' does not exist in ${formatComponentsDir(options.componentsDir)}.`);
114
+ }
115
+ }
116
+ }
117
+ async function discoverComponents(componentsDir) {
118
+ const dirs = Array.isArray(componentsDir)
119
+ ? componentsDir
120
+ : componentsDir
121
+ ? [componentsDir]
122
+ : ['src/components', 'src/pages'];
123
+ const components = new Set();
124
+ for (const dir of dirs) {
125
+ const absolute = path.resolve(dir);
126
+ let files;
127
+ try {
128
+ files = (await readdir(absolute, { withFileTypes: true }));
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ for (const file of files) {
134
+ if (!file.isFile())
135
+ continue;
136
+ if (!/\.(tsx?|jsx?)$/.test(file.name))
137
+ continue;
138
+ components.add(file.name.replace(/\.[^.]+$/, ''));
139
+ }
140
+ }
141
+ return components;
142
+ }
143
+ function validateMissingLocales(files, options, warnings) {
144
+ const idsByLocale = new Map();
145
+ for (const file of files) {
146
+ const id = String(file.frontmatter.id || file.slug);
147
+ if (!idsByLocale.has(file.locale))
148
+ idsByLocale.set(file.locale, new Set());
149
+ idsByLocale.get(file.locale)?.add(id);
150
+ }
151
+ const allIds = new Set();
152
+ for (const ids of idsByLocale.values()) {
153
+ for (const id of ids)
154
+ allIds.add(id);
155
+ }
156
+ for (const id of allIds) {
157
+ for (const locale of options.locales) {
158
+ if (!idsByLocale.get(locale)?.has(id)) {
159
+ warnings.push(`[missing-locale] Content id '${id}' is missing locale '${locale}'.`);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ function formatComponentsDir(componentsDir) {
165
+ if (!componentsDir)
166
+ return 'src/components or src/pages';
167
+ return Array.isArray(componentsDir) ? componentsDir.join(', ') : componentsDir;
168
+ }
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from 'vite';
2
+ import type { FlatwaveContentOptions } from './types';
3
+ export declare function flatwaveContent(options: FlatwaveContentOptions): Plugin[];
4
+ export default flatwaveContent;
package/dist/index.js ADDED
@@ -0,0 +1,246 @@
1
+ import path from 'node:path';
2
+ import { buildIndex } from './content/indexer.js';
3
+ import { validateContent } from './content/validator.js';
4
+ import { parseMarkdown } from './content/parser.js';
5
+ import { routeForLocaleSlug } from './content/scanner.js';
6
+ import { escapeHtml, escapeXml, renderHtmlHead } from './seo/metadata.js';
7
+ const VIRTUAL_ID = '\0virtual:flatwave/content';
8
+ const PUBLIC_VIRTUAL_ID = 'virtual:flatwave/content';
9
+ // Test comment for lint-staged
10
+ export function flatwaveContent(options) {
11
+ const normalizedOptions = normalizeOptions(options);
12
+ let index = { entries: [], byId: {}, byLocale: {}, routes: [] };
13
+ return [
14
+ {
15
+ name: 'flatwave-react:content',
16
+ enforce: 'pre',
17
+ async buildStart() {
18
+ index = await buildIndex(normalizedOptions);
19
+ const validation = await validateContent(normalizedOptions);
20
+ for (const warning of validation.warnings)
21
+ this.warn(warning);
22
+ for (const error of validation.errors)
23
+ this.error(error);
24
+ },
25
+ resolveId(id) {
26
+ if (id === PUBLIC_VIRTUAL_ID)
27
+ return VIRTUAL_ID;
28
+ return null;
29
+ },
30
+ load(id) {
31
+ if (id !== VIRTUAL_ID)
32
+ return null;
33
+ return createVirtualModule(index, normalizedOptions.defaultLocale);
34
+ },
35
+ async handleHotUpdate(ctx) {
36
+ if (!ctx.file.endsWith('.md'))
37
+ return;
38
+ index = await buildIndex(normalizedOptions);
39
+ },
40
+ },
41
+ {
42
+ name: 'flatwave-react:markdown',
43
+ enforce: 'pre',
44
+ resolveId(id) {
45
+ if (id.endsWith('.md')) {
46
+ const resolved = path.resolve(process.cwd(), id);
47
+ return resolved;
48
+ }
49
+ return null;
50
+ },
51
+ async load(id) {
52
+ if (!id.endsWith('.md'))
53
+ return null;
54
+ const source = await readFile(id);
55
+ const parsed = parseMarkdown(source);
56
+ const locale = inferLocale(id, normalizedOptions.contentDir, normalizedOptions.locales) ??
57
+ normalizedOptions.defaultLocale;
58
+ const slug = path.basename(id, '.md');
59
+ const route = routeForLocaleSlug(locale, slug);
60
+ const idValue = slug;
61
+ return `export default ${JSON.stringify({
62
+ body: parsed.body,
63
+ attributes: parsed.attributes,
64
+ frontmatter: parsed.frontmatter,
65
+ locale,
66
+ slug,
67
+ id: idValue,
68
+ route,
69
+ file: id,
70
+ }, null, 2)};`;
71
+ },
72
+ },
73
+ {
74
+ name: 'flatwave-react:ssg',
75
+ async generateBundle(_, bundle) {
76
+ const routes = index.routes;
77
+ const html = findIndexHtml(bundle);
78
+ const assets = extractAssets(html);
79
+ if (normalizedOptions.emitRouteManifest !== false) {
80
+ this.emitFile({
81
+ type: 'asset',
82
+ fileName: 'route-manifest.json',
83
+ source: JSON.stringify(routes, null, 2),
84
+ });
85
+ }
86
+ if (normalizedOptions.emitSitemap !== false) {
87
+ this.emitFile({
88
+ type: 'asset',
89
+ fileName: 'sitemap.xml',
90
+ source: renderSitemap(routes, normalizedOptions.sitemap?.hostname ?? 'http://localhost:4173'),
91
+ });
92
+ }
93
+ if (normalizedOptions.emitRobotsTxt !== false) {
94
+ this.emitFile({
95
+ type: 'asset',
96
+ fileName: 'robots.txt',
97
+ source: renderRobotsTxt(normalizedOptions.sitemap?.hostname ?? 'http://localhost:4173'),
98
+ });
99
+ }
100
+ for (const route of routes) {
101
+ this.emitFile({
102
+ type: 'asset',
103
+ fileName: `${route.path.replace(/^\//, '').replace(/\/$/, '')}/index.html`,
104
+ source: renderRouteHtml(route, assets),
105
+ });
106
+ }
107
+ },
108
+ },
109
+ ];
110
+ }
111
+ function normalizeOptions(options) {
112
+ if (!options.locales.includes(options.defaultLocale)) {
113
+ throw new Error(`defaultLocale '${options.defaultLocale}' must be included in locales.`);
114
+ }
115
+ return {
116
+ ...options,
117
+ requiredFields: options.requiredFields ?? ['title', 'slug', 'id', 'component', 'public'],
118
+ validateComponents: options.validateComponents ?? true,
119
+ componentsDir: options.componentsDir,
120
+ emitRouteManifest: options.emitRouteManifest ?? true,
121
+ emitSitemap: options.emitSitemap ?? true,
122
+ emitRobotsTxt: options.emitRobotsTxt ?? true,
123
+ };
124
+ }
125
+ function createVirtualModule(index, defaultLocale) {
126
+ const content = index.entries;
127
+ const routes = index.routes;
128
+ return `
129
+ const content = ${JSON.stringify(content)};
130
+ const routes = ${JSON.stringify(routes)};
131
+
132
+ export function getContent(id, locale) {
133
+ if (locale) return content.find((entry) => entry.id === id && entry.locale === locale);
134
+ return content.find((entry) => entry.id === id);
135
+ }
136
+
137
+ export function getAllContent() {
138
+ return content;
139
+ }
140
+
141
+ export function getRoutes(locale) {
142
+ if (locale) return routes.filter((route) => route.locale === locale);
143
+ return routes;
144
+ }
145
+
146
+ export function getAlternatives(contentId, currentLocale) {
147
+ const entry = content.find((item) => item.id === contentId);
148
+ if (!entry) return {};
149
+ const alternatives = { ...entry.alternatives };
150
+ delete alternatives[currentLocale];
151
+ return alternatives;
152
+ }
153
+
154
+ export function getLocale(locale) {
155
+ return locale;
156
+ }
157
+
158
+ export function getLocales() {
159
+ return [...new Set(routes.map((route) => route.locale))];
160
+ }
161
+
162
+ export function getDefaultLocale() {
163
+ return ${JSON.stringify(defaultLocale)};
164
+ }
165
+
166
+ export const flatwaveContentIndex = ${JSON.stringify(index)};
167
+ `;
168
+ }
169
+ async function readFile(file) {
170
+ const fs = await import('node:fs/promises');
171
+ return fs.readFile(file, 'utf-8');
172
+ }
173
+ function inferLocale(file, contentDir, locales) {
174
+ const relative = path.relative(path.resolve(contentDir), file);
175
+ const firstSegment = relative.split(path.sep)[0];
176
+ return locales.includes(firstSegment) ? firstSegment : undefined;
177
+ }
178
+ function findIndexHtml(bundle) {
179
+ for (const item of Object.values(bundle)) {
180
+ if (item &&
181
+ typeof item === 'object' &&
182
+ 'fileName' in item &&
183
+ item.fileName === 'index.html' &&
184
+ 'source' in item) {
185
+ return String(item.source);
186
+ }
187
+ }
188
+ return undefined;
189
+ }
190
+ function extractAssets(html) {
191
+ if (!html)
192
+ return { scripts: [], styles: [] };
193
+ const scripts = [...html.matchAll(/<script[^>]+src="([^"]+\.js)"[^>]*>/g)].map((match) => match[1]);
194
+ const styles = [
195
+ ...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+\.css)"[^>]*>/g),
196
+ ].map((match) => match[1]);
197
+ return { scripts, styles };
198
+ }
199
+ function renderSitemap(routes, hostname) {
200
+ const base = hostname.replace(/\/$/, '');
201
+ const urls = routes
202
+ .map((route) => {
203
+ const loc = `${base}${route.path}`;
204
+ return `<url><loc>${escapeXml(loc)}</loc><lastmod>${new Date().toISOString().slice(0, 10)}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>`;
205
+ })
206
+ .join('');
207
+ return `<?xml version="1.0" encoding="UTF-8"?>
208
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>
209
+ `;
210
+ }
211
+ function renderRobotsTxt(hostname) {
212
+ const base = hostname.replace(/\/$/, '');
213
+ return `User-agent: *
214
+ Allow: /
215
+
216
+ Sitemap: ${base}/sitemap.xml
217
+ `;
218
+ }
219
+ function renderRouteHtml(route, assets) {
220
+ const scripts = assets.scripts
221
+ .map((src) => `<script type="module" crossorigin src="${escapeHtml(src)}"></script>`)
222
+ .join('\n');
223
+ const styles = assets.styles
224
+ .map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`)
225
+ .join('\n');
226
+ const title = escapeHtml(route.metadata.title);
227
+ const description = route.metadata.description ? escapeHtml(route.metadata.description) : title;
228
+ return `<!doctype html>
229
+ <html lang="${escapeHtml(route.locale)}">
230
+ <head>
231
+ <meta charset="UTF-8">
232
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
233
+ <title>${title}</title>
234
+ <meta name="description" content="${description}">
235
+ <link rel="canonical" href="${escapeHtml(route.metadata.canonical ?? route.path)}">
236
+ ${styles}
237
+ ${renderHtmlHead(route)}
238
+ </head>
239
+ <body>
240
+ <div id="root"></div>
241
+ ${scripts}
242
+ </body>
243
+ </html>
244
+ `;
245
+ }
246
+ export default flatwaveContent;
@@ -0,0 +1,8 @@
1
+ /// <reference path="../virtual.d.ts" />
2
+ import { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes } from 'virtual:flatwave/content';
3
+ export declare function useFlatwaveContent(id: string, locale?: string): import("virtual:flatwave/content").FlatwaveVirtualContent | undefined;
4
+ export declare function useFlatwaveRoutes(locale?: string): import("virtual:flatwave/content").FlatwaveVirtualRoute[];
5
+ export declare function useFlatwaveAlternatives(id: string, currentLocale?: string): Record<string, string>;
6
+ export declare function useFlatwaveLocales(): string[];
7
+ export declare function useFlatwaveLocale(locale?: string): string | undefined;
8
+ export { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes };
@@ -0,0 +1,18 @@
1
+ import { useMemo } from 'react';
2
+ import { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes, } from 'virtual:flatwave/content';
3
+ export function useFlatwaveContent(id, locale) {
4
+ return useMemo(() => getContent(id, locale), [id, locale]);
5
+ }
6
+ export function useFlatwaveRoutes(locale) {
7
+ return useMemo(() => getRoutes(locale), [locale]);
8
+ }
9
+ export function useFlatwaveAlternatives(id, currentLocale) {
10
+ return useMemo(() => getAlternatives(id, currentLocale), [id, currentLocale]);
11
+ }
12
+ export function useFlatwaveLocales() {
13
+ return useMemo(() => getLocales(), []);
14
+ }
15
+ export function useFlatwaveLocale(locale) {
16
+ return useMemo(() => getLocale(locale), [locale]);
17
+ }
18
+ export { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes };
@@ -0,0 +1,41 @@
1
+ declare module 'virtual:flatwave/content' {
2
+ export interface FlatwaveVirtualContent {
3
+ id: string;
4
+ locale: string;
5
+ slug: string;
6
+ path: string;
7
+ file: string;
8
+ component?: string;
9
+ public: boolean;
10
+ attributes: Record<string, unknown>;
11
+ frontmatter: Record<string, unknown>;
12
+ body: string;
13
+ route: string;
14
+ alternatives: Record<string, string>;
15
+ }
16
+
17
+ export interface FlatwaveVirtualRoute {
18
+ locale: string;
19
+ path: string;
20
+ contentId: string;
21
+ component?: string;
22
+ metadata: Record<string, unknown>;
23
+ frontmatter: Record<string, unknown>;
24
+ alternatives: Record<string, string>;
25
+ }
26
+
27
+ export function getContent(id: string, locale?: string): FlatwaveVirtualContent | undefined;
28
+ export function getAllContent(): FlatwaveVirtualContent[];
29
+ export function getRoutes(locale?: string): FlatwaveVirtualRoute[];
30
+ export function getAlternatives(
31
+ contentId: string,
32
+ currentLocale?: string
33
+ ): Record<string, string>;
34
+ export function getLocale(locale?: string): string | undefined;
35
+ export function getLocales(): string[];
36
+ export function getDefaultLocale(): string;
37
+ export const flatwaveContentIndex: {
38
+ entries: FlatwaveVirtualContent[];
39
+ routes: FlatwaveVirtualRoute[];
40
+ };
41
+ }
@@ -0,0 +1,6 @@
1
+ import type { FlatwaveRoute, SeoMetadata } from '../types';
2
+ export declare function escapeHtml(value: string): string;
3
+ export declare function escapeXml(value: string): string;
4
+ export declare function renderHtmlHead(route: FlatwaveRoute): string;
5
+ export declare function escapeJsonScript(value: string): string;
6
+ export declare function buildSeoMetadata(metadata: SeoMetadata): string;
@@ -0,0 +1,57 @@
1
+ export function escapeHtml(value) {
2
+ return value
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#039;');
8
+ }
9
+ export function escapeXml(value) {
10
+ return escapeHtml(value).replace(/"/g, '&quot;');
11
+ }
12
+ export function renderHtmlHead(route) {
13
+ const metadata = route.metadata;
14
+ const tags = [];
15
+ if (metadata.description)
16
+ tags.push(`<meta name="description" content="${escapeHtml(metadata.description)}">`);
17
+ if (metadata.robots)
18
+ tags.push(`<meta name="robots" content="${escapeHtml(metadata.robots)}">`);
19
+ if (metadata.canonical)
20
+ tags.push(`<link rel="canonical" href="${escapeHtml(metadata.canonical)}">`);
21
+ if (metadata.image) {
22
+ tags.push(`<meta property="og:image" content="${escapeHtml(metadata.image)}">`);
23
+ tags.push(`<meta name="twitter:image" content="${escapeHtml(metadata.image)}">`);
24
+ }
25
+ for (const [locale, alternate] of Object.entries(route.alternatives).sort()) {
26
+ tags.push(`<link rel="alternate" hreflang="${escapeHtml(locale)}" href="${escapeHtml(alternate)}">`);
27
+ }
28
+ for (const [property, value] of Object.entries(metadata.og ?? {})) {
29
+ tags.push(`<meta property="og:${escapeHtml(property)}" content="${escapeHtml(value)}">`);
30
+ }
31
+ for (const [name, value] of Object.entries(metadata.twitter ?? {})) {
32
+ tags.push(`<meta name="twitter:${escapeHtml(name)}" content="${escapeHtml(value)}">`);
33
+ }
34
+ if (metadata.jsonLd) {
35
+ tags.push(`<script type="application/ld+json">${escapeJsonScript(JSON.stringify(metadata.jsonLd))}</script>`);
36
+ }
37
+ return tags.join('\n ');
38
+ }
39
+ export function escapeJsonScript(value) {
40
+ return value
41
+ .replace(/</g, '\\u003c')
42
+ .replace(/>/g, '\\u003e')
43
+ .replace(/&/g, '\\u0026')
44
+ .replace(/\u2028/g, '\\u2028')
45
+ .replace(/\u2029/g, '\\u2029');
46
+ }
47
+ export function buildSeoMetadata(metadata) {
48
+ const route = {
49
+ locale: '',
50
+ path: metadata.canonical ?? '/',
51
+ contentId: '',
52
+ metadata,
53
+ frontmatter: {},
54
+ alternatives: {},
55
+ };
56
+ return renderHtmlHead(route);
57
+ }
@@ -0,0 +1,87 @@
1
+ export type FlatwaveFallbackPolicy = 'default' | 'warn' | 'error';
2
+ export interface FlatwaveSitemapOptions {
3
+ hostname?: string;
4
+ changefreq?: string;
5
+ priority?: number;
6
+ }
7
+ export interface FlatwaveRobotsOptions {
8
+ allowAll?: boolean;
9
+ sitemapPath?: string;
10
+ rules?: string[];
11
+ }
12
+ export interface FlatwaveContentOptions {
13
+ contentDir: string;
14
+ locales: string[];
15
+ defaultLocale: string;
16
+ fallback?: FlatwaveFallbackPolicy;
17
+ strictMissingLocales?: boolean;
18
+ requiredFields?: string[];
19
+ validateComponents?: boolean;
20
+ componentsDir?: string | string[];
21
+ emitRouteManifest?: boolean;
22
+ emitSitemap?: boolean;
23
+ emitRobotsTxt?: boolean;
24
+ sitemap?: FlatwaveSitemapOptions;
25
+ robots?: FlatwaveRobotsOptions;
26
+ }
27
+ export interface FlatwaveFrontmatter extends Record<string, unknown> {
28
+ title: string;
29
+ slug: string;
30
+ id: string;
31
+ component: string;
32
+ public?: boolean | string;
33
+ description?: string;
34
+ canonical?: string;
35
+ image?: string;
36
+ robots?: string;
37
+ keywords?: string[];
38
+ jsonLd?: unknown;
39
+ og?: Record<string, string>;
40
+ twitter?: Record<string, string>;
41
+ menu?: string;
42
+ menu_position?: number | string;
43
+ }
44
+ export interface FlatwaveContentEntry {
45
+ id: string;
46
+ locale: string;
47
+ slug: string;
48
+ path: string;
49
+ file: string;
50
+ component?: string;
51
+ public: boolean;
52
+ attributes: FlatwaveFrontmatter;
53
+ frontmatter: FlatwaveFrontmatter;
54
+ body: string;
55
+ route: string;
56
+ alternatives: Record<string, string>;
57
+ }
58
+ export interface FlatwaveRoute {
59
+ locale: string;
60
+ path: string;
61
+ contentId: string;
62
+ component?: string;
63
+ metadata: SeoMetadata;
64
+ frontmatter: FlatwaveFrontmatter;
65
+ alternatives: Record<string, string>;
66
+ }
67
+ export interface FlatwaveContentIndex {
68
+ entries: FlatwaveContentEntry[];
69
+ byId: Record<string, Record<string, FlatwaveContentEntry>>;
70
+ byLocale: Record<string, Record<string, FlatwaveContentEntry>>;
71
+ routes: FlatwaveRoute[];
72
+ }
73
+ export interface SeoMetadata {
74
+ title: string;
75
+ description?: string;
76
+ canonical?: string;
77
+ image?: string;
78
+ robots?: string;
79
+ keywords?: string[];
80
+ jsonLd?: unknown;
81
+ og?: Record<string, string>;
82
+ twitter?: Record<string, string>;
83
+ }
84
+ export interface ValidationResult {
85
+ errors: string[];
86
+ warnings: string[];
87
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@kamansoft/vite-plugin-flatwave-react",
3
+ "version": "0.1.0",
4
+ "description": "Vite content plugin for Markdown-driven, i18n-aware static React sites.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react/index.d.ts",
15
+ "import": "./dist/react/index.js"
16
+ },
17
+ "./seo": {
18
+ "types": "./dist/seo/metadata.d.ts",
19
+ "import": "./dist/seo/metadata.js"
20
+ },
21
+ "./validation": {
22
+ "types": "./dist/content/validator.d.ts",
23
+ "import": "./dist/content/validator.js"
24
+ },
25
+ "./types": {
26
+ "types": "./dist/types.d.ts"
27
+ },
28
+ "./virtual": {
29
+ "types": "./dist/react/virtual.d.ts"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "bin": {
34
+ "flatwave-validate": "dist/cli/validate.js"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-virtual-types.js",
43
+ "test": "vitest run"
44
+ },
45
+ "author": "KamanaSoft",
46
+ "license": "MIT",
47
+ "keywords": [
48
+ "vite",
49
+ "vite-plugin",
50
+ "react",
51
+ "markdown",
52
+ "i18n",
53
+ "ssg",
54
+ "static-site-generator",
55
+ "content"
56
+ ],
57
+ "homepage": "https://github.com/kamansoft/vite-plugin-flatwave-react/tree/main/packages/vite-plugin-flatwave-react",
58
+ "bugs": {
59
+ "url": "https://github.com/kamansoft/vite-plugin-flatwave-react/issues"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/kamansoft/vite-plugin-flatwave-react.git",
64
+ "directory": "packages/vite-plugin-flatwave-react"
65
+ },
66
+ "engines": {
67
+ "node": ">=22.0.0"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "peerDependencies": {
73
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
74
+ "react": ">=18.0.0",
75
+ "react-dom": ">=18.0.0"
76
+ },
77
+ "dependencies": {
78
+ "commander": "^13.1.0",
79
+ "fast-glob": "^3.3.3",
80
+ "gray-matter": "^4.0.3"
81
+ },
82
+ "devDependencies": {
83
+ "@types/node": "^22.10.2",
84
+ "@types/react": "^18.3.12",
85
+ "@types/react-dom": "^18.3.1",
86
+ "react": "^18.3.1",
87
+ "react-dom": "^18.3.1",
88
+ "typescript": "^5.7.2",
89
+ "vite": "^6.0.7"
90
+ }
91
+ }