Initial template

This commit is contained in:
hh
2026-09-08 19:15:15 +02:00
commit 2ad458c018
35 changed files with 865 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.svelte-kit
build
.env
.env.*
+1
View File
@@ -0,0 +1 @@
PUBLIC_API_BASE_URL=/api
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# Env
.env
.env.*
!.env.example
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Impeccable
**/.impeccable/config.local.json
**/.impeccable/hook.cache.json
**/.impeccable/hook.pending.json
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+6
View File
@@ -0,0 +1,6 @@
## Checking commands
After writing code, always run from `frontend/`:
```shell
bun fix
bun check
```
+17
View File
@@ -0,0 +1,17 @@
COMPOSE := docker compose --project-directory ..
.PHONY: fmt check build dev
fmt:
bun run fix
check:
bun run check
bun run lint
build:
{% if serve == 'api' %} $(COMPOSE) run --rm frontend-dev sh -c "bun install && bun run build"
{% else %} $(COMPOSE) build frontend
{% endif %}
dev:
bun run dev
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite/biome/core", "ultracite/biome/svelte"],
"files": {
"includes": [
"!.claude",
"!.svelte-kit",
"!build",
"!.mcp.json",
"!.impeccable"
]
},
"javascript": { "globals": ["Bun"] },
"overrides": [
{%- if ui_library == 'shadcn' %}
{
"includes": ["src/lib/components/ui/**"],
"linter": { "enabled": false },
"assist": { "enabled": false }
},
{
"includes": ["src/lib/utils.ts"],
"linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } }
},
{%- endif %}
{
"includes": ["**/*.svelte"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": { "filenameCases": ["kebab-case", "PascalCase"] }
}
}
}
}
},
{
"includes": ["src/app.d.ts"],
"linter": { "rules": { "style": { "noNamespace": "off" } } }
}
]
}
@@ -0,0 +1,8 @@
services:
{% if serve != 'api' %} frontend:
ports:
- "127.0.0.1:3000:3000"
{% endif %} frontend-dev:
ports:
- "127.0.0.1:3000:3000"
@@ -0,0 +1,15 @@
{%- set dash = project_slug | replace('_', '-') -%}
{% if serve != 'api' -%}
services:
frontend:
networks:
caddy:
aliases:
- {{ dash }}-frontend
networks:
caddy:
external: true
{%- else -%}
services: {}
{%- endif %}
@@ -0,0 +1,34 @@
services:
{% if serve != 'api' %} frontend:
build: frontend
image: {{ project_slug }}/frontend
profiles: [frontend, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: frontend/.env
required: false
command: {% if serve == 'bun' %}[build/index.js]{% else %}[run, serve.ts]{% endif %}
{% endif %} frontend-dev:
image: oven/bun:alpine
profiles: [frontend-dev]
restart: unless-stopped
working_dir: /app
env_file:
- path: .env
required: false
- path: frontend/.env
required: false
environment:
API_PROXY_TARGET: http://api:8080
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
- frontend_svelte_kit:/app/.svelte-kit
command: [sh, -c, "bun install && bun run dev --host 0.0.0.0 --port 3000"]
volumes:
frontend_node_modules:
frontend_svelte_kit:
+41
View File
@@ -0,0 +1,41 @@
{
"name": "{{ project_slug }}",
"type": "module",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "ultracite check",
"fix": "ultracite fix"
},
"devDependencies": {
"@biomejs/biome": "2.5.2",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@types/bun": "^1.3.0",
{% if frontend_adapter == 'bun' %} "svelte-adapter-bun": "^1.0.1",
{% else %} "@sveltejs/adapter-static": "^3.0.10",
{% endif %}{% if ui_library == 'shadcn' %} "@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.23.0",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
"mode-watcher": "^1.1.0",
"shadcn-svelte": "^1.3.0",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0",
{% endif %} "svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"ultracite": "7.9.0",
"vite": "^8.0.16"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta content="scale" name="text-scale">
<link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
import { env } from "$env/dynamic/public";
const BASE = env.PUBLIC_API_BASE_URL ?? "/api";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
type Json = Record<string, unknown> | unknown[];
async function handle<T>(res: Response): Promise<T> {
if (res.status === 204) {
return undefined as T;
}
if (res.ok) {
return (await res.json()) as T;
}
let detail = res.statusText;
try {
const { detail: parsed } = (await res.json()) as { detail?: string };
if (parsed) {
detail = parsed;
}
} catch {
detail = res.statusText;
}
throw new ApiError(res.status, detail);
}
async function request<T>(
method: string,
path: string,
body?: Json
): Promise<T> {
const init: RequestInit = { credentials: "include", method };
if (body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body);
}
const res = await fetch(BASE + path, init);
return handle<T>(res);
}
export const api = {
del: <T>(path: string) => request<T>("DELETE", path),
get: <T>(path: string) => request<T>("GET", path),
patch: <T>(path: string, body?: Json) => request<T>("PATCH", path, body),
post: <T>(path: string, body?: Json) => request<T>("POST", path, body),
put: <T>(path: string, body?: Json) => request<T>("PUT", path, body),
};
+2
View File
@@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
// biome-ignore-all lint/suspicious/noEmptySource: temporary
@@ -0,0 +1,15 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
@@ -0,0 +1,12 @@
<script lang="ts">
import "./layout.css";
{%- if ui_library == 'shadcn' %}
import { ModeWatcher } from "mode-watcher";
{%- endif %}
let { children } = $props();
</script>
{% if ui_library == 'shadcn' %}<ModeWatcher />
{% endif %}{@render children()}
@@ -0,0 +1,13 @@
<svelte:head>
<title>{{ project_name }}</title>
</svelte:head>
<main
class="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center {% if ui_library == 'shadcn' %}bg-background text-foreground{% else %}bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50{% endif %}"
>
<h1 class="font-bold text-4xl tracking-tight sm:text-5xl">{{ project_name }}</h1>
<p class="max-w-prose text-base {% if ui_library == 'shadcn' %}text-muted-foreground{% else %}text-neutral-500{% endif %}">
Edit
<code class="font-mono">src/routes/+page.svelte</code>.
</p>
</main>
@@ -0,0 +1,129 @@
{% if ui_library == 'shadcn' -%}
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-svelte/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
{%- else -%}
@import "tailwindcss";
@theme {
--font-sans:
system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
html,
body {
font-family: var(--font-sans);
}
button:not(:disabled),
[role="button"]:not(:disabled),
a {
cursor: pointer;
}
{%- endif %}
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
+36
View File
@@ -0,0 +1,36 @@
{% if frontend_adapter == 'bun' -%}
import adapter from "svelte-adapter-bun";
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
runes: ({ filename }) =>
// biome-ignore lint/performance/useTopLevelRegex: svelte default behaviour
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
},
kit: {
adapter: adapter(),
},
};
export default config;
{%- else -%}
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
assets: "build",
fallback: "index.html",
pages: "build",
precompress: false,
strict: false,
}),
},
preprocess: vitePreprocess(),
};
export default config;
{%- endif %}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+15
View File
@@ -0,0 +1,15 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
"/api": {
changeOrigin: true,
target: process.env.API_PROXY_TARGET ?? "http://localhost:8080",
},
},
},
});
@@ -0,0 +1,55 @@
{% if frontend_adapter == 'bun' -%}
FROM oven/bun:alpine
ENV TERM=xterm-256color
ENV COLORTERM=truecolor
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun --bun svelte-kit sync
RUN bun --bun run build
ENTRYPOINT ["bun"]
CMD ["build/index.js"]
{%- else -%}
FROM oven/bun:alpine AS deps
ENV TERM=xterm-256color
ENV COLORTERM=truecolor
ENV BUN_INSTALL_CACHE_DIR=/bun-cache
WORKDIR /app
COPY package.json bun.lock* ./
RUN --mount=type=cache,target=/bun-cache bun install --frozen-lockfile
FROM deps AS builder
COPY . .
RUN bun --bun svelte-kit sync
RUN bun run build
FROM oven/bun:alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/serve.ts ./serve.ts
EXPOSE 3000
ENTRYPOINT ["bun"]
CMD ["run", "serve.ts"]
{%- endif %}
@@ -0,0 +1,36 @@
const ROOT = `${import.meta.dir}/build`;
const PORT = Number(Bun.env.PORT ?? 3000);
const IMMUTABLE = "/_app/immutable/";
const fallback = Bun.file(`${ROOT}/index.html`);
function headersFor(pathname: string): HeadersInit {
if (pathname.startsWith(IMMUTABLE)) {
return { "Cache-Control": "public, max-age=31536000, immutable" };
}
return { "Cache-Control": "no-cache" };
}
Bun.serve({
async fetch(request) {
const { pathname } = new URL(request.url);
if (pathname.includes("..")) {
return new Response("Bad Request", { status: 400 });
}
const file = Bun.file(`${ROOT}${pathname}`);
if (await file.exists()) {
return new Response(file, { headers: headersFor(pathname) });
}
return new Response(fallback, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/html; charset=utf-8",
},
});
},
idleTimeout: 30,
port: PORT,
});
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "luma",
"iconLibrary": "lucide",
"menuColor": "default-translucent",
"menuAccent": "subtle"
}