add initial test client

This commit is contained in:
Samuel Lorch 2023-03-06 23:56:36 +01:00
parent 0a51ba0beb
commit fbc899fbe0
28 changed files with 4829 additions and 0 deletions

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"prettier.enable": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

26
client/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
src/generated
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
client/README.md Normal file
View file

@ -0,0 +1,18 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

15
client/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A web-based configuration tool for netfilter tables">
<meta name="theme-color" content="#1A237E"/>
<link rel="shortcut icon" href="favicon.svg" type="image/svg+xml">
<title>nfSense</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

90
client/package.json Normal file
View file

@ -0,0 +1,90 @@
{
"name": "nfsm",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@intlify/unplugin-vue-i18n": "^0.8.2",
"@open-rpc/client-js": "^1.8.1",
"@vueuse/core": "^9.13.0",
"@vueuse/head": "^1.1.15",
"events": "^3.3.0",
"focus-trap": "^7.3.1",
"focus-trap-vue": "^4.0.2",
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-shiki": "^0.8.0",
"vue": "^3.2.45",
"vue-i18n": "9",
"vue-router": "4"
},
"devDependencies": {
"@iconify/json": "^2.2.30",
"@types/events": "^3.0.0",
"@types/markdown-it-link-attributes": "^3.0.1",
"@typescript-eslint/parser": "^5.54.1",
"@vitejs/plugin-vue": "^4.0.0",
"@vue-macros/reactivity-transform": "^0.2.4",
"@vue-macros/volar": "^0.8.4",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.9.0",
"typescript": "^4.9.3",
"unplugin-auto-import": "^0.15.0",
"unplugin-icons": "^0.15.3",
"unplugin-vue-components": "^0.24.0",
"unplugin-vue-macros": "^1.9.1",
"vite": "^4.1.0",
"vite-plugin-pages": "^0.28.0",
"vite-plugin-vue-markdown": "^0.22.4",
"vue-tsc": "^1.0.24"
},
"eslintConfig": {
"root": true,
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"overrides": [
{
"files": "**/*.+(ts|vue)"
}
],
"extends": [
"plugin:vue/vue3-strongly-recommended"
],
"plugins": [
"eslint-plugin-vue"
],
"rules": {
"semi": [
"error",
"always"
],
"comma-dangle": [
"error",
"always-multiline"
],
"no-trailing-spaces": [
"error"
],
"vue/multi-word-component-names": "off",
"vue/html-closing-bracket-spacing": "off",
"vue/html-self-closing": "off",
"vue/first-attribute-linebreak": "off",
"vue/max-attributes-per-line": "off",
"vue/html-closing-bracket-newline": "off",
"vue/singleline-html-element-content-newline": "off",
"indent": [
"error",
2
]
}
}
}

3969
client/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

133
client/src/App.vue Normal file
View file

