fimo-vite 0.21.0-experimental.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/plugins/content-sync.d.ts +11 -0
- package/dist/plugins/content-sync.js +40 -0
- package/dist/plugins/data-id.d.ts +3 -0
- package/dist/plugins/data-id.js +98 -0
- package/dist/plugins/fimo-config.d.ts +12 -0
- package/dist/plugins/fimo-config.js +68 -0
- package/dist/plugins/overlay.d.ts +3 -0
- package/dist/plugins/overlay.js +153 -0
- package/dist/plugins/robots.d.ts +10 -0
- package/dist/plugins/robots.js +56 -0
- package/dist/plugins/seo.d.ts +3 -0
- package/dist/plugins/seo.js +112 -0
- package/dist/plugins/server-config.d.ts +3 -0
- package/dist/plugins/server-config.js +36 -0
- package/dist/plugins/sitemap.d.ts +3 -0
- package/dist/plugins/sitemap.js +73 -0
- package/dist/plugins/translations.d.ts +11 -0
- package/dist/plugins/translations.js +600 -0
- package/dist/runtime/overlay-hmr.d.ts +2 -0
- package/dist/runtime/overlay-hmr.js +27 -0
- package/dist/runtime/overlay-runtime.d.ts +24 -0
- package/dist/runtime/overlay-runtime.js +103 -0
- package/dist/vite.d.ts +32 -0
- package/dist/vite.js +98 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# fimo-vite
|
|
2
|
+
|
|
3
|
+
The framework-neutral Fimo integration for projects that build with Vite.
|
|
4
|
+
Install it alongside `fimo` at the same version:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install fimo fimo-vite
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { defineConfig } from 'vite';
|
|
12
|
+
import { fimo } from 'fimo-vite';
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
plugins: [fimo({ seo: true })],
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Nothing here depends on React, React Router, Svelte, or any other UI framework:
|
|
20
|
+
it is the config virtual module, the label/content dev bridge, the JSX
|
|
21
|
+
`data-fimo-id` tagger, the dev error overlay, and the opt-in `sitemap` /
|
|
22
|
+
`robots` / `seo` emitters. Framework packages compose it — React Router
|
|
23
|
+
projects use `fimo-react-router/vite`, which adds the router-specific
|
|
24
|
+
dependency-optimization behaviour on top of this plugin set.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Keeps generated schema + form clients in sync while the Vite dev server is
|
|
4
|
+
* running.
|
|
5
|
+
*
|
|
6
|
+
* Production builds are deliberately read-only. Explicit authoring commands
|
|
7
|
+
* (`fimo validate`, `fimo schemas push`, and `fimo forms push`) own initial
|
|
8
|
+
* generation before a build starts.
|
|
9
|
+
*/
|
|
10
|
+
export default function contentSyncPlugin(): Plugin;
|
|
11
|
+
//# sourceMappingURL=content-sync.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { syncForms, syncSchemas } from 'fimo/sync';
|
|
3
|
+
/**
|
|
4
|
+
* Keeps generated schema + form clients in sync while the Vite dev server is
|
|
5
|
+
* running.
|
|
6
|
+
*
|
|
7
|
+
* Production builds are deliberately read-only. Explicit authoring commands
|
|
8
|
+
* (`fimo validate`, `fimo schemas push`, and `fimo forms push`) own initial
|
|
9
|
+
* generation before a build starts.
|
|
10
|
+
*/
|
|
11
|
+
export default function contentSyncPlugin() {
|
|
12
|
+
let rootDir = process.cwd();
|
|
13
|
+
return {
|
|
14
|
+
name: 'vite-plugin-fimo-content-sync',
|
|
15
|
+
enforce: 'pre',
|
|
16
|
+
configResolved(config) {
|
|
17
|
+
rootDir = config.root;
|
|
18
|
+
},
|
|
19
|
+
configureServer(server) {
|
|
20
|
+
const schemasGlob = resolve(rootDir, 'src/schemas');
|
|
21
|
+
const formsGlob = resolve(rootDir, 'src/forms');
|
|
22
|
+
server.watcher.add([schemasGlob, formsGlob]);
|
|
23
|
+
server.watcher.on('add', onChange);
|
|
24
|
+
server.watcher.on('change', onChange);
|
|
25
|
+
server.watcher.on('unlink', onChange);
|
|
26
|
+
function onChange(file) {
|
|
27
|
+
if (!file.endsWith('.json')) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (file.startsWith(schemasGlob)) {
|
|
31
|
+
syncSchemas({ cwd: rootDir, framework: 'vite', verbose: false });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (file.startsWith(formsGlob)) {
|
|
35
|
+
syncForms({ cwd: rootDir, verbose: false });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createFilter } from '@rollup/pluginutils';
|
|
2
|
+
import { getLineColumn } from 'fimo/vite/data-id';
|
|
3
|
+
import { parseSync } from 'oxc-parser';
|
|
4
|
+
export default function dataIdPlugin() {
|
|
5
|
+
let config;
|
|
6
|
+
return {
|
|
7
|
+
name: 'vite-plugin-data-id',
|
|
8
|
+
enforce: 'pre',
|
|
9
|
+
configResolved(resolvedConfig) {
|
|
10
|
+
config = resolvedConfig;
|
|
11
|
+
},
|
|
12
|
+
async transform(code, id) {
|
|
13
|
+
if (config?.command === 'build') {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const filter = createFilter(/\.(jsx|tsx)$/);
|
|
17
|
+
if (!filter(id)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (id.includes('components/ui/') || id.includes('.fimo/ui/')) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const result = parseSync(id, code);
|
|
25
|
+
const ast = result.program;
|
|
26
|
+
const insertions = [];
|
|
27
|
+
function visit(node) {
|
|
28
|
+
if (!node || typeof node !== 'object') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (node.type === 'JSXOpeningElement') {
|
|
32
|
+
const attributes = node.attributes || [];
|
|
33
|
+
const hasId = attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
34
|
+
attr.name?.type === 'JSXIdentifier' &&
|
|
35
|
+
attr.name?.name === 'data-fimo-id');
|
|
36
|
+
if (!hasId && node.start != null) {
|
|
37
|
+
const line = getLineColumn(code, node.start);
|
|
38
|
+
const relativePath = id.replace(process.cwd() + '/', '');
|
|
39
|
+
const fimoId = `${relativePath}#${line.line}-${line.column}`;
|
|
40
|
+
const nameEnd = getNameEnd(node);
|
|
41
|
+
insertions.push({
|
|
42
|
+
offset: nameEnd,
|
|
43
|
+
value: ` data-fimo-id="${fimoId}"`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(node)) {
|
|
48
|
+
if (key === 'parent') {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const child = node[key];
|
|
52
|
+
if (Array.isArray(child)) {
|
|
53
|
+
for (const item of child) {
|
|
54
|
+
visit(item);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (typeof child === 'object' && child !== null) {
|
|
58
|
+
visit(child);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
visit(ast);
|
|
63
|
+
if (insertions.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
insertions.sort((a, b) => b.offset - a.offset);
|
|
67
|
+
let transformed = code;
|
|
68
|
+
for (const insertion of insertions) {
|
|
69
|
+
transformed = transformed.slice(0, insertion.offset) + insertion.value + transformed.slice(insertion.offset);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
code: transformed,
|
|
73
|
+
map: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Offset just past the element's name (or its type arguments), which is where
|
|
84
|
+
* an extra attribute can be spliced in without shifting any other token.
|
|
85
|
+
*
|
|
86
|
+
* The `<path>#<line>-<column>` id itself comes from `fimo/vite/data-id`, shared
|
|
87
|
+
* with the SvelteKit plugin, so the convention cannot drift between them.
|
|
88
|
+
*/
|
|
89
|
+
function getNameEnd(openingElement) {
|
|
90
|
+
const name = openingElement.name;
|
|
91
|
+
if (name?.end != null) {
|
|
92
|
+
if (openingElement.typeParameters?.end != null) {
|
|
93
|
+
return openingElement.typeParameters.end;
|
|
94
|
+
}
|
|
95
|
+
return name.end;
|
|
96
|
+
}
|
|
97
|
+
return openingElement.start;
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Vite plugin that exposes the user's `.fimo/config.json`:
|
|
4
|
+
* - As a virtual module `virtual:fimo-config` that browser-running package
|
|
5
|
+
* code (fimo/seo, fimo/app) imports to read `seo`, `routes`, etc. without
|
|
6
|
+
* relying on `readFileSync` at runtime.
|
|
7
|
+
*
|
|
8
|
+
* Edits to the project config invalidate the virtual module so HMR picks up
|
|
9
|
+
* the new values without a full reload.
|
|
10
|
+
*/
|
|
11
|
+
export default function fimoConfigPlugin(): Plugin;
|
|
12
|
+
//# sourceMappingURL=fimo-config.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolveFimoConfigPath } from 'fimo/config';
|
|
3
|
+
const VIRTUAL_ID = 'virtual:fimo-config';
|
|
4
|
+
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
|
5
|
+
/**
|
|
6
|
+
* Vite plugin that exposes the user's `.fimo/config.json`:
|
|
7
|
+
* - As a virtual module `virtual:fimo-config` that browser-running package
|
|
8
|
+
* code (fimo/seo, fimo/app) imports to read `seo`, `routes`, etc. without
|
|
9
|
+
* relying on `readFileSync` at runtime.
|
|
10
|
+
*
|
|
11
|
+
* Edits to the project config invalidate the virtual module so HMR picks up
|
|
12
|
+
* the new values without a full reload.
|
|
13
|
+
*/
|
|
14
|
+
export default function fimoConfigPlugin() {
|
|
15
|
+
let configPath = resolveFimoConfigPath(process.cwd());
|
|
16
|
+
return {
|
|
17
|
+
name: 'vite-plugin-fimo-config',
|
|
18
|
+
config(userConfig) {
|
|
19
|
+
const root = typeof userConfig.root === 'string' ? userConfig.root : process.cwd();
|
|
20
|
+
configPath = resolveFimoConfigPath(root);
|
|
21
|
+
},
|
|
22
|
+
resolveId(id) {
|
|
23
|
+
if (id === VIRTUAL_ID) {
|
|
24
|
+
return RESOLVED_ID;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
},
|
|
28
|
+
load(id) {
|
|
29
|
+
if (id !== RESOLVED_ID) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
this.addWatchFile(configPath);
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
35
|
+
return `export default ${raw};`;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 'export default {};';
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
transformIndexHtml(html) {
|
|
42
|
+
const lang = readHtmlLang(configPath);
|
|
43
|
+
if (/<html\b[^>]*\blang=/.test(html)) {
|
|
44
|
+
return html.replace(/(<html\b[^>]*\blang=["'])[^"']*(["'])/i, `$1${lang}$2`);
|
|
45
|
+
}
|
|
46
|
+
return html.replace(/<html\b([^>]*)>/i, `<html$1 lang="${lang}">`);
|
|
47
|
+
},
|
|
48
|
+
handleHotUpdate(ctx) {
|
|
49
|
+
if (ctx.file !== configPath) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
53
|
+
if (mod) {
|
|
54
|
+
ctx.server.moduleGraph.invalidateModule(mod);
|
|
55
|
+
return [mod];
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function readHtmlLang(configPath) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
63
|
+
return (raw.i18n?.defaultLocale ?? 'en').replace('_', '-');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'en';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { dirname, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
// Compiled overlay runtime lives at `dist/runtime/*.js` once this package is
|
|
5
|
+
// built. The plugin lives at `dist/plugins/overlay.js`, so it walks back to
|
|
6
|
+
// `dist` and then into the sibling runtime directory.
|
|
7
|
+
function resolveRuntimeDir() {
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
return resolve(here, '..', 'runtime');
|
|
10
|
+
}
|
|
11
|
+
// Two virtual modules so a framework's script connector can wire both halves
|
|
12
|
+
// of the overlay:
|
|
13
|
+
//
|
|
14
|
+
// `virtual:fimo-overlay-runtime` — exports the runtime source as a string,
|
|
15
|
+
// to be embedded inline. Handles window.error + unhandledrejection + DOM
|
|
16
|
+
// overlay. Inline because we want it loaded as early as possible.
|
|
17
|
+
//
|
|
18
|
+
// `virtual:fimo-overlay-hmr` — a real ES module (side-effect import).
|
|
19
|
+
// Handles vite:error / vite:afterUpdate. Must be vite-transformed so
|
|
20
|
+
// `import.meta.hot` is defined; inline scripts bypass vite's transform.
|
|
21
|
+
//
|
|
22
|
+
// In production both resolve to no-ops so the overlay machinery is dev-only.
|
|
23
|
+
// The previous `transformIndexHtml` approach only fired when vite served an
|
|
24
|
+
// `index.html`, which react-router framework mode bypasses entirely.
|
|
25
|
+
const VIRTUAL_RUNTIME = 'virtual:fimo-overlay-runtime';
|
|
26
|
+
const VIRTUAL_HMR = 'virtual:fimo-overlay-hmr';
|
|
27
|
+
// When the user's code has a parse / load error at the time of a full page
|
|
28
|
+
// request (e.g. hard reload while the file is broken, or cold-loading the
|
|
29
|
+
// preview iframe against a broken sandbox), vite serves a 500 HTML response
|
|
30
|
+
// containing the structured error as a JS object literal:
|
|
31
|
+
//
|
|
32
|
+
// <script type="module">
|
|
33
|
+
// const error = { message, stack, id, frame, pluginCode, ... };
|
|
34
|
+
// const { ErrorOverlay } = await import("/@vite/client");
|
|
35
|
+
// document.body.appendChild(new ErrorOverlay(error));
|
|
36
|
+
// </script>
|
|
37
|
+
//
|
|
38
|
+
// In framework mode, none of the user's modules — including the inline
|
|
39
|
+
// overlay-runtime and the side-effect HMR import — ever load on this kind of
|
|
40
|
+
// response, so the parent UI is left blind. We extract that object literal
|
|
41
|
+
// and append a script that postMessages it to `window.parent` using the same
|
|
42
|
+
// `preview/error` + `app/error` shape the runtime uses, so the dashboard's
|
|
43
|
+
// overlay state machine fires identically to the HMR path.
|
|
44
|
+
function injectErrorForwarder(html) {
|
|
45
|
+
// Vite's error page structure:
|
|
46
|
+
// const error = { ...escaped JSON including the whole source as
|
|
47
|
+
// `pluginCode` (which contains its own `};` sequences) }
|
|
48
|
+
// <newline + spaces>try {
|
|
49
|
+
// Anchor on `try {` (which always immediately follows) so the lazy
|
|
50
|
+
// matcher backtracks past `}` sequences embedded inside the source.
|
|
51
|
+
// The trailing `;` is optional — vite relies on ASI here.
|
|
52
|
+
const match = html.match(/const error = (\{[\s\S]*?\})\s*;?\s*try\s*\{/);
|
|
53
|
+
if (!match)
|
|
54
|
+
return html;
|
|
55
|
+
const errorLiteral = match[1];
|
|
56
|
+
const script = '<script>(function(){try{var e=' +
|
|
57
|
+
errorLiteral +
|
|
58
|
+
';if(window.parent&&window.parent!==window){' +
|
|
59
|
+
'var p={heading:"Build Error",message:(e&&e.message)||"",stack:(e&&e.stack)||""};' +
|
|
60
|
+
'window.parent.postMessage({type:"preview/error",payload:p},"*");' +
|
|
61
|
+
'window.parent.postMessage({type:"app/error",payload:p},"*");' +
|
|
62
|
+
'}}catch(_){}}());</script>';
|
|
63
|
+
if (html.includes('</body>'))
|
|
64
|
+
return html.replace('</body>', script + '</body>');
|
|
65
|
+
return html + script;
|
|
66
|
+
}
|
|
67
|
+
export default function overlayPlugin() {
|
|
68
|
+
let isDev = false;
|
|
69
|
+
return {
|
|
70
|
+
name: 'vite-plugin-overlay',
|
|
71
|
+
config(_, env) {
|
|
72
|
+
isDev = env.command === 'serve';
|
|
73
|
+
},
|
|
74
|
+
resolveId(id) {
|
|
75
|
+
if (id === VIRTUAL_RUNTIME || id === VIRTUAL_HMR)
|
|
76
|
+
return id;
|
|
77
|
+
return undefined;
|
|
78
|
+
},
|
|
79
|
+
load(id) {
|
|
80
|
+
if (id === VIRTUAL_RUNTIME) {
|
|
81
|
+
if (!isDev)
|
|
82
|
+
return 'export default "";';
|
|
83
|
+
try {
|
|
84
|
+
const runtime = readFileSync(resolve(resolveRuntimeDir(), 'overlay-runtime.js'), 'utf-8');
|
|
85
|
+
return `export default ${JSON.stringify(runtime)};`;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return 'export default "";';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (id === VIRTUAL_HMR) {
|
|
92
|
+
if (!isDev)
|
|
93
|
+
return 'export {};';
|
|
94
|
+
try {
|
|
95
|
+
return readFileSync(resolve(resolveRuntimeDir(), 'overlay-hmr.js'), 'utf-8');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return 'export {};';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
},
|
|
103
|
+
configureServer(server) {
|
|
104
|
+
server.middlewares.use((_req, res, next) => {
|
|
105
|
+
const chunks = [];
|
|
106
|
+
const origWrite = res.write.bind(res);
|
|
107
|
+
const origEnd = res.end.bind(res);
|
|
108
|
+
let capturing = false;
|
|
109
|
+
// Capture every 500 response; sniff by body content in
|
|
110
|
+
// `injectErrorForwarder` since vite emits its error page without a
|
|
111
|
+
// Content-Type at the moment res.end() is called.
|
|
112
|
+
const shouldCapture = () => res.statusCode === 500;
|
|
113
|
+
// The Connect / Node response.write & end signatures accept Buffer
|
|
114
|
+
// | string | Uint8Array; we accept everything and normalise to Buffer
|
|
115
|
+
// so concat works regardless of how vite chose to emit the chunks.
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
res.write = function (chunk, ...rest) {
|
|
118
|
+
if (!capturing && shouldCapture())
|
|
119
|
+
capturing = true;
|
|
120
|
+
if (capturing) {
|
|
121
|
+
if (chunk)
|
|
122
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return origWrite(chunk, ...rest);
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
};
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
+
res.end = function (chunk, ...rest) {
|
|
130
|
+
if (!capturing && shouldCapture())
|
|
131
|
+
capturing = true;
|
|
132
|
+
if (!capturing)
|
|
133
|
+
return origEnd(chunk, ...rest);
|
|
134
|
+
if (chunk)
|
|
135
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
136
|
+
const html = injectErrorForwarder(Buffer.concat(chunks).toString('utf8'));
|
|
137
|
+
const body = Buffer.from(html, 'utf8');
|
|
138
|
+
// Vite sets Content-Length before us; rewrite to match the new body.
|
|
139
|
+
try {
|
|
140
|
+
res.setHeader('content-length', String(body.byteLength));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Headers already sent — ignore, content-length will be wrong but
|
|
144
|
+
// browsers tolerate trailing data on HTTP/1.1 keep-alive.
|
|
145
|
+
}
|
|
146
|
+
return origEnd(body, ...rest);
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
148
|
+
};
|
|
149
|
+
next();
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Emit a minimal `robots.txt` (allow-all + Sitemap reference if `seo.url` is
|
|
4
|
+
* set in `.fimo/config.json`). Serves at `/robots.txt` in dev; emits as a
|
|
5
|
+
* static asset at build time.
|
|
6
|
+
*
|
|
7
|
+
* Opt-in via `fimo({ robots: true })` in your `vite.config.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export default function robotsPlugin(): Plugin;
|
|
10
|
+
//# sourceMappingURL=robots.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolveFimoConfigPath } from 'fimo/config';
|
|
3
|
+
function readSeoUrl() {
|
|
4
|
+
try {
|
|
5
|
+
const raw = readFileSync(resolveFimoConfigPath(process.cwd()), 'utf-8');
|
|
6
|
+
const config = JSON.parse(raw);
|
|
7
|
+
return config.seo?.url;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function buildRobotsTxt(baseUrl) {
|
|
14
|
+
const lines = ['User-agent: *', 'Allow: /'];
|
|
15
|
+
if (baseUrl) {
|
|
16
|
+
try {
|
|
17
|
+
const sitemapUrl = new URL('/sitemap.xml', baseUrl).toString();
|
|
18
|
+
lines.push('', `Sitemap: ${sitemapUrl}`);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Invalid baseUrl — skip the Sitemap directive.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return lines.join('\n') + '\n';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Emit a minimal `robots.txt` (allow-all + Sitemap reference if `seo.url` is
|
|
28
|
+
* set in `.fimo/config.json`). Serves at `/robots.txt` in dev; emits as a
|
|
29
|
+
* static asset at build time.
|
|
30
|
+
*
|
|
31
|
+
* Opt-in via `fimo({ robots: true })` in your `vite.config.ts`.
|
|
32
|
+
*/
|
|
33
|
+
export default function robotsPlugin() {
|
|
34
|
+
return {
|
|
35
|
+
name: 'fimo:robots',
|
|
36
|
+
configureServer(server) {
|
|
37
|
+
server.middlewares.use((req, res, next) => {
|
|
38
|
+
if (req.url?.split('?')[0] !== '/robots.txt') {
|
|
39
|
+
next();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const baseUrl = readSeoUrl();
|
|
43
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
44
|
+
res.end(buildRobotsTxt(baseUrl));
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
generateBundle() {
|
|
48
|
+
const baseUrl = readSeoUrl();
|
|
49
|
+
this.emitFile({
|
|
50
|
+
type: 'asset',
|
|
51
|
+
fileName: 'robots.txt',
|
|
52
|
+
source: buildRobotsTxt(baseUrl),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getConfigServer } from 'fimo/config';
|
|
2
|
+
import { normalizePathname, resolveSeoForPath } from 'fimo/seo';
|
|
3
|
+
class HtmlTag {
|
|
4
|
+
tags = [];
|
|
5
|
+
addMeta(name, content) {
|
|
6
|
+
this.tags.push({ tag: 'meta', attrs: { name, content } });
|
|
7
|
+
}
|
|
8
|
+
addProperty(property, content) {
|
|
9
|
+
this.tags.push({ tag: 'meta', attrs: { property, content } });
|
|
10
|
+
}
|
|
11
|
+
addLink(rel, href) {
|
|
12
|
+
this.tags.push({ tag: 'link', attrs: { rel, href } });
|
|
13
|
+
}
|
|
14
|
+
list() {
|
|
15
|
+
return this.tags;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export default function seoPlugin() {
|
|
19
|
+
let rootDir;
|
|
20
|
+
return {
|
|
21
|
+
name: 'vite-plugin-seo',
|
|
22
|
+
configResolved(config) {
|
|
23
|
+
rootDir = config.root;
|
|
24
|
+
},
|
|
25
|
+
transformIndexHtml(html, ctx) {
|
|
26
|
+
try {
|
|
27
|
+
const config = getConfigServer(rootDir);
|
|
28
|
+
const pathname = normalizePathname(ctx?.path ?? '/');
|
|
29
|
+
const seo = resolveSeoForPath(pathname, config);
|
|
30
|
+
if (!seo) {
|
|
31
|
+
return html;
|
|
32
|
+
}
|
|
33
|
+
let nextHtml = html;
|
|
34
|
+
if (seo.title) {
|
|
35
|
+
if (/<title>[\s\S]*?<\/title>/.test(nextHtml)) {
|
|
36
|
+
nextHtml = nextHtml.replace(/<title>[\s\S]*?<\/title>/, `<title>${seo.title}</title>`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
nextHtml = nextHtml.replace('<head>', `<head>\n <title>${seo.title}</title>`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Remove existing meta/link tags that we will re-inject to avoid duplication
|
|
43
|
+
// (e.g. when the source index.html already contains og:/twitter: tags from the user's original site)
|
|
44
|
+
nextHtml = nextHtml
|
|
45
|
+
.replace(/<meta\b[^>]*\bname=["']description["'][^>]*>/gi, '')
|
|
46
|
+
.replace(/<meta\b[^>]*\bname=["']robots["'][^>]*>/gi, '')
|
|
47
|
+
.replace(/<meta\b[^>]*\bname=["']theme-color["'][^>]*>/gi, '')
|
|
48
|
+
.replace(/<meta\b[^>]*\b(?:name|property)=["']og:[^"']*["'][^>]*>/gi, '')
|
|
49
|
+
.replace(/<meta\b[^>]*\b(?:name|property)=["']twitter:[^"']*["'][^>]*>/gi, '')
|
|
50
|
+
.replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>/gi, '');
|
|
51
|
+
if (seo.favicon) {
|
|
52
|
+
nextHtml = nextHtml.replace(/<link\b[^>]*\brel=(?:"[^"]*icon[^"]*"|'[^']*icon[^']*'|[^>\s]*icon[^>\s]*)[^>]*>/gi, '');
|
|
53
|
+
}
|
|
54
|
+
const tags = new HtmlTag();
|
|
55
|
+
if (seo.description) {
|
|
56
|
+
tags.addMeta('description', seo.description);
|
|
57
|
+
}
|
|
58
|
+
if (seo.robots) {
|
|
59
|
+
tags.addMeta('robots', seo.robots);
|
|
60
|
+
}
|
|
61
|
+
if (seo.themeColor) {
|
|
62
|
+
tags.addMeta('theme-color', seo.themeColor);
|
|
63
|
+
}
|
|
64
|
+
if (seo.url) {
|
|
65
|
+
tags.addLink('canonical', seo.url);
|
|
66
|
+
}
|
|
67
|
+
if (seo.favicon) {
|
|
68
|
+
tags.addLink('icon', seo.favicon);
|
|
69
|
+
tags.addLink('apple-touch-icon', seo.favicon);
|
|
70
|
+
}
|
|
71
|
+
// Open Graph (must use property attribute, not name)
|
|
72
|
+
if (seo.title) {
|
|
73
|
+
tags.addProperty('og:title', seo.title);
|
|
74
|
+
}
|
|
75
|
+
if (seo.description) {
|
|
76
|
+
tags.addProperty('og:description', seo.description);
|
|
77
|
+
}
|
|
78
|
+
tags.addProperty('og:type', 'website');
|
|
79
|
+
if (seo.url) {
|
|
80
|
+
tags.addProperty('og:url', seo.url);
|
|
81
|
+
}
|
|
82
|
+
if (seo.siteName) {
|
|
83
|
+
tags.addProperty('og:site_name', seo.siteName);
|
|
84
|
+
}
|
|
85
|
+
if (seo.locale) {
|
|
86
|
+
tags.addProperty('og:locale', seo.locale);
|
|
87
|
+
}
|
|
88
|
+
if (seo.image) {
|
|
89
|
+
tags.addProperty('og:image', seo.image);
|
|
90
|
+
}
|
|
91
|
+
// Twitter
|
|
92
|
+
tags.addMeta('twitter:card', 'summary_large_image');
|
|
93
|
+
if (seo.twitterSite) {
|
|
94
|
+
tags.addMeta('twitter:site', seo.twitterSite);
|
|
95
|
+
}
|
|
96
|
+
if (seo.title) {
|
|
97
|
+
tags.addMeta('twitter:title', seo.title);
|
|
98
|
+
}
|
|
99
|
+
if (seo.description) {
|
|
100
|
+
tags.addMeta('twitter:description', seo.description);
|
|
101
|
+
}
|
|
102
|
+
if (seo.image) {
|
|
103
|
+
tags.addMeta('twitter:image', seo.image);
|
|
104
|
+
}
|
|
105
|
+
return { html: nextHtml, tags: tags.list() };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return html;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const PREVIEW_ORIGINS_ENV = 'FIMO_PREVIEW_ALLOWED_ORIGINS';
|
|
2
|
+
function readPreviewHosts() {
|
|
3
|
+
return (process.env[PREVIEW_ORIGINS_ENV] ?? '')
|
|
4
|
+
.split(',')
|
|
5
|
+
.map((origin) => origin.trim())
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
.map((origin) => (origin.startsWith('**.') ? `.${origin.slice(3)}` : origin));
|
|
8
|
+
}
|
|
9
|
+
export default function serverConfigPlugin() {
|
|
10
|
+
return {
|
|
11
|
+
name: 'fimo:server-config',
|
|
12
|
+
config(config) {
|
|
13
|
+
const configuredHosts = config.server?.allowedHosts;
|
|
14
|
+
const previewHosts = readPreviewHosts();
|
|
15
|
+
const allowedHosts = configuredHosts === true
|
|
16
|
+
? true
|
|
17
|
+
: [...(configuredHosts ?? []), ...previewHosts].filter((host, index, hosts) => hosts.indexOf(host) === index);
|
|
18
|
+
return {
|
|
19
|
+
server: {
|
|
20
|
+
...(allowedHosts === true || allowedHosts.length > 0 ? { allowedHosts } : {}),
|
|
21
|
+
hmr: {
|
|
22
|
+
overlay: false,
|
|
23
|
+
},
|
|
24
|
+
watch: {
|
|
25
|
+
ignored: ['**/.worktrees/**', '**/.fimo/config.json', '**/fimo-config.json'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
build: {},
|
|
29
|
+
optimizeDeps: {
|
|
30
|
+
include: [],
|
|
31
|
+
exclude: [],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|