├── .gitignore ├── .gitpod.yml ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── context └── svelte-5.md ├── e2e └── test.ts ├── eslint.config.js ├── mdsvex.config.mjs ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── scripts ├── choose ├── choose.ts ├── css-transform.ts ├── domshot.ts ├── lib │ ├── cli.test.ts │ ├── cli.ts │ ├── spinner.ts │ └── utils.oops.ts └── parse.ts ├── shiki.config.mjs ├── src ├── app.d.ts ├── app.html ├── hooks.client.ts ├── hooks.server.ts ├── hooks.ts.md ├── lib │ ├── actions │ │ ├── click-outside.ts │ │ ├── focus.ts │ │ ├── hover.ts │ │ ├── index.ts │ │ ├── trap.ts │ │ └── utils.ts │ ├── components │ │ ├── Dropdown.svelte │ │ ├── FontToggle.svelte │ │ ├── Gooey.svelte │ │ ├── Hello.svelte │ │ ├── Hero.svelte │ │ ├── HoverMenu.svelte │ │ ├── Icon.svelte │ │ ├── Icons.svelte │ │ ├── ModalOverlay.svelte │ │ ├── Page.svelte │ │ ├── PageTitle.svelte │ │ ├── Shell.svelte │ │ ├── ThemeSwitch.svelte │ │ ├── header │ │ │ ├── Header.svelte │ │ │ ├── Logo.svelte │ │ │ └── navs │ │ │ │ ├── Mobile │ │ │ │ ├── Burger.svelte │ │ │ │ └── PageFill.svelte │ │ │ │ ├── NavDesktop.svelte │ │ │ │ └── NavMobile.svelte │ │ └── nav │ │ │ ├── MobileMenu.svelte │ │ │ ├── MobileMenuCool.svelte │ │ │ ├── MobileSubMenu.svelte │ │ │ ├── Nav.Links.svelte │ │ │ ├── Nav.Menu.svelte │ │ │ ├── Nav.Mobile.svelte │ │ │ ├── Nav.svelte │ │ │ ├── PreloadingIndicator.svelte │ │ │ ├── SkipLink.svelte │ │ │ ├── index.ts │ │ │ └── nav_state.svelte.ts │ ├── icons │ │ ├── arrow-left.svg │ │ ├── bluesky-dark.svg │ │ ├── bluesky-light.svg │ │ ├── bluesky.svg │ │ ├── chevron.svg │ │ ├── contents.svg │ │ ├── delete.svg │ │ ├── discord-dark.svg │ │ ├── discord-light.svg │ │ ├── download-dark.svg │ │ ├── download-light.svg │ │ ├── external.svg │ │ ├── file-dark.svg │ │ ├── file-edit-inline-dark.svg │ │ ├── file-edit.svg │ │ ├── file-new.svg │ │ ├── file.svg │ │ ├── folder-new.svg │ │ ├── folder-open.svg │ │ ├── folder.svg │ │ ├── github-dark.svg │ │ ├── github-light.svg │ │ ├── github.svg │ │ ├── lightbulb.svg │ │ ├── refresh.svg │ │ ├── rename.svg │ │ ├── terminal.svg │ │ ├── theme-dark.svg │ │ ├── theme-light.svg │ │ ├── user-dark.svg │ │ └── user-light.svg │ ├── router │ │ ├── index.ts │ │ ├── router.svelte.ts │ │ ├── router.types.ts │ │ └── routes.test.ts │ ├── routes.ts │ ├── routes.types.ts │ ├── themer │ │ ├── resolveTheme.ts │ │ ├── themer.svelte.ts │ │ ├── themer.types.ts │ │ └── themes │ │ │ └── themes.ts │ └── utils │ │ ├── ansi │ │ ├── ansi-hex.ts │ │ ├── ansi-logger.ts │ │ ├── ansi-mini.ts │ │ ├── ansi.bench.ts │ │ ├── index.ts │ │ └── package.json │ │ ├── deep-merge.ts │ │ ├── defer.ts │ │ ├── device.svelte.ts │ │ ├── hexToRgb.ts │ │ ├── logger │ │ ├── css-colors.ts │ │ ├── index.ts │ │ ├── logger-colors.ts │ │ └── logger.ts │ │ ├── prettify.ts │ │ ├── stringify.ts │ │ ├── tldr.ts │ │ ├── transitions.ts │ │ └── ua.ts ├── routes │ ├── (dev) │ │ ├── +layout.server.ts │ │ └── playground │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ └── misc │ │ │ ├── +page.svelte │ │ │ └── nested │ │ │ └── +page.svelte │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.svelte │ ├── about │ │ └── +page.svelte │ ├── design │ │ ├── +page.svelte │ │ ├── elements │ │ │ └── +page.svelte │ │ └── inputs │ │ │ └── +page.svelte │ └── index.test.ts └── styles │ ├── animations.scss │ ├── app.scss │ ├── code.scss │ ├── dimensions.scss │ ├── elements.scss │ ├── font.scss │ ├── inputs.scss │ ├── reset.scss │ ├── shadows.scss │ ├── theme.scss │ ├── utils.scss │ └── view-transitions.scss ├── static ├── Screenshot 2024-09-24 at 7.44.09 PM 1.png ├── Screenshot 2024-09-24 at 7.44.09 PM 2.png ├── Screenshot 2024-09-24 at 7.48.41 PM 1.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── assets │ ├── noise-361x370.png │ └── svelte-starter.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── fonts │ ├── inconsolata │ │ ├── OFL.txt │ │ └── inconsolata.ttf │ ├── readme.md │ └── red_hat_text │ │ ├── OFL.txt │ │ ├── red_hat_text-italic.ttf │ │ └── red_hat_text.ttf ├── icons │ ├── check-dark.svg │ ├── check-light.svg │ ├── chevron.svg │ ├── copy-to-clipboard-dark.svg │ ├── copy-to-clipboard-light.svg │ ├── document-dark.svg │ ├── document-light.svg │ ├── font-accessible-dark.svg │ ├── font-accessible-light.svg │ ├── font-boring-dark.svg │ ├── font-boring-light.svg │ ├── font-elegant-dark.svg │ ├── font-elegant-light.svg │ ├── hash-dark.svg │ ├── hash-light.svg │ ├── home.svg │ ├── link.svg │ ├── menu.svg │ └── search.svg ├── mstile-150x150.png ├── robots.txt └── site.webmanifest ├── svelte.config.js ├── tsconfig.json ├── vercel.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .svelte-kit 3 | functions 4 | 5 | package 6 | .vercel 7 | build 8 | 9 | .env 10 | .env.* 11 | !.env.example 12 | 13 | vite.config.js.timestamp-* 14 | vite.config.ts.timestamp-* 15 | 16 | .DS_Store -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | tasks: 8 | - init: pnpm install && pnpm run build 9 | command: pnpm run dev 10 | 11 | 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=false 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .svelte-kit/** 2 | static/** 3 | build/** 4 | node_modules/** 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "svelteSortOrder": "options-scripts-markup-styles", 3 | "htmlWhitespaceSensitivity": "css", 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "printWidth": 120, 7 | "useTabs": true, 8 | "tabWidth": 4, 9 | "semi": false, 10 | "arrowParens": "avoid", 11 | "plugins": ["prettier-plugin-svelte"], 12 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | /* 4 | * Important extentions. 5 | */ 6 | "christian-kohler.path-intellisense", 7 | "aaron-bond.better-comments", 8 | "ethansk.restore-terminals", 9 | "svelte.svelte-vscode", 10 | "unifiedjs.vscode-mdx" // mdsvex 11 | 12 | /* 13 | * Honorable mentions. 14 | */ 15 | // "github.vscode-pull-request-github", 16 | // "ardenivanov.svelte-intellisense", 17 | // "ziyasal.vscode-open-in-github", 18 | // "pflannery.vscode-versionlens", 19 | // "wayou.vscode-todo-highlight", 20 | // "alefragnani.project-manager", 21 | // "ms-vsliveshare.vsliveshare", 22 | // "pkief.material-icon-theme", 23 | // "zokugun.explicit-folding", 24 | // "naumovs.color-highlight", 25 | // "stevencl.adddoccomments", 26 | // "inu1255.easy-snippet", 27 | // "anseki.vscode-color", 28 | // "vsls-contrib.gistfs", 29 | // "mhutchie.git-graph", 30 | // "henoc.svgeditor" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "restoreTerminals.terminals": [ 3 | { 4 | "splitTerminals": [ 5 | { 6 | "name": "server", 7 | "commands": ["pnpm i && pnpm dev"] 8 | }, 9 | { 10 | "name": "svelte-check", 11 | "commands": ["pnpm check:watch"] 12 | }, 13 | { 14 | "name": "vitest ui", 15 | "commands": ["pnpm test"] 16 | } 17 | ] 18 | } 19 | ], 20 | 21 | "css.lint.vendorPrefix": "ignore", 22 | "scss.lint.vendorPrefix": "ignore", 23 | 24 | "files.exclude": { 25 | "**/.git": true 26 | }, 27 | 28 | "files.associations": { 29 | "[*.md, *.svx, *.mdsvex]": "mdx", //* https://github.com/pngwn/MDsveX/issues/121#issuecomment-675106929 30 | "*.xml": "html", //* Prettier 31 | "*.svg": "html" //* Prettier 32 | }, 33 | 34 | "editor.formatOnSave": false, 35 | "editor.defaultFormatter": "esbenp.prettier-vscode", 36 | 37 | //* Workbench 38 | "workbench.iconTheme": "material-icon-theme", 39 | "workbench.editor.labelFormat": "short", 40 | 41 | //* More comment tags. 42 | "better-comments.tags": [ 43 | { 44 | "tag": "·", 45 | "color": "#374fa6", 46 | "bold": true 47 | }, 48 | { 49 | "tag": "⌟", 50 | "color": "#002255", 51 | "bold": true 52 | }, 53 | { 54 | "tag": "!", 55 | "color": "#886f50" 56 | }, 57 | { 58 | "tag": "?", 59 | "color": "#3498DB" 60 | }, 61 | { 62 | "tag": "//", 63 | "color": "#474747", 64 | "strikethrough": true, 65 | "backgroundColor": "transparent" 66 | }, 67 | { 68 | "tag": "todo", 69 | "color": "#FF8C00" 70 | }, 71 | { 72 | "tag": "*", 73 | "color": "#98C379" 74 | } 75 | ], 76 | 77 | //* Svelte 78 | "[svelte]": { 79 | "editor.defaultFormatter": "svelte.svelte-vscode" 80 | }, 81 | "svelte.enable-ts-plugin": true, 82 | "svelte.plugin.css.globals": "src/styles/theme.scss, src/styles/app.scss", 83 | "svelte.plugin.svelte.compilerWarnings": { 84 | "element_invalid_self_closing_tag": "ignore", 85 | // "a11y_aria_attributes": "ignore", 86 | // "a11y_incorrect_aria_attribute_type": "ignore", 87 | // "a11y_unknown_aria_attribute": "ignore", 88 | // "a11y_hidden": "ignore", 89 | // "a11y_misplaced_role": "ignore", 90 | // "a11y_unknown_role": "ignore", 91 | // "a11y_no_abstract_role": "ignore", 92 | // "a11y_no_redundant_roles": "ignore", 93 | // "a11y_role_has_required_aria_props": "ignore", 94 | // "a11y_accesskey": "ignore", 95 | // "a11y_autofocus": "ignore", 96 | // "a11y_misplaced_scope": "ignore", 97 | // "a11y_positive_tabindex": "ignore", 98 | // "a11y_invalid_attribute": "ignore", 99 | // "a11y_missing_attribute": "ignore", 100 | // "a11y_img_redundant_alt": "ignore", 101 | // "a11y_label_has_associated_control": "ignore", 102 | // "a11y_media_has_caption": "ignore", 103 | // "a11y_distracting_elements": "ignore", 104 | // "a11y_structure": "ignore", 105 | // "a11y_mouse_events_have_key_events": "ignore", 106 | // "a11y_missing_content": "ignore", 107 | // "a11y_click_events_have_key_events": "ignore", 108 | "a11y_no_static_element_interactions": "ignore" 109 | // "a11y_no_noninteractive_element_interactions": "ignore" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [svelte-starter](https://github.com/braebo/svelte-starter) 2 | 3 | A minimal, opinionated Sveltekit starter-template. 4 | 5 | demo · https://svelte-starter.braebo.dev 6 | 7 | ### init 8 | 9 | ```bash 10 | npx degit braebo/svelte-starter my-app 11 | ``` 12 | 13 | ### run 14 | 15 | ```bash 16 | cd my-app 17 | pnpm install 18 | pnpm dev 19 | ``` 20 | 21 | ### meta 22 | 23 | - Generate static files with [realfavicongenerator](https://realfavicongenerator.net/) and drop the result into [`/static`](./static) to replace content like icons / pwa metadata with your own. 24 | 25 | - Generate metatags with [metatags.io](https://metatags.io/) to overwrite the existing ones in [`/src/app.html`](./src/app.html). 26 | 27 | ### some features 28 | 29 | - design system 30 | - dynamic themer 31 | - [samplekit's](https://preprocessors.samplekit.dev/docs/code-decoration/) [shiki](https://shiki.style) [preprocessor](https://github.com/samplekit/preprocess-shiki) 32 | - [mdsvex](https://mdsvex.com) 33 | - common utils 34 | - typescript 35 | - [scss](https://sass-lang.com) 36 | - [autoprefixer](https://github.com/postcss/autoprefixer) 37 | - local font setup 38 | - mobile nav 39 | -------------------------------------------------------------------------------- /e2e/test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test('index page has expected h1', async ({ page }) => { 4 | await page.goto('/') 5 | expect(await page.textContent('h1')).toBe('Hello 🌎') 6 | }) 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier' 2 | import svelte from 'eslint-plugin-svelte' 3 | import ts from 'typescript-eslint' 4 | import globals from 'globals' 5 | 6 | export default ts.config( 7 | ...ts.configs.recommended, 8 | ...svelte.configs['flat/recommended'], 9 | prettier, 10 | ...svelte.configs['flat/prettier'], 11 | { 12 | languageOptions: { 13 | globals: { 14 | ...globals.browser, 15 | ...globals.node, 16 | }, 17 | }, 18 | }, 19 | { 20 | files: ['**/*.svelte'], 21 | languageOptions: { 22 | parserOptions: { 23 | parser: ts.parser, 24 | }, 25 | }, 26 | }, 27 | { 28 | ignores: ['build/', '.svelte-kit/', 'dist/'], 29 | }, 30 | { 31 | rules: { 32 | '@typescript-eslint/no-unused-expressions': 'off', 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | }, 35 | }, 36 | ) 37 | -------------------------------------------------------------------------------- /mdsvex.config.mjs: -------------------------------------------------------------------------------- 1 | // https://mdsvex.com/docs#options 2 | 3 | import autolinkHeadings from 'rehype-autolink-headings' 4 | import slug from 'rehype-slug' 5 | import abbr from 'remark-abbr' 6 | 7 | /** @type {import('mdsvex').MdsvexOptions} */ 8 | const mdsvexConfig = { 9 | extensions: ['.md'], 10 | smartypants: { 11 | dashes: 'oldschool', 12 | }, 13 | remarkPlugins: [abbr], 14 | rehypePlugins: [ 15 | slug, 16 | [ 17 | autolinkHeadings, 18 | { 19 | behavior: 'wrap', 20 | }, 21 | ], 22 | ], 23 | } 24 | 25 | export default mdsvexConfig 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-starter", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Opinionated SvelteKit starter template.", 6 | "keywords": [ 7 | "sveltekit", 8 | "template", 9 | "starter", 10 | "svelte", 11 | "kit" 12 | ], 13 | "type": "module", 14 | "scripts": { 15 | "build": "vite build", 16 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --compiler-warnings 'element_invalid_self_closing_tag:ignore,a11y_aria_attributes:ignore,a11y_incorrect_aria_attribute_type:ignore,a11y_unknown_aria_attribute:ignore,a11y_hidden:ignore,a11y_misplaced_role:ignore,a11y_unknown_role:ignore,a11y_no_abstract_role:ignore,a11y_no_redundant_roles:ignore,a11y_role_has_required_aria_props:ignore,a11y_accesskey:ignore,a11y_autofocus:ignore,a11y_misplaced_scope:ignore,a11y_positive_tabindex:ignore,a11y_invalid_attribute:ignore,a11y_missing_attribute:ignore,a11y_img_redundant_alt:ignore,a11y_label_has_associated_control:ignore,a11y_media_has_caption:ignore,a11y_distracting_elements:ignore,a11y_structure:ignore,a11y_mouse_events_have_key_events:ignore,a11y_missing_content:ignore,a11y_click_events_have_key_events:ignore,a11y_no_static_element_interactions:ignore,a11y_no_noninteractive_element_interactions:ignore,element_invalid_self_closing_tag:ignore'", 17 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch --compiler-warnings 'element_invalid_self_closing_tag:ignore,a11y_aria_attributes:ignore,a11y_incorrect_aria_attribute_type:ignore,a11y_unknown_aria_attribute:ignore,a11y_hidden:ignore,a11y_misplaced_role:ignore,a11y_unknown_role:ignore,a11y_no_abstract_role:ignore,a11y_no_redundant_roles:ignore,a11y_role_has_required_aria_props:ignore,a11y_accesskey:ignore,a11y_autofocus:ignore,a11y_misplaced_scope:ignore,a11y_positive_tabindex:ignore,a11y_invalid_attribute:ignore,a11y_missing_attribute:ignore,a11y_img_redundant_alt:ignore,a11y_label_has_associated_control:ignore,a11y_media_has_caption:ignore,a11y_distracting_elements:ignore,a11y_structure:ignore,a11y_mouse_events_have_key_events:ignore,a11y_missing_content:ignore,a11y_click_events_have_key_events:ignore,a11y_no_static_element_interactions:ignore,a11y_no_noninteractive_element_interactions:ignore,element_invalid_self_closing_tag:ignore'", 18 | "dev": "vite dev", 19 | "extras": "pnpm i -D type-fest froebel nanostores rxjs", 20 | "format": "prettier --write --plugin-search-dir=. .", 21 | "postinstall": "svelte-kit sync", 22 | "lint": "prettier --check . --plugin-search-dir .", 23 | "preview": "vite preview", 24 | "test": "vitest --ui", 25 | "test:e2e": "playwright test" 26 | }, 27 | "devDependencies": { 28 | "@braebo/ansi": "^0.1.0", 29 | "@playwright/test": "1.50.1", 30 | "@samplekit/preprocess-shiki": "^3.0.0", 31 | "@sveltejs/adapter-auto": "^4.0.0", 32 | "@sveltejs/kit": "^2.17.3", 33 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 34 | "@types/node": "22.13.7", 35 | "@types/remark-abbr": "^1.4.4", 36 | "@vitest/ui": "^3.0.7", 37 | "autoprefixer": "^10.4.20", 38 | "bun-types": "^1.2.4", 39 | "cookie": "^1.0.2", 40 | "eslint": "^9.21.0", 41 | "eslint-config-prettier": "^10.0.2", 42 | "eslint-plugin-svelte": "^3.0.2", 43 | "esm-env": "^1.2.2", 44 | "fractils": "3.1.3", 45 | "gooey": "^0.3.2", 46 | "mdsvex": "^0.12.3", 47 | "prettier": "3.5.2", 48 | "prettier-plugin-svelte": "^3.3.3", 49 | "rehype-autolink-headings": "7.1.0", 50 | "rehype-slug": "6.0.0", 51 | "remark-abbr": "1.4.2", 52 | "sass-embedded": "^1.85.1", 53 | "sonda": "^0.7.1", 54 | "svelte": "5.20.5", 55 | "svelte-check": "^4.1.4", 56 | "sveltekit-view-transition": "^0.5.3", 57 | "typescript": "^5.8.2", 58 | "vite": "^6.2.0", 59 | "vitest": "^3.0.7" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test' 2 | 3 | export default defineConfig({ 4 | webServer: { 5 | command: 'npm run build && npm run preview', 6 | port: 4173, 7 | }, 8 | testDir: 'e2e', 9 | }) 10 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /scripts/choose: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/svelte-starter/e5c3a0fe404c6c3323c2bc93480c34dfbc7eb2c4/scripts/choose -------------------------------------------------------------------------------- /scripts/choose.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | /** 4 | * @fileoverview 5 | * @build bun build ./scripts/choose.ts --compile --minify --outfile choose && chmod +x choose 6 | * @run echo "a b c" | ./choose 2 7 | * > b 8 | */ 9 | 10 | /// 11 | 12 | import { Cli } from './lib/cli' 13 | 14 | const cli = new Cli({ 15 | banner: 'choose', 16 | description: 'field selector', 17 | cmd: 'choose [options] field...', 18 | examples: [ 19 | 'echo "a b c" | choose 2', 20 | 'ls -l | choose 9', 21 | 'echo "1,2,3" | choose -f "," 1 3', 22 | ], 23 | args: { 24 | 'field-separator': { 25 | type: 'string', 26 | short: 'f', 27 | default: ' ', 28 | description: 'Field separator', 29 | }, 30 | help: { 31 | type: 'boolean', 32 | short: 'h', 33 | description: 'Print this message', 34 | }, 35 | }, 36 | }) 37 | 38 | const fields = cli.positionals.map(arg => { 39 | const field = parseInt(arg) 40 | if (isNaN(field)) { 41 | cli.error(`Invalid field number: ${arg}`) 42 | } 43 | return field - 1 // Convert to 0-based index 44 | }) 45 | 46 | if (fields.length === 0) { 47 | cli.error('No fields specified') 48 | } 49 | 50 | // Process stdin 51 | const chunks: Uint8Array[] = [] 52 | for await (const chunk of Bun.stdin.stream()) { 53 | chunks.push(chunk) 54 | } 55 | const input = Buffer.concat(chunks).toString('utf-8') 56 | 57 | const separator = new RegExp(cli.values['field-separator']) 58 | 59 | input 60 | .split('\n') 61 | .filter(line => line.trim()) 62 | .forEach(line => { 63 | const parts = line.split(separator) 64 | const selected = fields.map(f => parts[f] || '').filter(Boolean) 65 | if (selected.length > 0) { 66 | console.log(selected.join(' ')) 67 | } 68 | }) 69 | -------------------------------------------------------------------------------- /scripts/css-transform.ts: -------------------------------------------------------------------------------- 1 | import { l, c, d, err, g, m, r, y } from '../src/lib/utils/logger/logettes' 2 | import { readFile, writeFile, readdir } from 'node:fs/promises' 3 | import { join, relative } from 'node:path' 4 | 5 | const DRY = true 6 | const MULTIPLY_BY = 1.6 7 | const START_DIR = process.argv[2] || '.' 8 | const EXCLUDE = ['node_modules', 'dist', 'build', '.git', '.svelte-kit', 'scripts'] 9 | const REM_REGEX = /(\d*\.?\d+)rem\b/g 10 | 11 | const isExcluded = (path: string): boolean => EXCLUDE.some(excluded => path.includes(excluded)) 12 | 13 | async function* findFiles(dir: string): AsyncGenerator { 14 | const entries = await readdir(dir, { withFileTypes: true }) 15 | 16 | for (const entry of entries) { 17 | const fullPath = join(dir, entry.name) 18 | 19 | if (isExcluded(fullPath)) continue 20 | 21 | if (entry.isDirectory()) { 22 | yield* findFiles(fullPath) 23 | } else if (/\.(css|scss|svelte|ts|js|jsx|tsx)$/.test(entry.name)) { 24 | yield fullPath 25 | } 26 | } 27 | } 28 | 29 | function processContent(content: string): { 30 | result: string 31 | changes: Array<{ old: string; new: string }> 32 | } { 33 | const changes: Array<{ old: string; new: string }> = [] 34 | 35 | const result = content.replace(REM_REGEX, (match, num) => { 36 | const newValue = (parseFloat(num) * MULTIPLY_BY).toFixed(2) 37 | const newMatch = `${newValue}rem` 38 | changes.push({ old: match, new: newMatch }) 39 | return newMatch 40 | }) 41 | 42 | return { result, changes } 43 | } 44 | 45 | async function processFile(filepath: string): Promise { 46 | const content = await readFile(filepath, 'utf-8') 47 | const { result, changes } = processContent(content) 48 | 49 | if (changes.length > 0) { 50 | if (DRY) { 51 | l() 52 | l(d(relative(START_DIR, filepath))) 53 | 54 | changes.forEach(({ old, new: newVal }) => { 55 | l(` ${d(m(old))} ${d('->')} ${c(newVal)}`) 56 | }) 57 | } else { 58 | await writeFile(filepath, result) 59 | } 60 | 61 | l(d(` ${g(changes.length)} changes`)) 62 | } 63 | } 64 | 65 | async function main() { 66 | l(`${DRY ? y('DRY') : r('LIVE')} run`) 67 | 68 | try { 69 | for await (const file of findFiles(START_DIR)) { 70 | await processFile(file) 71 | } 72 | 73 | l('\nCompleted successfully!') 74 | } catch (error) { 75 | err('\nError:', error) 76 | } 77 | } 78 | 79 | main() 80 | -------------------------------------------------------------------------------- /scripts/domshot.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { chromium, type Browser } from '@playwright/test' 4 | import { l, g, d, err } from '@braebo/ansi' 5 | import { Spinner } from './lib/spinner' 6 | import { Cli } from './lib/cli' 7 | import { resolve } from 'path' 8 | 9 | const cli = new Cli({ 10 | banner: [ 11 | ' ____ __ _ _ ____ _ _ __ ____ ', 12 | '( \\ / \\ ( \\/ )/ ___)/ )( \\ / \\(_ _)', 13 | ' ) D (( O )/ \\/ \\\\___ \\) __ (( O ) )( ', 14 | '(____/ \\__/ \\_)(_/(____/\\_)(_/ \\__/ (__)', 15 | ].join('\n'), 16 | // banner: 'DOMshot', 17 | description: '📸 Screenshot DOM nodes with transparency', 18 | cmd: 'domshot', 19 | examples: ['domshot .hero -o hero.png', 'domshot .hero --url http://localhost:5174 --out hero.png'], 20 | positionals: [], 21 | args: { 22 | query: { 23 | short: 'q', 24 | default: '.example', 25 | description: 'Query selector', 26 | }, 27 | url: { 28 | type: 'string', 29 | default: 'localhost:5173', 30 | short: 'u', 31 | description: 'Destination URL', 32 | }, 33 | out: { 34 | type: 'string', 35 | default: 'domshot.png', 36 | short: 'o', 37 | description: 'Output filename', 38 | }, 39 | headless: { 40 | type: 'boolean', 41 | default: true, 42 | short: 'h', 43 | description: 'Headless mode', 44 | }, 45 | delay: { 46 | type: 'number', 47 | default: 1000, 48 | short: 'd', 49 | description: 'Delay timer (ms)', 50 | }, 51 | scale: { 52 | type: 'number', 53 | default: 1, 54 | short: 's', 55 | description: 'Scale factor', 56 | }, 57 | viewport: { 58 | type: 'string', 59 | default: '1280x720', 60 | short: 'v', 61 | description: 'Viewport size', 62 | }, 63 | animations: { 64 | short: 'a', 65 | default: 'allow' as 'allow' | 'disabled', 66 | description: 'Allow or disable animations', 67 | }, 68 | }, 69 | }) 70 | 71 | try { 72 | cli.run(async (values, _positionals) => { 73 | // const [selector] = _positionals 74 | // await takeScreenshot({ ...values, query: selector }) 75 | await takeScreenshot(values) 76 | }) 77 | } catch (e) { 78 | err(e) 79 | } 80 | 81 | // async function takeScreenshot(values: Record) { 82 | async function takeScreenshot(values: typeof cli.values) { 83 | let browser: Browser | null = null 84 | let spinner: Spinner | null = null 85 | 86 | try { 87 | const waitTime = values.delay 88 | const scaleValue = values.scale 89 | const [viewportWidth, viewportHeight] = values.viewport.split('x').map(Number) 90 | 91 | spinner = new Spinner('Spawning browser...') 92 | spinner.start() 93 | 94 | browser = await chromium.launch({ headless: values.headless }) 95 | const page = await browser.newPage() 96 | await page.setViewportSize({ width: viewportWidth, height: viewportHeight }) 97 | 98 | spinner.setText(' Loading page...') 99 | await page.goto(values.url, { waitUntil: 'networkidle' }) 100 | 101 | await spinner.countdown(waitTime) 102 | 103 | spinner.setText(' 📸') 104 | 105 | // We need to make the body transparent 106 | await page.evaluate(() => { 107 | document.body.style.backgroundColor = 'transparent' 108 | }) 109 | 110 | const element = page.locator(values.query).first() 111 | 112 | // Apply scaling via CSS transform if scale is not 1 113 | if (scaleValue !== 1) { 114 | await element.evaluate((el, scale) => { 115 | const originalTransform = getComputedStyle(el).transform 116 | const newTransform = 117 | originalTransform === 'none' ? `scale(${scale})` : `${originalTransform} scale(${scale})` 118 | el.style.transform = newTransform 119 | }, scaleValue) 120 | } 121 | 122 | await element.screenshot({ 123 | path: values.out, 124 | omitBackground: true, 125 | animations: values.animations, 126 | }) 127 | 128 | spinner.stop() 129 | 130 | l('📸', g('✔'), d(`${resolve(values.out)}`)) 131 | console.log(d('⌞')) 132 | } catch (error) { 133 | spinner?.stop(true) 134 | await browser?.close() 135 | throw error 136 | } 137 | 138 | await browser?.close() 139 | } 140 | -------------------------------------------------------------------------------- /scripts/lib/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, expectTypeOf } from 'vitest' 2 | import { Cli } from './cli' 3 | 4 | // Test the type inference 5 | const cli = new Cli({ 6 | banner: 'Foobar', 7 | description: 'A test instance of CLI running in Bun test.', 8 | cmd: 'bun test scripts/lib/cli.test.ts', 9 | examples: ['bun test scripts/lib/cli.test.ts --string "Hello, world!" -b true'], 10 | args: { 11 | string: { type: 'string', description: 'some string', default: 'test', short: 's' }, 12 | boolean: { type: 'boolean', description: 'a really cool boolean', default: false, short: 'b' }, 13 | number: { type: 'number', description: 'not a number', default: 5, short: 'n' }, 14 | }, 15 | }) 16 | 17 | cli.run() 18 | 19 | const booleanType = cli.config.args.boolean.type // => 20 | const stringType = cli.config.args.string.type // => 21 | const numberType = cli.config.args.number.type // => 22 | 23 | describe('cli.config.args.type', () => { 24 | test('boolean', () => { 25 | expect(booleanType).toBe('boolean') 26 | }) 27 | test('string', () => { 28 | expect(stringType).toBe('string') 29 | }) 30 | test('number', () => { 31 | expect(numberType).toBe('number') 32 | }) 33 | test('should equal typeof', () => { 34 | expect(typeof cli.values.string).toBe(cli.config.args.string.type) 35 | expect(typeof cli.values.boolean).toBe(cli.config.args.boolean.type) 36 | expect(typeof cli.values.number).toBe(cli.config.args.number.type) 37 | }) 38 | }) 39 | 40 | describe('parsed cli.values', () => { 41 | test('strings', () => { 42 | expect(cli.values.string).toBe('test') 43 | expectTypeOf(cli.values.string).toBeString() 44 | }) 45 | test('booleans', () => { 46 | expect(cli.values.boolean).toBe(false) 47 | expectTypeOf(cli.values.boolean).toBeBoolean() 48 | }) 49 | test('numbers', () => { 50 | expect(cli.values.number).toBe(5) 51 | expectTypeOf(cli.values.number).toBeNumber() 52 | }) 53 | test('help', () => { 54 | expect(cli.values.help).toBeUndefined() 55 | }) 56 | }) 57 | 58 | // TODO: 59 | 60 | // These should all show help: 61 | // cli 62 | // cli -h 63 | // cli --help 64 | // cli -h 65 | // cli --help 66 | 67 | // These should all show help: 68 | // cli -h 69 | // cli --help 70 | // cli help 71 | // cli help 72 | // cli -h 73 | // cli --help 74 | -------------------------------------------------------------------------------- /scripts/lib/spinner.ts: -------------------------------------------------------------------------------- 1 | import { d, o, err } from '@braebo/ansi' 2 | import { stdout } from 'node:process' 3 | 4 | export class Spinner { 5 | #currentFrame = 0 6 | #interval = 80 7 | #running = false 8 | #timer = null as NodeJS.Timer | null 9 | 10 | constructor( 11 | private text = '', 12 | private frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], 13 | ) {} 14 | 15 | start() { 16 | if (this.#running) return 17 | this.#running = true 18 | 19 | // Save cursor position and hide it. 20 | stdout.write('\x1B[?25l') 21 | 22 | this.#timer = setInterval(() => { 23 | const frame = this.frames[this.#currentFrame] 24 | // Clear the current line and move cursor to start. 25 | stdout.write('\r\x1B[K') 26 | stdout.write(`${d(frame)} ${this.text}`) 27 | this.#currentFrame = (this.#currentFrame + 1) % this.frames.length 28 | }, this.#interval) 29 | 30 | return this 31 | } 32 | 33 | stop(printErr = false) { 34 | if (!this.#running) return 35 | this.#running = false 36 | 37 | if (this.#timer) { 38 | clearInterval(this.#timer) 39 | this.#timer = null 40 | } 41 | 42 | // Clear line and restore cursor. 43 | stdout.write('\r\x1B[K\x1B[?25h') 44 | 45 | if (printErr) { 46 | err(this.text) 47 | } 48 | 49 | return this 50 | } 51 | 52 | async countdown(ms: number) { 53 | const progress = Array(30).fill('𝍌') 54 | const endTime = Date.now() + ms 55 | let remaining = Math.ceil(ms / 1000) 56 | 57 | return new Promise(resolve => { 58 | const updateText = () => { 59 | const now = Date.now() 60 | const remainingMs = endTime - now 61 | 62 | remaining = Math.ceil(remainingMs / 1000) 63 | 64 | const progressLength = Math.ceil((remainingMs / ms) * 30) 65 | progress.length = progressLength 66 | 67 | this.setText(` ${o(remaining)} ${d(progress.join(''))}`) 68 | 69 | if (remainingMs <= 0) { 70 | clearInterval(countdownTimer) 71 | resolve() 72 | } 73 | } 74 | 75 | updateText() 76 | const countdownTimer = setInterval(updateText, 50) 77 | 78 | setTimeout(() => { 79 | clearInterval(countdownTimer) 80 | resolve() 81 | }, ms) 82 | }) 83 | } 84 | 85 | setText(text: string) { 86 | this.text = text 87 | return this 88 | } 89 | 90 | setFrames(frames: string[]) { 91 | this.frames = frames 92 | return this 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /scripts/lib/utils.oops.ts: -------------------------------------------------------------------------------- 1 | // Color utils for one-off script. 2 | 3 | // ANSI escape codes for colors and styles 4 | const codes = { 5 | // Styles 6 | reset: '\x1b[0m', 7 | bold: '\x1b[1m', 8 | dim: '\x1b[2m', 9 | italic: '\x1b[3m', 10 | underline: '\x1b[4m', 11 | inverse: '\x1b[7m', 12 | hidden: '\x1b[8m', 13 | strikethrough: '\x1b[9m', 14 | 15 | // Foreground colors 16 | black: '\x1b[30m', 17 | red: '\x1b[31m', 18 | green: '\x1b[32m', 19 | yellow: '\x1b[33m', 20 | blue: '\x1b[34m', 21 | magenta: '\x1b[35m', 22 | cyan: '\x1b[36m', 23 | white: '\x1b[37m', 24 | 25 | // Bright foreground colors 26 | brightBlack: '\x1b[90m', 27 | brightRed: '\x1b[91m', 28 | brightGreen: '\x1b[92m', 29 | brightYellow: '\x1b[93m', 30 | brightBlue: '\x1b[94m', 31 | brightMagenta: '\x1b[95m', 32 | brightCyan: '\x1b[96m', 33 | brightWhite: '\x1b[97m', 34 | 35 | // Background colors 36 | bgBlack: '\x1b[40m', 37 | bgRed: '\x1b[41m', 38 | bgGreen: '\x1b[42m', 39 | bgYellow: '\x1b[43m', 40 | bgBlue: '\x1b[44m', 41 | bgMagenta: '\x1b[45m', 42 | bgCyan: '\x1b[46m', 43 | bgWhite: '\x1b[47m', 44 | 45 | // Bright background colors 46 | bgBrightBlack: '\x1b[100m', 47 | bgBrightRed: '\x1b[101m', 48 | bgBrightGreen: '\x1b[102m', 49 | bgBrightYellow: '\x1b[103m', 50 | bgBrightBlue: '\x1b[104m', 51 | bgBrightMagenta: '\x1b[105m', 52 | bgBrightCyan: '\x1b[106m', 53 | bgBrightWhite: '\x1b[107m', 54 | } as const 55 | 56 | type ColorCode = keyof typeof codes 57 | 58 | // Helper function to wrap text with ANSI codes 59 | const color = (str: string, ...styles: ColorCode[]): string => { 60 | if (styles.length === 0) return str 61 | const prefix = styles.map((style) => codes[style]).join('') 62 | return `${prefix}${str}${codes.reset}` 63 | } 64 | 65 | // Create individual color functions 66 | type ColorFunction = (str: string) => string 67 | type Colors = { [K in ColorCode]: ColorFunction } 68 | 69 | /** 70 | * A map of color functions for each color code. 71 | * @example 72 | * console.log(colors.green('Success!')); 73 | * console.log(colors.red('Error!')); 74 | * console.log(colors.yellow('Warning!')); 75 | */ 76 | export const colors = Object.keys(codes).reduce((acc, code) => { 77 | acc[code as ColorCode] = (str: string) => color(str, code as ColorCode) 78 | return acc 79 | }, {} as Colors) 80 | 81 | /** 82 | * A wrapper function that preserves string interpolation and nested styles. 83 | */ 84 | function wrap(style: ColorCode) { 85 | return (strings: TemplateStringsArray | any, ...values: any[]) => { 86 | // Handle both template literals and regular values. 87 | if (strings instanceof Array) { 88 | // Template literal case. 89 | const result = strings.reduce((acc, str, i) => { 90 | const value = values[i] || '' 91 | // If the interpolated value already has ANSI codes. 92 | if (typeof value === 'string' && value.includes('\x1b[')) { 93 | // Extract the reset code and add our style code after it. 94 | const parts = value.split(codes.reset) 95 | // There might be content after the reset code. 96 | const lastPart = parts[parts.length - 1] 97 | const processedValue = 98 | value.slice(0, -lastPart.length - codes.reset.length) + 99 | codes.reset + 100 | codes[style] + 101 | lastPart 102 | return acc + str + processedValue 103 | } 104 | return acc + str + value 105 | }, '') 106 | return color(result, style) 107 | } 108 | // Regular value case 109 | return color(String(strings), style) 110 | } 111 | } 112 | 113 | /** console.log */ 114 | export function l(...args: any[]) { 115 | console.log(color('|', 'dim'), ...args) 116 | } 117 | 118 | /** console.error */ 119 | export function err(...args: any[]) { 120 | console.error(color('| ERROR:', 'red'), ...args) 121 | } 122 | 123 | /** Wraps a string in ansi `red`: `\x1b[31m` */ 124 | export const r = wrap('red') 125 | /** Wraps a string in ansi `green`: `\x1b[32m` */ 126 | export const g = wrap('green') 127 | /** Wraps a string in ansi `yellow`: `\x1b[33m` */ 128 | export const y = wrap('yellow') 129 | /** Wraps a string in ansi `blue`: `\x1b[34m` */ 130 | export const b = wrap('blue') 131 | /** Wraps a string in ansi `magenta`: `\x1b[35m` */ 132 | export const m = wrap('magenta') 133 | /** Wraps a string in ansi `cyan`: `\x1b[36m` */ 134 | export const c = wrap('cyan') 135 | /** Wraps a string in ansi `dim`: `\x1b[2m` */ 136 | export const d = wrap('dim') 137 | /** Wraps a string in ansi `reset`: `\x1b[0m` */ 138 | export const reset = wrap('reset') 139 | /** Wraps a string in ansi `bold`: `\x1b[1m` */ 140 | export const bd = wrap('bold') 141 | -------------------------------------------------------------------------------- /scripts/parse.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { l, n } from '../src/lib/utils/logger/logettes' 4 | import { Cli } from './lib/cli' 5 | 6 | const cli = new Cli({ 7 | banner: 'Parse', 8 | description: 'Visualize how your flags are parsed', 9 | cmd: 'parse', 10 | examples: ['parse -q --foo bar --baz=qux'], 11 | args: { 12 | foo: { 13 | default: 'bar', 14 | short: '-f', 15 | description: 'The foo flag', 16 | }, 17 | bar: { 18 | default: 'baz', 19 | short: '-b', 20 | description: 'The bar flag', 21 | }, 22 | baz: { 23 | default: 'qux', 24 | short: '-z', 25 | description: 'The baz flag', 26 | }, 27 | }, 28 | }) 29 | 30 | try { 31 | cli.run(async values => { 32 | n() 33 | l({ values }) 34 | }) 35 | } catch (e) { 36 | console.log(e) 37 | } 38 | -------------------------------------------------------------------------------- /shiki.config.mjs: -------------------------------------------------------------------------------- 1 | // https://preprocessors.samplekit.dev/docs/code-decoration 2 | 3 | import { createShikiLogger, processCodeblockSync, getOrLoadOpts } from '@samplekit/preprocess-shiki' 4 | 5 | export default async () => { 6 | const opts = await getOrLoadOpts() 7 | const preprocessorRoot = `${import.meta.dirname}/src/routes/` 8 | const formatFilename = (/** @type {string} */ filename) => filename.replace(preprocessorRoot, '') 9 | 10 | return processCodeblockSync({ 11 | include: (filename) => filename.startsWith(preprocessorRoot), 12 | logger: createShikiLogger(formatFilename), 13 | opts, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | declare global { 6 | namespace App { 7 | interface Locals { 8 | theme: 'light' | 'dark' | 'system' 9 | } 10 | interface PageData { 11 | theme: 'light' | 'dark' | 'system' 12 | } 13 | // interface Platform {} 14 | } 15 | } 16 | 17 | export {} 18 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | %sveltekit.head% 43 | 44 | 45 |
%sveltekit.body%
46 | 47 | 48 | -------------------------------------------------------------------------------- /src/hooks.client.ts: -------------------------------------------------------------------------------- 1 | import type { Handle, HandleClientError } from '@sveltejs/kit' 2 | 3 | import { redirect } from '@sveltejs/kit' 4 | import { router } from '$lib/router' 5 | 6 | import { parse } from 'cookie' 7 | import { DEV } from 'esm-env' 8 | 9 | export const handle: Handle = ({ event, resolve }) => { 10 | const cookies = parse(event.request.headers?.get('cookie') || '') 11 | event.locals.theme = <'dark' | 'light' | 'system'>cookies.theme || 'dark' 12 | 13 | const route = router.get(event.url.pathname) 14 | if (!DEV && (route as any)?.dev) { 15 | redirect(308, '/404') 16 | } 17 | 18 | let page = '' 19 | return resolve(event, { 20 | transformPageChunk: ({ html, done }) => { 21 | page += html 22 | if (done) 23 | return page.replace('__THEME__', event.locals.theme).replace('__COLOR_SCHEME__', event.locals.theme) 24 | }, 25 | }) 26 | } 27 | 28 | export const handleError: HandleClientError = ({ error, event }) => { 29 | console.error(error) 30 | 31 | if (error && typeof error === 'object' && 'status' in error && error.status === 404) { 32 | if (event.url.pathname !== '/404') redirect(308, '/404') 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import type { Handle, HandleServerError } from '@sveltejs/kit' 2 | 3 | import { redirect } from '@sveltejs/kit' 4 | import { router } from '$lib/router' 5 | 6 | import { parse } from 'cookie' 7 | import { DEV } from 'esm-env' 8 | 9 | export const handle: Handle = ({ event, resolve }) => { 10 | const cookies = parse(event.request.headers?.get('cookie') || '') 11 | event.locals.theme = <'dark' | 'light' | 'system'>cookies.theme || 'dark' 12 | 13 | const route = router.get(event.url.pathname) 14 | if (!DEV && (route as any)?.dev) { 15 | redirect(308, '/404') 16 | } 17 | 18 | let page = '' 19 | return resolve(event, { 20 | transformPageChunk: ({ html, done }) => { 21 | page += html 22 | if (done) 23 | return page.replace('__THEME__', event.locals.theme).replace('__COLOR_SCHEME__', event.locals.theme) 24 | }, 25 | }) 26 | } 27 | 28 | export const handleError: HandleServerError = ({ error, event }) => { 29 | console.error(error) 30 | 31 | if (error && typeof error === 'object' && 'status' in error && error.status === 404) { 32 | if (event.url.pathname !== '/404') redirect(308, '/404') 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks.ts.md: -------------------------------------------------------------------------------- 1 | TODO: This doesn't work for some reason, so I had to create two identical hooks for client and server... -------------------------------------------------------------------------------- /src/lib/actions/click-outside.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from 'svelte/action' 2 | 3 | /** 4 | * Calls a function when the user clicks outside the element. 5 | * @example 6 | * ```svelte 7 | *
8 | * ``` 9 | */ 10 | export const clickOutside: Action< 11 | Element, 12 | | { 13 | /** 14 | * Array of classnames. If the click target element has one of these classes, it will not fire the `onOutClick` event. 15 | */ 16 | whitelist?: string[] 17 | } 18 | | undefined, 19 | { 20 | onOutClick?: ( 21 | event: CustomEvent<{ 22 | target: HTMLElement 23 | }>, 24 | ) => void 25 | } 26 | > = (node, options) => { 27 | const handleClick = (event: MouseEvent) => { 28 | let disable = false 29 | 30 | for (const className of options?.whitelist || []) { 31 | if (event.target instanceof Element && event.target.classList.contains(className)) { 32 | disable = true 33 | } 34 | } 35 | 36 | if (!disable && node && !node.contains(event.target as Node) && !event.defaultPrevented) { 37 | node.dispatchEvent( 38 | new CustomEvent('outclick', { 39 | detail: { 40 | target: event.target as HTMLElement, 41 | }, 42 | }), 43 | ) 44 | } 45 | } 46 | 47 | document.addEventListener('click', handleClick, true) 48 | 49 | return { 50 | update: (newOptions) => (options = { ...options, ...newOptions }), 51 | destroy() { 52 | document.removeEventListener('click', handleClick, true) 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/actions/focus.ts: -------------------------------------------------------------------------------- 1 | import { tick } from 'svelte'; 2 | 3 | /** Sometimes the autofocus attribute is insufficient, we need to do this */ 4 | export function forcefocus(node: HTMLInputElement) { 5 | tick().then(() => node.focus()); 6 | } 7 | 8 | export function focusable_children(node: HTMLElement) { 9 | const nodes: HTMLElement[] = Array.from( 10 | node.querySelectorAll( 11 | 'a[href], button, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])' 12 | ) 13 | ); 14 | 15 | const index = nodes.indexOf(document.activeElement as HTMLElement); 16 | 17 | const update = (d: number) => { 18 | let i = index + d; 19 | i += nodes.length; 20 | i %= nodes.length; 21 | 22 | nodes[i].focus(); 23 | }; 24 | 25 | function traverse(d: number, selector?: string) { 26 | const reordered = [...nodes.slice(index), ...nodes.slice(0, index)]; 27 | 28 | let i = (reordered.length + d) % reordered.length; 29 | let node; 30 | 31 | while ((node = reordered[i])) { 32 | i += d; 33 | 34 | if (node.matches('details:not([open]) *')) { 35 | continue; 36 | } 37 | 38 | if (!selector || node.matches(selector)) { 39 | node.focus(); 40 | return; 41 | } 42 | } 43 | } 44 | 45 | return { 46 | next: (selector?: string) => traverse(1, selector), 47 | prev: (selector?: string) => traverse(-1, selector), 48 | update 49 | }; 50 | } 51 | 52 | export function trap(node: HTMLElement, { reset_focus = true }: { reset_focus?: boolean } = {}) { 53 | const previous = document.activeElement as HTMLElement; 54 | 55 | const handle_keydown = (e: KeyboardEvent) => { 56 | if (e.key === 'Tab') { 57 | e.preventDefault(); 58 | 59 | const group = focusable_children(node); 60 | if (e.shiftKey) { 61 | group.prev(); 62 | } else { 63 | group.next(); 64 | } 65 | } 66 | }; 67 | 68 | node.addEventListener('keydown', handle_keydown); 69 | 70 | return { 71 | destroy: () => { 72 | node.removeEventListener('keydown', handle_keydown); 73 | if (reset_focus) { 74 | previous?.focus({ preventScroll: true }); 75 | } 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/actions/hover.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from 'svelte/action' 2 | 3 | /** 4 | * Calls `onHover` when the user hovers over the element. When the user hovers out, it waits until 5 | * `delay` milliseconds have passed before calling `onHover` again with `hovering: false`. 6 | */ 7 | export const hover: Action< 8 | Element, 9 | | { 10 | /** 11 | * Delay in milliseconds before releasing the hover state. 12 | * @default 0 13 | */ 14 | delay?: number 15 | } 16 | | undefined, 17 | { 18 | onhover?: (event: CustomEvent<{ hovering: boolean }>) => void 19 | } 20 | > = (node, options) => { 21 | function enter(_e: Event) { 22 | clearTimeout(leaveTimer) 23 | node.dispatchEvent(new CustomEvent('hover', { detail: { hovering: true } })) 24 | } 25 | 26 | let leaveTimer: ReturnType 27 | function leave(_e: Event) { 28 | clearTimeout(leaveTimer) 29 | leaveTimer = setTimeout(() => { 30 | node.dispatchEvent(new CustomEvent('hover', { detail: { hovering: false } })) 31 | }, options?.delay ?? 0) 32 | } 33 | 34 | node.addEventListener('pointerleave', leave, true) 35 | node.addEventListener('pointerenter', enter, true) 36 | 37 | return { 38 | destroy() { 39 | clearTimeout(leaveTimer) 40 | node.removeEventListener('pointerleave', leave, true) 41 | node.removeEventListener('pointerenter', enter, true) 42 | }, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/actions/index.ts: -------------------------------------------------------------------------------- 1 | export { focusable_children, forcefocus, trap } from './focus'; 2 | -------------------------------------------------------------------------------- /src/lib/actions/trap.ts: -------------------------------------------------------------------------------- 1 | // original from svelte.dev: https://github.com/sveltejs/svelte.dev/blob/a865f37f3f060a698b79a4b35cbce97835c5c413/packages/site-kit/src/lib/actions/focus.ts#L1-L78 2 | 3 | import { tick } from 'svelte' 4 | 5 | /** Sometimes the autofocus attribute is insufficient, we need to do this */ 6 | export function forcefocus(node: HTMLInputElement) { 7 | tick().then(() => node.focus()) 8 | } 9 | 10 | export function focusable_children(node: HTMLElement) { 11 | const nodes: HTMLElement[] = Array.from( 12 | node.querySelectorAll( 13 | 'a[href], button, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])', 14 | ), 15 | ) 16 | 17 | const index = nodes.indexOf(document.activeElement as HTMLElement) 18 | 19 | const update = (d: number) => { 20 | let i = index + d 21 | i += nodes.length 22 | i %= nodes.length 23 | 24 | nodes[i].focus() 25 | } 26 | 27 | function traverse(d: number, selector?: string) { 28 | const reordered = [...nodes.slice(index), ...nodes.slice(0, index)] 29 | 30 | let i = (reordered.length + d) % reordered.length 31 | let node 32 | 33 | while ((node = reordered[i])) { 34 | i += d 35 | 36 | if (node.matches('details:not([open]) *')) { 37 | continue 38 | } 39 | 40 | if (!selector || node.matches(selector)) { 41 | node.focus() 42 | return 43 | } 44 | } 45 | } 46 | 47 | return { 48 | next: (selector?: string) => traverse(1, selector), 49 | prev: (selector?: string) => traverse(-1, selector), 50 | update, 51 | } 52 | } 53 | 54 | export function trap(node: HTMLElement, { reset_focus = true }: { reset_focus?: boolean } = {}) { 55 | const previous = document.activeElement as HTMLElement 56 | 57 | const handle_keydown = (e: KeyboardEvent) => { 58 | if (e.key === 'Tab') { 59 | e.preventDefault() 60 | 61 | const group = focusable_children(node) 62 | if (e.shiftKey) { 63 | group.prev() 64 | } else { 65 | group.next() 66 | } 67 | } 68 | } 69 | 70 | node.addEventListener('keydown', handle_keydown) 71 | 72 | return { 73 | destroy: () => { 74 | node.removeEventListener('keydown', handle_keydown) 75 | if (reset_focus) { 76 | previous?.focus({ preventScroll: true }) 77 | } 78 | }, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/lib/actions/utils.ts: -------------------------------------------------------------------------------- 1 | export function fix_position(element: HTMLElement, fn: Function) { 2 | let scroll_parent: HTMLElement | null = element; 3 | 4 | while ((scroll_parent = scroll_parent.parentElement)) { 5 | if (/^(scroll|auto)$/.test(getComputedStyle(scroll_parent).overflowY)) { 6 | break; 7 | } 8 | } 9 | 10 | const top = element.getBoundingClientRect().top; 11 | fn(); 12 | const delta = element.getBoundingClientRect().top - top; 13 | 14 | if (delta !== 0) { 15 | // whichever element the user interacted with should stay in the same position 16 | (scroll_parent ?? window).scrollBy(0, delta); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/components/Dropdown.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 33 | 34 | 70 | -------------------------------------------------------------------------------- /src/lib/components/FontToggle.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 26 | 27 | 52 | -------------------------------------------------------------------------------- /src/lib/components/Gooey.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 37 | 38 | { 40 | if (e.key === '1') { 41 | gooey!.toggleHidden() 42 | } 43 | }} 44 | /> 45 | -------------------------------------------------------------------------------- /src/lib/components/Hello.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

7 | {#each 'Hello' as letter} 8 |
{letter}
9 | {/each} 10 |

11 |
12 | 13 | 82 | -------------------------------------------------------------------------------- /src/lib/components/Hero.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 |
31 | 32 |
33 | {@render word('svelte')} 34 | 35 |
39 | 42 |
43 | 44 | {@render word('starter', 6)} 45 |
46 | 47 | {#snippet word(letters: string, start = 0)} 48 |
49 | {#each letters as letter, i} 50 | {@const pct = (100 / letters.length) * i} 51 | 52 | 57 | {letter} 58 | 59 | {/each} 60 |
61 | {/snippet} 62 | 63 | 116 | -------------------------------------------------------------------------------- /src/lib/components/HoverMenu.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | {@render children()} 9 |
10 | 11 | 42 | -------------------------------------------------------------------------------- /src/lib/components/Icon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 17 | 18 | 19 | 20 | 21 | 22 | 42 | -------------------------------------------------------------------------------- /src/lib/components/ModalOverlay.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if nav_state.open} 14 | 15 | {/if} 16 | 17 | 34 | -------------------------------------------------------------------------------- /src/lib/components/Page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 | {@render before?.()} 23 | 24 |
25 | {#if title} 26 |

{title}

27 | {/if} 28 | 29 |
30 | {@render children?.()} 31 |
32 |
33 | 34 | {@render after?.()} 35 |
36 | 37 | 101 | -------------------------------------------------------------------------------- /src/lib/components/PageTitle.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | svelte-starter · {pageTitle()} 19 | 20 | -------------------------------------------------------------------------------- /src/lib/components/Shell.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | 25 | 26 | 27 | {#if navigating.from} 28 | 29 | {/if} 30 | 31 | {#if nav_visible} 32 | 33 | 34 | {@render top_nav?.()} 35 | {/if} 36 | 37 | {@render children?.()} 38 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /src/lib/components/header/Header.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
0}> 11 |
12 | 13 | 14 | 15 |
16 | 17 | {#if !device.mobile} 18 | 19 | {/if} 20 | 21 |
22 | 23 |
24 |
25 |
26 |
27 | 28 | 54 | -------------------------------------------------------------------------------- /src/lib/components/header/Logo.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 42 | 43 | 123 | -------------------------------------------------------------------------------- /src/lib/components/header/navs/Mobile/Burger.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | {#if browser} 26 | {#key showMenu} 27 | 28 | 37 | 48 | 57 | 58 | {/key} 59 | {/if} 60 |
61 | 62 | 192 | -------------------------------------------------------------------------------- /src/lib/components/header/navs/Mobile/PageFill.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
(showMenu = false)} 11 | onkeydown={(e) => { 12 | if (e.key === 'Escape') showMenu = false 13 | }} 14 | > 15 | {@render children?.()} 16 |
17 | 18 | 50 | -------------------------------------------------------------------------------- /src/lib/components/header/navs/NavDesktop.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 97 | -------------------------------------------------------------------------------- /src/lib/components/header/navs/NavMobile.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
(showMenu = false)} 18 | > 19 | 20 |
21 | 22 | 23 | {#if showMenu} 24 | 40 | {/if} 41 | 42 | 43 | 101 | -------------------------------------------------------------------------------- /src/lib/components/nav/MobileSubMenu.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | 75 | 76 | 111 | -------------------------------------------------------------------------------- /src/lib/components/nav/Nav.Menu.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 9 | 10 | 21 | 22 | 127 | -------------------------------------------------------------------------------- /src/lib/components/nav/Nav.Mobile.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 57 | 58 | { 60 | // We only manage focus when Esc is hit, otherwise the navigation will reset focus. 61 | if (nav_state.open && e.key === 'Escape') { 62 | nav_state.open = false 63 | tick().then(() => menu_button?.focus()) 64 | } 65 | }} 66 | /> 67 | 68 | {#if title} 69 |
70 | {title} 71 |
72 | {/if} 73 | 74 |
75 | 76 | 77 | 94 |
95 | 96 | {#if nav_state.open} 97 |
98 | (nav_state.open = false)} /> 99 | 100 |
101 | {/if} 102 | 103 | 138 | -------------------------------------------------------------------------------- /src/lib/components/nav/Nav.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 27 | 28 |