@ -0,0 +1,133 @@
<script setup lang="ts">
import IDashboard from '~icons/ri/dashboard-2-line';
import IRule from '~icons/material-symbols/rule-folder-outline-sharp';
import IAddress from '~icons/eos-icons/ip';
enum NavState { Open, Reduced, Collapsed };
const NavStateCount = 3;
let navState = $ref(NavState.Open);
let loggedOut = $ref(false);
const navRoutes = {
"/": { icon: IDashboard, caption: "Dashboard" },
"/rules": { icon: IRule, caption: "Rules" },
"/addresses": { icon: IAddress, caption: "Addresses" },
};
</script>
<template>
<div v-if="!loggedOut" :class="{
'layout': 1,
'nav-state-open': navState === NavState.Open,
'nav-state-collapsed': navState === NavState.Collapsed,
'nav-state-reduced': navState === NavState.Reduced,
}">
<button class="nav-head" @click="() => navState = (navState + 1) % NavStateCount">
nfSense
</button>
<Portal from="page-header" class="page-header pad gap"/>
<div class="nav-body">
<template v-for="(options, route) in navRoutes" :key="route">
<router-link :to="route" class="button">
<component :is="options.icon"/>
{{ options.caption }}
</router-link>
</template>
<div class="flex-grow"/>
<div class="flex-row">
<router-link class="button" to="/help"><i-material-symbols-help-outline/></router-link>
<router-link class="button" to="/settings"><i-material-symbols-settings/></router-link>
<button @click="() => loggedOut = true"><i-material-symbols-logout/></button>
</div>
</div>
<router-view v-slot="{ Component, route }" v-if="!loggedOut">
<Transition name="fade">
<component :is="Component" :key="{route}" class="page-content pad gap"/>
</Transition>
</router-view>
</div>
<Transition name="fade">
<div class="login" v-if="loggedOut">
<FocusTrap>
<form @submit="$event => $event.preventDefault()">
<h1>nfSense Login</h1>
<label for="username" v-text="'Username'"/>
<input name="username"/>
<label for="password" v-text="'Password'" type="password"/>
<input name="password"/>
<button @click="() => loggedOut = false">Login</button>
</form>
</FocusTrap>
</div>
</Transition>
</template>
<style>
/* Basic Layout */
.layout, .login {
position: absolute;
left: 0px; right: 0px; top: 0px; bottom: 0px;
display: grid;
background-color: var(--cl-bg);
}
.layout {
grid-template-rows: auto 1fr;
grid-template-columns: auto 1fr;
}
.login { place-items: center; }
/* Navigation */
.nav-head, .nav-body { background: var(--cl-bg-low); }
.nav-head {
font-size: 2rem;
font-weight: bold;
}
.nav-body .button { justify-content: left; }
.nav-body .flex-row * { flex: 1; }
/* Page */
.page-header {
grid-row: 1;
grid-column: 2;
flex-flow: row nowrap;
align-items: center;
}
.page-header button svg {
margin: -0.25rem;
}
.page-content {
grid-row: 2;
grid-column: 2;
background: var(--cl-bg);
}
/* Nav-Body-Collapsing */
.nav-body, .page-content {
position: relative;
left: 0%;
width: 100%;
transition: left 0.2s ease-out, width 0.2s ease-out;
--reduced-width: 2.5rem;
}
.nav-state-reduced .nav-body { width: calc(0% + var(--reduced-width)); }
.nav-state-reduced .page-content {
left: calc(calc(-100vw + 100%) + var(--reduced-width));
width: calc(calc(0% + 100vw) - var(--reduced-width));
}
.nav-state-collapsed .nav-body { width: 0%; }
.nav-state-collapsed .page-content {
left: calc(-100vw + 100%);
width: calc(0% + 100vw);
}
:not(.nav-state-open) > .nav-body > .flex-row {
flex-direction: column;
align-items: start;
}
</style>

14
client/src/api.ts Normal file
View file

@ -0,0 +1,14 @@
import { RequestManager, HTTPTransport, WebSocketTransport, Client } from "@open-rpc/client-js";
const httpTransport = new HTTPTransport("http://"+ window.location.host +"/api");
const socktransport = new WebSocketTransport("ws://"+ window.location.host + "/ws/api");
const manager = new RequestManager([socktransport, httpTransport], () => crypto.randomUUID());
const client = new Client(manager);
export async function apiCall(method: string, params: Record<string, any>){
try {
const result = await client.request({method, params});
console.debug("api call result", result);
} catch (ex){
console.debug("api call epic fail", ex);
}
}

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
const props = defineModel<{
columns?: Record<string, string>,
data?: Record<string, any>[],
sortSelf?: boolean,
sortBy?: string,
sortDesc?: boolean,
}>();
let { columns, data, sortSelf, sortBy, sortDesc } = $(props);
const displayData = $computed(() => (sortSelf && sortBy !== '')
? data?.sort((a, b) => {
let result;
if (a[sortBy ?? ''] > b[sortBy ?? '']) result = 1;
else if (a[sortBy ?? ''] === b[sortBy ?? '']) result = 0;
else result = -1;
if (sortDesc) return -result;
return result;
})
: data);
function toggleSorting(columnName: string) {
if (columnName === sortBy) sortDesc = !sortDesc;
else {
sortDesc = false;
sortBy = columnName;
}
}
</script>
<template>
<table>
<thead>
<tr>
<th v-for="[name, heading] in Object.entries(columns ?? {})" :key="name" @click="toggleSorting(name)">
<div class="flex-row">
{{ heading }}
<i-mdi-arrow-down v-if="name === sortBy && sortDesc"/>
<i-mdi-arrow-up v-else-if="name === sortBy"/>
</div>
</th>
</tr>
</thead>
<tbody>
<!-- eslint-disable-next-line vue/require-v-for-key -->
<tr v-for="row of displayData">
<td v-for="cell in row" :key="cell" v-text="cell"/>
</tr>
</tbody>
</table>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
const { title, noSpacer } = $(withDefaults(defineProps<{
title?: string,
noSpacer?: boolean,
}>(), {
title: "",
noSpacer: false,
}));
watchEffect(() => useTitle(`${title} - nfSense`));
</script>
<template>
<Portal to="page-header">
<h1 v-if="title !== ''" v-text="title" :class="{'flex-grow': !noSpacer}"/>
<slot/>
</Portal>
</template>

