diff --git a/README.md b/README.md index 6c13c20..a4002bb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # frontend-svelte -Copier template for a SvelteKit frontend, either as a `frontend/` part next to other parts or alone at the root of the project (`layout`): SvelteKit on Bun with Tailwind v4, ultracite (biome) and optional shadcn-svelte, served by a Bun server with SSR, as a static SPA behind `serve.ts`, or as a static SPA that the backend api serves from `frontend/build`. +Copier template for a SvelteKit frontend, either as a `frontend/` part next to other parts or alone at the root of the project (`layout`): SvelteKit on Bun with Tailwind v4, ultracite (biome) or ESLint + Prettier (`linter`) and optional shadcn-svelte, served by a Bun server with SSR, as a static SPA behind `serve.ts`, or as a static SPA that the backend api serves from `frontend/build`. -Ships `frontend/docker-compose.yml` with root-relative paths and profiles per service, `docker-compose.override.yml.example` (the `caddy` network aliases, copied by `make env`), a `Makefile` with `fmt`, `check`, `build` and `dev`, `.env.example`, `CLAUDE.md`, `.claude/rules` for Svelte and ultracite. The Svelte MCP server comes from `infra`. Applied on top of the `infra` template, `layout: parts` or `layout: single` respectively. +Ships `frontend/docker-compose.yml` with root-relative paths and profiles per service, `docker-compose.override.yml.example` (the `caddy` network aliases, copied by `make env`), a `Makefile` with `fmt`, `check`, `build` and `dev`, `.env.example`, `CLAUDE.md`, `.claude/rules` for Svelte (and ultracite with biome). The image is built in stages: `.env` is in the build context, since `$env/static/*` are inlined at build time, and stays in the builder stage; the runtime image gets `build/` and production dependencies only. The Svelte MCP server comes from `infra`. Applied on top of the `infra` template, `layout: parts` or `layout: single` respectively. ```sh copier copy --trust -d project_name="My Thing" -d serve=bun diff --git a/copier.yml b/copier.yml index 9d8124b..11fd5c2 100644 --- a/copier.yml +++ b/copier.yml @@ -37,6 +37,14 @@ ui_library: shadcn-svelte: shadcn default: none +linter: + type: str + help: Linter and formatter + choices: + Biome with the ultracite preset: biome + ESLint + Prettier (the sv create setup): eslint + default: biome + install_deps: type: bool help: bun install after generation @@ -58,4 +66,6 @@ prefix: default: "{{ 'frontend/' if layout == 'part' else '' }}" _tasks: - - "{% if _copier_operation == 'copy' and install_deps %}sh -c 'cd {{ part_dir }} && bun install'{% else %}true{% endif %}" + # prettier-plugin-tailwindcss and biome sort classes differently, the + # sources follow biome: an eslint project is formatted once, right away. + - "{% if _copier_operation == 'copy' and install_deps %}sh -c 'cd {{ part_dir }} && bun install{% if linter == 'eslint' %} && bun run fix{% endif %}'{% else %}true{% endif %}" diff --git a/template/.claude/rules/ultracite.md b/template/.claude/rules/{% if linter == 'biome' %}ultracite.md{% endif %} similarity index 100% rename from template/.claude/rules/ultracite.md rename to template/.claude/rules/{% if linter == 'biome' %}ultracite.md{% endif %} diff --git a/template/.claude/settings.json.jinja b/template/.claude/settings.json.jinja index c04b1b8..da0979e 100644 --- a/template/.claude/settings.json.jinja +++ b/template/.claude/settings.json.jinja @@ -4,7 +4,7 @@ { "hooks": [ { - "command": "{% if layout == 'part' %}cd frontend && {% endif %}bun run fix --skip=correctness/noUnusedImports", + "command": "{% if layout == 'part' %}cd frontend && {% endif %}bun run fix{% if linter == 'biome' %} --skip=correctness/noUnusedImports{% endif %}", "type": "command" } ], diff --git a/template/{{ part_dir }}/.dockerignore b/template/{{ part_dir }}/.dockerignore index 1048241..ed46f37 100644 --- a/template/{{ part_dir }}/.dockerignore +++ b/template/{{ part_dir }}/.dockerignore @@ -1,5 +1,7 @@ node_modules .svelte-kit build -.env +# .env goes into the build: $env/static/public (and static/private) are +# inlined at build time from it. It stays in the builder stage, the runtime +# image gets build/ only. Local overrides (.env.local and the like) stay out. .env.* diff --git a/template/{{ part_dir }}/Makefile.jinja b/template/{{ part_dir }}/Makefile.jinja index 724896d..8405f28 100644 --- a/template/{{ part_dir }}/Makefile.jinja +++ b/template/{{ part_dir }}/Makefile.jinja @@ -1,5 +1,5 @@ COMPOSE := {% if layout == 'part' %}cd .. && {% endif %}docker compose -{% if serve != 'api' %}IMAGE := {{ project_slug }}/frontend +{% if serve != 'api' %}IMAGE := {{ project_slug | replace('_', '-') }}/frontend {% endif %}changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null stamp = { git update-ref $(1) HEAD 2>/dev/null || true; } diff --git a/template/{{ part_dir }}/docker-compose.yml.jinja b/template/{{ part_dir }}/docker-compose.yml.jinja index c9a4675..4be1915 100644 --- a/template/{{ part_dir }}/docker-compose.yml.jinja +++ b/template/{{ part_dir }}/docker-compose.yml.jinja @@ -1,7 +1,7 @@ services: {% if serve != 'api' %} frontend: build: {{ part_dir }} - image: {{ project_slug }}/frontend + image: {{ project_slug | replace('_', '-') }}/frontend profiles: [frontend, services] restart: unless-stopped env_file: diff --git a/template/{{ part_dir }}/package.json.jinja b/template/{{ part_dir }}/package.json.jinja index a41a597..1d83f87 100644 --- a/template/{{ part_dir }}/package.json.jinja +++ b/template/{{ part_dir }}/package.json.jinja @@ -8,14 +8,18 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "dev": "vite dev", +{%- if linter == 'biome' %} "fix": "ultracite fix", "lint": "ultracite check", +{%- else %} + "fix": "prettier --write . && eslint --fix .", + "lint": "prettier --check . && eslint .", +{%- endif %} "prepare": "svelte-kit sync || echo ''", "preview": "vite preview" }, "devDependencies": { {%- set deps = { - "@biomejs/biome": "2.5.13", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.0", @@ -24,9 +28,23 @@ "svelte-check": "^4.6.0", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", - "ultracite": "7.12.0", "vite": "^8.0.16", } -%} +{%- if linter == 'biome' %}{% set _ = deps.update({ + "@biomejs/biome": "2.5.13", + "ultracite": "7.12.0", +}) %}{% else %}{% set _ = deps.update({ + "@eslint/compat": "^2.1.1", + "@eslint/js": "^10.0.1", + "eslint": "^10.12.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.23.0", + "globals": "^17.13.0", + "prettier": "^3.9.9", + "prettier-plugin-svelte": "^4.1.1", + "prettier-plugin-tailwindcss": "^0.8.1", + "typescript-eslint": "^8.71.1", +}) %}{% endif -%} {%- if frontend_adapter == 'bun' %}{% set _ = deps.update({"svelte-adapter-bun": "^1.0.1"}) %}{% else %}{% set _ = deps.update({"@sveltejs/adapter-static": "^3.0.10"}) %}{% endif -%} {%- if ui_library == 'shadcn' %}{% set _ = deps.update({ "@internationalized/date": "^3.12.0", diff --git a/template/{{ part_dir }}/src/lib/index.ts b/template/{{ part_dir }}/src/lib/index.ts deleted file mode 100644 index 7c43079..0000000 --- a/template/{{ part_dir }}/src/lib/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. -// biome-ignore-all lint/suspicious/noEmptySource: temporary diff --git a/template/{{ part_dir }}/src/lib/index.ts.jinja b/template/{{ part_dir }}/src/lib/index.ts.jinja new file mode 100644 index 0000000..5830bbc --- /dev/null +++ b/template/{{ part_dir }}/src/lib/index.ts.jinja @@ -0,0 +1,3 @@ +// place files you want to import through the `$lib` alias in this folder. +{% if linter == 'biome' %}// biome-ignore-all lint/suspicious/noEmptySource: temporary +{% endif %} \ No newline at end of file diff --git a/template/{{ part_dir }}/svelte.config.js.jinja b/template/{{ part_dir }}/svelte.config.js.jinja index 94f7031..4fa3b8c 100644 --- a/template/{{ part_dir }}/svelte.config.js.jinja +++ b/template/{{ part_dir }}/svelte.config.js.jinja @@ -5,8 +5,8 @@ import adapter from "svelte-adapter-bun"; const config = { compilerOptions: { runes: ({ filename }) => - // biome-ignore lint/performance/useTopLevelRegex: svelte default behaviour - filename.split(/[/\\]/).includes("node_modules") ? undefined : true, +{% if linter == 'biome' %} // biome-ignore lint/performance/useTopLevelRegex: svelte default behaviour +{% endif %} filename.split(/[/\\]/).includes("node_modules") ? undefined : true, }, kit: { adapter: adapter(), diff --git a/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja b/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja index ba8d1a5..82b771f 100644 --- a/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja +++ b/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja @@ -11,4 +11,6 @@ make recreate To deploy, `git pull && make deploy`. It runs `make pre-deploy`, which rebuilds the image only when something in the repository changed, then `docker compose up -d`. A deploy system with its own `up` step calls `make pre-deploy` before it and `make post-deploy` after. What was built is remembered as `refs/deploy/*` in the clone. +In the fleet, production is deployed from `infra/komodo` instead (git mode of Komodo, see `docs/architecture.md` there): Komodo clones this repo, and a layer plus `deploy.sh` in the stack's directory tag images by the hash of what they bake in and write `.env` from sops. The repo only has to stay a plain recipe: images built from `build:` in compose, `caddy/site.caddy`. `make deploy` and `refs/deploy/*` are for a checkout deployed by hand. + `make dev` runs the Vite dev server on the host, `make fmt` formats, `make check` runs svelte-check and the linter. diff --git a/template/{{ part_dir }}/biome.jsonc.jinja b/template/{{ part_dir }}/{% if linter == 'biome' %}biome.jsonc{% endif %}.jinja similarity index 100% rename from template/{{ part_dir }}/biome.jsonc.jinja rename to template/{{ part_dir }}/{% if linter == 'biome' %}biome.jsonc{% endif %}.jinja diff --git a/template/{{ part_dir }}/{% if linter == 'eslint' %}.prettierignore{% endif %} b/template/{{ part_dir }}/{% if linter == 'eslint' %}.prettierignore{% endif %} new file mode 100644 index 0000000..7a657b0 --- /dev/null +++ b/template/{{ part_dir }}/{% if linter == 'eslint' %}.prettierignore{% endif %} @@ -0,0 +1,14 @@ +# Package managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Written by copier and the infra template, not by hand +.copier/ +*.md +renovate.jsonc + +# Miscellaneous +/static/ diff --git a/template/{{ part_dir }}/{% if linter == 'eslint' %}.prettierrc{% endif %} b/template/{{ part_dir }}/{% if linter == 'eslint' %}.prettierrc{% endif %} new file mode 100644 index 0000000..15188a8 --- /dev/null +++ b/template/{{ part_dir }}/{% if linter == 'eslint' %}.prettierrc{% endif %} @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], + "tailwindStylesheet": "./src/routes/layout.css" +} diff --git a/template/{{ part_dir }}/{% if linter == 'eslint' %}eslint.config.js{% endif %}.jinja b/template/{{ part_dir }}/{% if linter == 'eslint' %}eslint.config.js{% endif %}.jinja new file mode 100644 index 0000000..049832f --- /dev/null +++ b/template/{{ part_dir }}/{% if linter == 'eslint' %}eslint.config.js{% endif %}.jinja @@ -0,0 +1,49 @@ +import { fileURLToPath } from "node:url"; +import { includeIgnoreFile } from "@eslint/compat"; +import js from "@eslint/js"; +import prettier from "eslint-config-prettier"; +import svelte from "eslint-plugin-svelte"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import ts from "typescript-eslint"; +import svelteConfig from "./svelte.config.js"; + +const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + // typescript-eslint recommends against no-undef on TypeScript projects: + // https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + "no-undef": "off", + }, + }, +{%- if ui_library == 'shadcn' %} + { + // shadcn-svelte primitives are copied in and changed upstream only. + files: ["src/lib/components/ui/**"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "svelte/no-navigation-without-resolve": "off", + }, + }, +{%- endif %} + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + languageOptions: { + parserOptions: { + extraFileExtensions: [".svelte"], + parser: ts.parser, + projectService: true, + svelteConfig, + }, + }, + } +); diff --git a/template/{{ part_dir }}/{% if serve != 'api' %}Dockerfile{% endif %}.jinja b/template/{{ part_dir }}/{% if serve != 'api' %}Dockerfile{% endif %}.jinja index 96377a5..c2512dc 100644 --- a/template/{{ part_dir }}/{% if serve != 'api' %}Dockerfile{% endif %}.jinja +++ b/template/{{ part_dir }}/{% if serve != 'api' %}Dockerfile{% endif %}.jinja @@ -1,20 +1,44 @@ {% if frontend_adapter == 'bun' -%} -FROM oven/bun:alpine +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 bun install --frozen-lockfile +RUN --mount=type=cache,target=/bun-cache bun install --frozen-lockfile + + +# .env is in the context ($env/static/* are inlined at build) and stays in +# this stage: the runtime image below gets build/ and production deps only. +FROM deps AS builder COPY . . RUN bun --bun svelte-kit sync RUN bun --bun run build + +FROM oven/bun:alpine AS runtime + +ENV NODE_ENV=production +# The cache mount only holds files; without the variable bun caches into +# ~/.bun inside the image. +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 --production + +COPY --from=builder /app/build ./build + +EXPOSE 3000 + ENTRYPOINT ["bun"] CMD ["build/index.js"] {%- else -%}