View file

@ -0,0 +1,25 @@
<script lang="ts">
let activeTargets = $ref<string[]>([]);
</script>
<script setup lang="ts">
const props = $defineProps<{
from?: string,
to?: string,
}>();
const { from, to } = $(props);
if (from) {
onMounted(() => activeTargets.push(from));
onBeforeUnmount(() => activeTargets.splice(activeTargets.indexOf(from), 1));
}
</script>
<template>
<div v-if="from" :id="'portal-' + from">
<slot/>
</div>
<Teleport v-else-if="to && activeTargets.includes(to)" :to="'#portal-' + to">
<slot/>
</Teleport>
</template>

View file

@ -0,0 +1,6 @@
/* Atomic Styles */
.text-select { user-select: text; }
.pad { padding: 0.5rem; }
.gap { gap: 0.5rem; }
.flex-grow { flex-grow: 1; }
.flex-row { flex-direction: row; }

View file

@ -0,0 +1,34 @@
/* Coloring */
:root {
/* Color Definitions */
--cl-md-50: #FAFAFA;
--cl-md-100: #F5F5F5;
--cl-md-200: #EEEEEE;
--cl-md-300: #E0E0E0;
--cl-md-400: #BDBDBD;
--cl-md-500: #9E9E9E;
--cl-md-600: #757575;
--cl-md-700: #616161;
--cl-md-800: #424242;
--cl-md-900: #212121;
/* Color Uses */
--cl-bg: var(--cl-md-900);
--cl-bg-mid: var(--cl-md-800);
--cl-bg-low: var(--cl-md-700);
--cl-fg: var(--cl-md-100);
/* Apply as default */
background-color: var(--cl-bg);
color: var(--cl-fg);
}
/* Changes for light mode */
@media screen and (prefers-color-scheme: light) {
:root {
--cl-bg: var(--cl-md-100);
--cl-bg-mid: var(--cl-md-200);
--cl-bg-low: var(--cl-md-300);
--cl-fg: var(--cl-md-900);
}
}

View file

@ -0,0 +1,75 @@
/* CSS Components */
button, .button {
all: unset;
display: flex;
flex-flow: row nowrap;
overflow: hidden;
vertical-align: center;
padding: 0.5rem;
gap: 0.5rem;
cursor: pointer;
align-items: center;
justify-content: center;
white-space: nowrap;
background-color: var(--cl-bg-low);
}
.button > svg, button > svg {
min-width: 1.5rem;
min-height: 1.5rem;
}
.button:hover, button:hover {
background-color: var(--cl-bg-mid);
}
form {
display: grid;
grid-template-columns: auto 1fr;
padding: 0.5rem;
gap: 0.5rem;
background-color: var(--cl-bg-low);
}
form > :is(button, .button, h1) {
grid-column: 1 / 3;
}
form button, form .button {
background-color: var(--cl-bg);
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background-color: var(--cl-bg-low);
}
th:hover {
background-color: var(--cl-bg-mid);
cursor: pointer;
}
th, td {
padding: 0.5rem;
border: 0.125rem solid var(--cl-bg-mid);
}
th > *{
justify-content: center;
align-items: center;
}
th svg {
height: 1rem;
}
tbody tr:nth-child(even) {
background-color: var(--cl-bg-mid)
}
.search-bar {
display: block;
padding: 0.4rem;
background-color: var(--cl-bg-low);
color: inherit;
border: 1px solid var(--cl-fg);
}

View file

@ -0,0 +1,33 @@
/* MLFE: Marginless FlexEverything (A CSS Reset for creating Layouts) */
:root {
font-family: sans-serif;
line-height: 1;
user-select: none;
}
* {
box-sizing: border-box;
padding: 0px;
margin: 0px;
user-select: inherit;
color: inherit;
overflow: hidden;
}
div, ul, ol, nav {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
h1 { font-size: calc(1rem + calc(1rem / 1))}
h2 { font-size: calc(1rem + calc(1rem / 2))}
h3 { font-size: calc(1rem + calc(1rem / 3))}
h4 { font-size: calc(1rem + calc(1rem / 4))}
h5 { font-size: calc(1rem + calc(1rem / 5))}
h6 { font-size: calc(1rem + calc(1rem / 6))}
ul, ol {
gap: 0.5rem;
padding-left: 1rem;
}

View file

@ -0,0 +1,6 @@
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease-out !important;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}

View file

@ -0,0 +1,3 @@
{
"test": "This is a test translation."
}

22
client/src/main.ts Normal file
View file

@ -0,0 +1,22 @@
import './global-styles/atomic.css';
import './global-styles/components.css';
import './global-styles/colors.css';
import './global-styles/mlfe.css';
import './global-styles/transitions.css';
import App from './App.vue';
import { createHead } from '@vueuse/head';
import { createRouter, createWebHistory } from 'vue-router';
import routes from '~pages';
const app = createApp(App);
const head = createHead();
const router = createRouter({
history: createWebHistory(),
routes,
});
app.use(router);
app.use(head);
app.mount('#app');

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
const props = $defineProps<{entity: string, id: string}>();
const { entity, id } = $(props);
const pageTypes: { [key: string]: any } = {
"rules": { title: "Rules" },
"addresses": { title: "Addresses"},
};
</script>
<template>
<div>
<PageHeader :title="pageTypes[entity].title"/>
{{ entity }} {{ id }}
</div>
</template>

View file

@ -0,0 +1,90 @@
<script setup lang="ts">
const props = $defineProps<{entity: string}>();
const { entity } = $(props);
const pageTypes: { [key: string]: any } = {
"rules": { title: "Rules" },
"addresses": { title: "Addresses"},
};
let searchTerm = $ref("");
</script>
<template>
<div>
<PageHeader :title="pageTypes[entity].title">
<input class="search-bar" placeholder="Search..." v-model="searchTerm"/>
<button>
<i-material-symbols-add/>
</button>
</PageHeader>
<NiceTable :columns="{fname: 'First Name', lname: 'Last Name'}" :sort-self="true" :data="[
{
fname: 'Haynes',
lname: 'Chavez'
}, {
fname: 'Brennan',
lname: 'Bradley'
}, {
fname: 'Blanchard',
lname: 'Thornton'
}, {
fname: 'Benjamin',
lname: 'Nash'
}, {
fname: 'Jan',
lname: 'Bradford'
}, {
fname: 'Zelma',
lname: 'Spears'
}, {
fname: 'Freeman',
lname: 'Page'
}, {
fname: 'Wilson',
lname: 'Carlson'
}, {
fname: 'Lewis',
lname: 'Fuentes'
}, {
fname: 'Vega',
lname: 'Villarreal'
}, {
fname: 'Carolyn',
lname: 'Cardenas'
}, {
fname: 'Angie',
lname: 'Adams'
}, {
fname: 'Richards',
lname: 'Leon'
}, {
fname: 'Velma',
lname: 'Fields'
}, {
fname: 'Witt',
lname: 'Lowe'
}, {
fname: 'Waters',
lname: 'Leblanc'
}, {
fname: 'Henry',
lname: 'Lloyd'
}, {
fname: 'Boone',
lname: 'Greer'
}, {
fname: 'Willis',
lname: 'Stark'
}, {
fname: 'Dickson',
lname: 'Spencer'
}
].filter(x => (`${x.fname} ${x.lname}`).toLowerCase().includes(searchTerm.toLowerCase()))"/>
</div>
</template>
<style scoped>
.page-content {
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,4 @@
<PageHeader title="Help"/>
## About
nfSense is a web-based configuration tool for nfTables. It works by storing its configuration in a .json file, and applies it by transforming it into a proper nfTables config.

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import { apiCall } from "../api";
async function doShit(){
apiCall("Firewall.GetForwardRules", {});
}
</script>
<template>
<div>
<PageHeader title="Dashboard">
<button @click="doShit">Example Buttons</button>
</PageHeader>
This is the main page, currently written in markdown because that's *pog*.
</div>
</template>

7
client/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
/// <reference types="unplugin-icons/types/vue" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

34
client/tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ESNext",
"lib": ["DOM", "ESNext"],
"strict": true,
"esModuleInterop": true,
"jsx": "preserve",
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"types": [
"vite/client",
"vue/ref-macros",
"vite-plugin-pages/client",
"unplugin-vue-macros/macros-global"
],
"paths": {
"~/*": ["src/*"]
}
},
"vueCompilerOptions": {
"plugins": [
"@vue-macros/volar/define-model",
"@vue-macros/volar/define-slots"
]
},
"exclude": ["dist", "node_modules", "cypress"]
}

View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

101
client/vite.config.ts Normal file
View file

@ -0,0 +1,101 @@
import { defineConfig } from 'vite';
import Vue from '@vitejs/plugin-vue';
import Pages from 'vite-plugin-pages';
import Markdown from 'vite-plugin-vue-markdown';
import Components from 'unplugin-vue-components/vite';
import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver';
import I18N from '@intlify/unplugin-vue-i18n/vite';
import Macros from 'unplugin-vue-macros/vite';
import AutoImport from 'unplugin-auto-import/vite';
import Shiki from 'markdown-it-shiki';
import LinkAttributes from 'markdown-it-link-attributes';
// https://vitejs.dev/config/
export default defineConfig({
server: {
"proxy": {
"/api": "http://localhost:8080",
"/ws": {
target: "ws://localhost:8080",
ws: true,
},
},
},
plugins: [
Macros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
reactivityTransform: true,
}),
},
}),
Pages({
extensions: ['vue', 'md'],
}),
Markdown({
wrapperClasses: 'prose prose-sm m-auto text-left',
headEnabled: true,
markdownItSetup(md) {
md.use(Shiki, {
theme: {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
});
md.use(LinkAttributes, {
matcher: (link: string) => /^https?:\/\//.test(link),
attrs: {
target: '_blank',
rel: 'noopener',
},
});
},
}),
Components({
extensions: ['vue', 'md'],
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
dts: 'src/generated/components.d.ts',
resolvers: [
IconsResolver(),
(componentName) => {
if (componentName === 'FocusTrap')
return { name: 'FocusTrap', from: 'focus-trap-vue' };
},
],
types: [{
from: 'focus-trap-vue',
names: ['FocusTrap'],
}],
}),
Icons({
}),
I18N({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: ['src/locales'],
}),
AutoImport({
include: [
/\.[tj]sx?$/,
/\.vue$/, /\.vue\?vue/,
/\.md$/,
],
imports: [
'vue',
'vue-router',
'vue-i18n',
'vue/macros',
'@vueuse/core',
'@vueuse/head',
],
dts: 'src/generated/auto-imports.d.ts',
dirs: ['src/composables'],
vueTemplate: true,
}),
],
});