initial commit

This commit is contained in:
minhtrannhat 2025-01-23 11:16:33 -05:00
commit 25cabeb8df
Signed by: minhtrannhat
GPG Key ID: E13CFA85C53F8062
57 changed files with 11214 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
dist
.solid
.output
.vercel
.netlify
netlify
.vinxi
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"cSpell.words": [
"andi",
"mapbox",
"owickstrom",
"prismjs",
"rehype",
"solidjs",
"vinxi"
]
}

32
README.md Normal file
View File

@ -0,0 +1,32 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
## This project was created with the [Solid CLI](https://solid-cli.netlify.app)

33
app.config.ts Normal file
View File

@ -0,0 +1,33 @@
import { defineConfig } from "@solidjs/start/config";
//@ts-expect-error
import pkg from "@vinxi/plugin-mdx";
import { blogPostsPlugin } from "./build-helpers/blogPostsPlugin";
import remarkFrontmatter from "remark-frontmatter";
import rehypeMdxCodeProps from "rehype-mdx-code-props";
import { mdxPrism } from "./build-helpers/mdxPrism";
import remarkToc from "remark-toc";
const { default: mdx } = pkg;
export default defineConfig({
extensions: ["mdx", "md"],
vite: {
plugins: [
mdx.withImports({})({
remarkPlugins: [remarkFrontmatter, remarkToc],
rehypePlugins: [rehypeMdxCodeProps, mdxPrism],
jsx: true,
jsxImportSource: "solid-js",
providerImportSource: "solid-mdx",
}),
blogPostsPlugin(),
],
build: {
minify: false,
},
},
server: {
prerender: {
crawlLinks: true,
},
},
});

12
biome.json Normal file
View File

@ -0,0 +1,12 @@
{
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}

View File

@ -0,0 +1,49 @@
import type { Plugin } from "vite";
import { readSync } from "to-vfile";
import { matter } from "vfile-matter";
import { resolve, join } from "node:path";
import { readdirSync, statSync, writeFileSync } from "node:fs";
import { exec } from "node:child_process";
const processFiles = () => {
const outputFile = resolve("src/data/posts.json");
const blogDir = resolve("src/routes/blog");
const files = readdirSync(blogDir);
const blogPosts = files
.filter(
(file) => statSync(join(blogDir, file)).isFile() && file.endsWith(".mdx"),
)
.map((file) => {
const f = readSync(resolve("src/routes/blog", file));
matter(f);
return {
...(f.data.matter as object),
slug: file.replace(".mdx", ""),
} as { date: string; slug: string };
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
writeFileSync(outputFile, JSON.stringify(blogPosts, null, 2), "utf-8");
exec("bunx @biomejs/biome format --write ./src/data/posts.json");
};
export const blogPostsPlugin = (): Plugin => {
return {
name: "blog-posts-gen",
buildEnd() {
processFiles();
},
configureServer(server) {
server.watcher.on("change", (filePath) => {
if (
!filePath.includes("/src/routes/blog") &&
!filePath.includes("blogPostsPlugin.ts")
)
return;
processFiles();
});
},
};
};

38
build-helpers/mdxPrism.ts Normal file
View File

@ -0,0 +1,38 @@
import { visit } from "unist-util-visit";
import { toString as nodeToString } from "hast-util-to-string";
import { refractor } from "refractor";
import tsx from "refractor/lang/tsx.js";
refractor.register(tsx);
export const mdxPrism = () => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
return (tree: any) => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
visit(tree, "element" as any, visitor);
};
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function visitor(node: any, index: number | undefined, parent: any) {
if (parent.type !== "mdxJsxFlowElement") {
return;
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const attrs = parent.attributes.reduce((a: any, c: any) => {
if (c.type === "mdxJsxAttribute") {
a[c.name] = c.value;
}
return a;
}, {});
const lang = attrs.lang;
if (!lang) {
return;
}
const result = refractor.highlight(nodeToString(node), lang);
node.children = result.children;
}
};

2386
bun.lock Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "example-with-mdx",
"type": "module",
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
"preview": "npx http-server ./.output/public"
},
"dependencies": {
"@mdx-js/mdx": "^2.3.0",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.0.11",
"@vinxi/plugin-mdx": "^3.7.2",
"prismjs": "^1.29.0",
"remark-toc": "^9.0.0",
"solid-js": "^1.9.4",
"solid-mdx": "^0.0.7",
"vinxi": "^0.5.1"
},
"engines": {
"node": ">=18",
"yarn": "3"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@mapbox/rehype-prism": "^0.9.0",
"@mdx-js/rollup": "^3.1.0",
"@solidjs/meta": "^0.29.4",
"autoprefixer": "^10.4.20",
"dayjs": "^1.11.13",
"hast-util-to-string": "^3.0.1",
"http-server": "^14.1.1",
"refractor": "^4.8.1",
"rehype-mdx-code-props": "^3.0.1",
"remark-frontmatter": "^5.0.0",
"solid-jsx": "^1.1.4",
"tailwindcss": "^3.4.17",
"to-vfile": "^8.0.0",
"typed-css-modules": "^0.9.1",
"unist-util-visit": "^5.0.0",
"vfile-matter": "^5.0.0",
"vite": "^6.0.11"
}
}

6987
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

11
postcss.config.js Normal file
View File

@ -0,0 +1,11 @@
import tailwind from "tailwindcss";
import autoprefixer from "autoprefixer";
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: [
autoprefixer,
tailwind
]
}
export default config

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

6
public/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="16" height="16"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13.28L12.2 2H13.76L9.56 13.28H8Z" fill="black"></path>
<path d="M2 8.89995V7.95195C2 7.45595 2.16 7.05995 2.48 6.76395C2.8 6.46795 3.22 6.31995 3.74 6.31995C4.068 6.31995 4.34 6.37595 4.556 6.48795C4.772 6.59995 4.952 6.73595 5.096 6.89595C5.248 7.05595 5.38 7.21995 5.492 7.38795C5.612 7.54795 5.732 7.68395 5.852 7.79595C5.972 7.89995 6.112 7.95195 6.272 7.95195C6.424 7.95195 6.536 7.90795 6.608 7.81995C6.688 7.72395 6.728 7.59595 6.728 7.43595V6.55995H8V7.50795C8 7.99595 7.84 8.39195 7.52 8.69595C7.208 8.99195 6.788 9.13995 6.26 9.13995C5.932 9.13995 5.66 9.08395 5.444 8.97195C5.228 8.85995 5.044 8.72395 4.892 8.56395C4.748 8.40395 4.616 8.24395 4.496 8.08395C4.384 7.91595 4.268 7.77995 4.148 7.67595C4.028 7.56395 3.888 7.50795 3.728 7.50795C3.584 7.50795 3.472 7.55195 3.392 7.63995C3.312 7.72795 3.272 7.85595 3.272 8.02395V8.89995H2Z" fill="black"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: invert(100%); } }
</style></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/images/all_code.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/images/magic.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

BIN
public/images/toggle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
public/logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

BIN
public/logo-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

BIN
public/resume.pdf Normal file

Binary file not shown.

331
src/app.css Normal file
View File

@ -0,0 +1,331 @@
@import url('https://fonts.cdnfonts.com/css/jetbrains-mono-2');
:root {
--line-height: 1.2rem;
--border-thickness: 2px;
--text-color: black;
font-optical-sizing: auto;
font-variant-numeric: tabular-nums lining-nums;
}
.dark {
:root {
--text-color: white;
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
text-decoration-thickness: var(--border-thickness);
}
*::selection {
@apply bg-black text-white dark:bg-white dark:text-black;
}
.button {
border: var(--border-thickness) solid;
padding:
calc(var(--line-height) / 2 - var(--border-thickness)) calc(1ch - var(--border-thickness));
margin: 0;
height: calc(var(--line-height) * 2);
width: auto;
overflow: visible;
line-height: normal;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
@apply select-none bg-white dark:bg-black px-1h shadow-box active:shadow-none active:translate-x-[3px] active:translate-y-[3px];
}
.button:focus:not(:active) {
--border-thickness: 3px;
outline: none;
}
hr {
@apply h-2v block relative text-black dark:text-white border-none my-1v;
}
hr:after {
@apply block absolute left-0 h-0 w-full border-black dark:border-white;
content: "";
top: calc(var(--line-height) - var(--border-thickness));
border-top: calc(var(--border-thickness) * 3) double;
}
.jump-text:hover>.jump-text {
animation: jump 0.25s ease-in-out;
animation-delay: var(--animation-delay);
}
@keyframes jump {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-7px);
}
}
details {
border: var(--border-thickness) solid var(--text-color);
padding: calc(var(--line-height) - var(--border-thickness)) 1ch;
margin-bottom: var(--line-height);
margin-top: var(--line-height);
}
summary {
font-weight: var(--font-weight-medium);
cursor: pointer;
}
details[open] summary {
margin-bottom: var(--line-height);
}
details ::marker {
display: inline-block;
content: '▶';
margin: 0;
}
details[open] ::marker {
content: '▼';
}
details :last-child {
margin-bottom: 0;
}
/* DITHER ANIMATION */
.dither {
background-repeat: repeat;
position: fixed;
inset: 0;
z-index: 50;
image-rendering: optimizeSpeed;
/* STOP SMOOTHING, GIVE ME SPEED */
image-rendering: -moz-crisp-edges;
/* Firefox */
image-rendering: -o-crisp-edges;
/* Opera */
image-rendering: -webkit-optimize-contrast;
/* Chrome (and eventually Safari) */
image-rendering: pixelated;
/* Universal support since 2021 */
image-rendering: optimize-contrast;
/* CSS3 Proposed */
-ms-interpolation-mode: nearest-neighbor;
pointer-events: none;
}
.dark {
.dither {
filter: invert(1)
}
.wave-image {
filter: invert(1)
}
}
.dither-1 {
background-image: url(/images/dither_light_3.png);
}
.dither-2 {
background-image: url(/images/dither_light_3.png);
background-position: 50px 50px;
}
.dither-3 {
background-image: url(/images/dither_light_3.png);
background-position: 100px 100px;
}
.tree,
.tree ul {
position: relative;
padding-left: 0;
list-style-type: none;
line-height: var(--line-height);
}
.tree ul {
margin: 0;
}
.tree ul li {
position: relative;
padding-left: 1.5ch;
margin-left: 1.5ch;
border-left: var(--border-thickness) solid var(--text-color);
}
.tree ul li:before {
position: absolute;
display: block;
top: calc(var(--line-height) / 2);
left: 0;
content: "";
width: 1ch;
border-bottom: var(--border-thickness) solid var(--text-color);
}
.tree ul li:last-child {
border-left: none;
}
.tree ul li:last-child:after {
position: absolute;
display: block;
top: 0;
left: 0;
content: "";
height: calc(var(--line-height) / 2);
border-left: var(--border-thickness) solid var(--text-color);
}
/* DEBUG UTILITIES */
.debug .debug-grid {
--line-height: 1.2rem;
--text-color: #000000;
--background-color: #FFFFFF;
--color: color-mix(in srgb, var(--text-color) 10%, var(--background-color) 90%);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background-image:
repeating-linear-gradient(var(--color) 0 1px, transparent 1px 100%),
repeating-linear-gradient(90deg, var(--color) 0 1px, transparent 1px 100%);
background-size: 1ch var(--line-height);
margin: 0;
}
code:not(pre code) {
@apply bg-black text-white dark:bg-white dark:text-black px-1h;
@apply selection:dark:bg-black selection:dark:text-white selection:bg-white selection:text-black;
}
.debug .off-grid {
background: rgba(255, 0, 0, 0.1);
}
.debug-toggle-label {
text-align: right;
}
.debug img {
/* border: 1px solid black; */
}
.formkit-form {
font-family: "JetBrains Mono", monospace;
}
.formkit-form [data-style="clean"] {
@apply !pt-2v !px-0 !pb-1v;
}
.formkit-fields {
@apply !m-0;
}
.formkit-field {
@apply !m-0 !mr-2h;
}
.formkit-input {
border: var(--border-thickness) solid !important;
padding:
calc(var(--line-height) / 2 - var(--border-thickness)) calc(1ch - var(--border-thickness)) !important;
margin: 0 !important;
height: calc(var(--line-height) * 2) !important;
width: 100% !important;
overflow: visible !important;
line-height: normal !important;
-webkit-font-smoothing: inherit !important;
-moz-osx-font-smoothing: inherit !important;
-webkit-appearance: none !important;
@apply !font-medium;
}
.formkit-input:focus:not(:active) {
--border-thickness: 3px;
outline: none;
}
.formkit-submit {
border: var(--border-thickness) solid !important;
padding:
calc(var(--line-height) / 2 - var(--border-thickness)) calc(1ch - var(--border-thickness)) !important;
margin: 0 !important;
height: calc(var(--line-height) * 2) !important;
width: auto !important;
overflow: visible !important;
line-height: normal !important;
-webkit-font-smoothing: inherit !important;
-moz-osx-font-smoothing: inherit !important;
-webkit-appearance: none !important;
@apply !select-none !bg-white !text-black !px-1h !shadow-box !py-0;
}
.formkit-submit:active {
@apply !shadow-none !translate-x-[3px] !translate-y-[3px]
}
.formkit-alert-success {
border-width: 0 !important;
@apply !bg-transparent !text-black !m-0 !p-0 !font-bold;
}
.dark {
.formkit-input {
@apply !border-white !bg-black !text-white;
}
.formkit-submit {
@apply !bg-black !text-white;
}
.formkit-alert-success {
@apply !text-white;
}
}
.formkit-submit span {
padding: 0 !important;
}
.formkit-submit:hover span {
background-color: transparent !important;
}
.formkit-submit:focus:not(:active) {
--border-thickness: 3px !important;
outline: none !important;
}
.pixelated {
image-rendering: pixelated;
}
/* <div id="a6d9b30e24" class="formkit-alert formkit-alert-success" data-element="success" data-group="alert">Success! Check your email to confirm the subscription.</div> */

5
src/app.css.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare const styles: {
readonly "visually-hidden": string;
};
export = styles;

38
src/app.tsx Normal file
View File

@ -0,0 +1,38 @@
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { createSignal, onCleanup, onMount, Suspense } from "solid-js";
import "./app.css";
import { Layout } from "./components/Layout";
import { MetaProvider, Title } from "@solidjs/meta";
export default function App() {
onMount(() => {
const listener = (e: KeyboardEvent) => {
if (e.metaKey && e.key.toLowerCase() === "k") {
e.preventDefault(); // Prevent the default action (optional)
document.body.classList.toggle("debug");
}
};
window.addEventListener("keydown", listener);
onCleanup(() => {
window.removeEventListener("keydown", listener);
});
});
return (
<MetaProvider>
<Title>minhtrannhat.com</Title>
<Router
root={(props) => {
return (
<Layout>
<Suspense>{props.children}</Suspense>
</Layout>
);
}}
>
<FileRoutes />
</Router>
</MetaProvider>
);
}

View File

@ -0,0 +1,9 @@
import type { ParentComponent } from "solid-js";
export const Button: ParentComponent<{ onClick?: () => void }> = (props) => {
return (
<button class="button" type="button" onClick={props.onClick}>
{props.children}
</button>
);
};

View File

@ -0,0 +1,69 @@
import { createSignal, onMount } from "solid-js";
export const DarkModeToggle = () => {
let ref!: HTMLButtonElement;
const [dark, setDark] = createSignal(false);
const size = 64;
const max = 5;
const time = 70;
let direction = dark() ? -1 : 1;
let current = dark() ? 0 : max;
onMount(() => {
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setDark(isDark);
if (isDark) {
direction = -1;
current = 0;
}
requestAnimationFrame(() => {
play();
});
});
const coord = (n: number) => -n * size;
const play = () => {
ref.style.backgroundPositionX = `${coord(current)}px`;
if (direction === -1 && current === 2) {
document.documentElement.classList.add("dark");
// ref.style.filter = "invert(0)";
}
if (direction === 1 && current === 3) {
document.documentElement.classList.remove("dark");
// ref.style.filter = "invert(1)";
}
if (direction === -1 && current === 0) return;
if (direction === 1 && current === max) return;
current += direction;
setTimeout(play, time);
};
const toggle = () => {
if (dark()) {
direction = 1;
} else {
direction = -1;
}
play();
setDark((d) => !d);
};
return (
<div class="absolute top-1v right-4h">
<button
onClick={toggle}
ref={ref}
class="pixelated"
style={{
scale: "1.5",
height: "32px",
width: "64px",
"background-image": `url("/images/toggle.png")`,
}}
aria-hidden
type="button"
/>
</div>
);
};

62
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,62 @@
import { A } from "@solidjs/router";
import { For, type ParentComponent } from "solid-js";
import { TextHoverJump } from "./TextHoverJump";
import { clientOnly } from "@solidjs/start";
const DarkModeToggle = clientOnly(() =>
import("./DarkModeToggle").then((r) => ({
default: r.DarkModeToggle,
})),
);
export const Layout: ParentComponent = (props) => {
return (
<>
<a href="#main-content" class="sr-only">
Skip to main content
</a>
<div class="flex flex-col min-h-screen pt-2v py-1v px-2h max-w-thread mx-auto relative overflow-x-hidden leading-1 box-border decoration-2 underline-offset-2">
<header class="flex flex-col items-center justify-center gap-2v px-4h py-2v">
<a href="/" class="text-2v leading-2 font-bold">
<TextHoverJump text="~/minhtrannhat.com" />
</a>
<DarkModeToggle />
<nav>
<ul class="flex items-center gap-7h">
<A end class="hover:underline" activeClass="font-bold" href={"/"}>
Home
</A>
<A
end
class="hover:underline"
activeClass="font-bold"
href={"/articles"}
>
Articles
</A>
<A
end
class="hover:underline"
activeClass="font-bold"
href={"/tags"}
>
Tags
</A>
<a href="/resume.pdf" target="_blank" rel="noreferrer">
Resume
</a>
</ul>
</nav>
</header>
<main id="main-content" class="mt-1v flex-auto">
{props.children}
</main>
<div class="debug-grid" />
</div>
</>
);
};

207
src/components/Markdown.tsx Normal file
View File

@ -0,0 +1,207 @@
import {
type Component,
createMemo,
type ParentComponent,
type JSXElement,
createSignal,
Show,
onMount,
} from "solid-js";
const P: ParentComponent = (props) => <p class="mt-1v">{props.children}</p>;
const Ol: ParentComponent = (props) => (
<ol class="list-decimal [&>li]:ml-3h">{props.children}</ol>
);
const Ul: ParentComponent = (props) => (
<ul class="list-square [&>li]:ml-2h">{props.children}</ul>
);
const Li: ParentComponent = (props) => <li class="">{props.children}</li>;
export const Blockquote: ParentComponent = (props) => (
<blockquote class="my-2v pl-1h text-slate-700 dark:text-slate-200 font-medium italic grid grid-cols-[max-content_1fr]">
<span class="w-2h">{"> "}</span>
<div class="[&>p]:mt-0">{props.children}</div>
</blockquote>
);
const Pre: ParentComponent<{ lang: string; lines?: string; file?: string }> = (
props,
) => {
const [copied, setCopied] = createSignal(false);
let ref!: HTMLPreElement;
const onCopy = () => {
setCopied(true);
navigator.clipboard.writeText(ref.innerText);
setTimeout(() => {
setCopied(false);
}, 1500);
};
return (
<div class="my-1v">
<div class="bg-black text-white dark:bg-white dark:text-black flex justify-between px-1h text-sm leading-1">
<Show when={props.file} fallback={<span aria-hidden />}>
<span>{props.file}</span>
</Show>
<button type="button" onClick={onCopy}>
{copied() ? "Copied!" : "Copy code"}
</button>
</div>
<pre ref={ref} class={`language-${props.lang}`} data-line={props.lines}>
{props.children}
</pre>
</div>
);
};
const headingLink = (children: JSXElement) =>
children?.toString().toLowerCase().replaceAll(" ", "-").replaceAll(",", "");
const HeadlineLink: Component<{ link: string; class: string }> = (props) => {
return (
<a href={props.link} class="relative top-[1px]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class={props.class}
fill="none"
>
<title>link</title>
<path
d="M9.52051 14.4359L14.4335 9.52283"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M12.5685 15.1086C13.3082 16.249 13.1108 17.418 12.2563 18.2725L9.26109 21.2678C8.28269 22.2462 6.69638 22.2462 5.71798 21.2678L2.73185 18.2816C1.75345 17.3032 1.75345 15.7169 2.73185 14.7385L5.72706 11.7433C6.429 11.0413 7.76312 10.636 8.90958 11.4662M15.1083 12.5688C16.2487 13.3085 17.4177 13.1111 18.2722 12.2566L21.2674 9.26138C22.2458 8.28297 22.2458 6.69666 21.2674 5.71825L18.2813 2.7321C17.3029 1.75369 15.7166 1.75369 14.7382 2.7321L11.743 5.72733C11.041 6.42927 10.6357 7.7634 11.4659 8.90986"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
);
};
const H2: ParentComponent = (props) => (
<h2
id={headingLink(props.children)}
class="text-2xl leading-2 font-bold mt-2v mb-1v flex items-center gap-1h scroll-mt-2v"
>
{props.children}
<HeadlineLink class="w-5 h-5" link={`#${headingLink(props.children)}`} />
</h2>
);
const H3: ParentComponent = (props) => (
<h3
id={headingLink(props.children)}
class="text-xl leading-2 font-bold mt-2v mb-1v flex items-center gap-1h scroll-mt-2v"
>
{props.children}
<HeadlineLink class="w-4 h-4" link={`#${headingLink(props.children)}`} />
</h3>
);
const H4: ParentComponent = (props) => (
<h4
id={headingLink(props.children)}
class="text-lg leading-1 font-bold mt-2v mb-1v flex items-center gap-1h scroll-mt-2v"
>
{props.children}
<HeadlineLink class="w-3 h-3" link={`#${headingLink(props.children)}`} />
</h4>
);
const A: ParentComponent<{ href: string }> = (props) => {
const isLocal = createMemo(() =>
["/", "./", "#"].some((s) => props.href.startsWith(s)),
);
return (
<a
href={props.href}
target={isLocal() ? "" : "_blank"}
class="underline underline-offset-2"
>
{props.children}
</a>
);
};
function gridCellDimensions() {
const element = document.createElement("div");
element.style.position = "fixed";
element.style.height = "var(--line-height)";
element.style.width = "1ch";
document.body.appendChild(element);
const rect = element.getBoundingClientRect();
document.body.removeChild(element);
return { width: rect.width, height: rect.height };
}
export const PostImage: Component<{
src: string;
alt: string;
attr?: JSXElement;
class?: string;
}> = (props) => {
let ref!: HTMLImageElement;
onMount(() => {
const cell = gridCellDimensions();
function setHeightFromRatio() {
const ratio = ref.naturalWidth / ref.naturalHeight;
const rect = ref.getBoundingClientRect();
const realHeight = rect.width / ratio;
const diff = cell.height - (realHeight % cell.height);
ref.style.setProperty("padding-bottom", `${diff}px`);
}
if (ref.complete) {
setHeightFromRatio();
} else {
ref.addEventListener("load", () => {
setHeightFromRatio();
});
}
});
return (
<div>
<img
ref={ref}
src={props.src}
alt={props.alt}
class="w-full"
classList={{ [props.class || ""]: !!props.class }}
/>
{props.attr}
</div>
);
};
export const Aside: ParentComponent = (props) => (
<aside class="border-l-2 border-black dark:border-white pl-1h mt-1v">
<div class="uppercase text-sm leading-1 font-medium select-none">Aside</div>
<div class="[&_*:first-child]:mt-0">{props.children}</div>
</aside>
);
export const markdownComponents = {
a: A,
p: P,
li: Li,
ol: Ol,
ul: Ul,
blockquote: Blockquote,
pre: Pre,
h2: H2,
h3: H3,
h4: H4,
};

22
src/components/Posts.tsx Normal file
View File

@ -0,0 +1,22 @@
import dayjs from "dayjs";
import { type Component, For } from "solid-js";
import type { Post } from "~/types";
export const Posts: Component<{ posts: Post[] }> = (props) => {
return (
<ol class="">
<For each={props.posts}>
{(post) => (
<li class="list-square ml-2h mb-1v">
<a class="font-medium underline block" href={`/blog/${post.slug}`}>
{post.title}
</a>
<span class="text-xs leading-1 text-slate-600 dark:text-slate-400">
{dayjs(post.date).format("MMMM YYYY")}
</span>
</li>
)}
</For>
</ol>
);
};

View File

@ -0,0 +1,20 @@
import { For, type Component } from "solid-js";
export const TextHoverJump: Component<{ text: string }> = (props) => {
return (
<span class="jump-text flex items-baseline">
<For each={[...props.text]}>
{(i, index) => (
<span
class="jump-text block"
style={{
"--animation-delay": `${index() * 20}ms`,
}}
>
{i}
</span>
)}
</For>
</span>
);
};

27
src/components/Tree.tsx Normal file
View File

@ -0,0 +1,27 @@
import { type Component, For, Show } from "solid-js";
type Node = { l: string; c: TreeNode[] };
type TreeNode = string | Node;
const Subtree: Component<{ tree: TreeNode }> = (props) => {
return (
<Show
when={typeof props.tree !== "string"}
fallback={<li>{props.tree as string}</li>}
>
<li>
<span>{(props.tree as Node).l}</span>
<ul class="incremental">
<For each={(props.tree as Node).c}>{(c) => <Subtree tree={c} />}</For>
</ul>
</li>
</Show>
);
};
export const Tree: Component<{ tree: TreeNode }> = (props) => {
return (
<ul class="tree [&>li>span]:font-bold">
<Subtree tree={props.tree} />
</ul>
);
};

199
src/css/prism-theme.css Normal file
View File

@ -0,0 +1,199 @@
/**
* Shades of Purple Theme for Prism.js
*
* @author Ahmad Awais <https://twitter.com/MrAhmadAwais/>
* @support Follow/tweet at https://twitter.com/MrAhmadAwais/
*/
code[class*='language-'],
pre[class*='language-'] {
@apply text-black dark:text-white font-medium;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*='language-']::-moz-selection,
pre[class*='language-'] ::-moz-selection,
code[class*='language-']::-moz-selection,
code[class*='language-'] ::-moz-selection,
pre[class*='language-']::selection,
pre[class*='language-'] ::selection,
code[class*='language-']::selection,
code[class*='language-'] ::selection {
color: inherit;
}
/* Code blocks. */
pre[class*='language-'] {
@apply box-border border-black dark:border-white border-2;
padding: calc(var(--line-height) - var(--border-thickness)) calc(2ch - var(--border-thickness));
overflow: auto;
}
/*
:not(pre)>code[class*='language-'],
pre[class*='language-'] {
}
@media (prefers-color-scheme: dark) {
:not(pre)>code[class*='language-'],
pre[class*='language-'] {
@apply border-white;
}
} */
/* Inline code */
/* :not(pre)>code[class*='language-'] {
} */
.token {
font-weight: 400;
}
.token.comment,
.token.prolog,
.token.cdata {
@apply text-slate-600 dark:text-slate-400;
}
.token.delimiter,
.token.keyword,
.token.selector,
.token.important,
.token.atrule {
@apply text-black dark:text-white;
}
.token.operator,
.token.attr-name {
@apply font-medium text-black dark:text-white;
}
.token.punctuation {
@apply text-slate-500;
}
.token.boolean {
@apply font-medium text-black dark:text-white;
}
.token.tag,
.token.tag .punctuation,
.token.doctype,
.token.builtin {
@apply font-medium text-black dark:text-white;
}
.token.entity,
.token.symbol {
@apply font-medium text-black dark:text-white;
}
.token.number {
@apply font-medium text-black dark:text-white;
}
.token.property,
.token.constant,
.token.variable {
@apply font-medium text-black dark:text-white;
}
.token.string,
.token.char {
@apply text-slate-800 dark:text-slate-200;
font-style: italic;
}
.token.attr-value,
.token.attr-value .punctuation {
color: #a5c261;
}
.token.attr-value .punctuation:first-child {
color: #a9b7c6;
}
.token.url {
color: #287bde;
text-decoration: underline;
}
.token.function {
@apply font-bold text-black dark:text-white;
}
/* .token.regex {
} */
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.inserted {
background: #00ff00;
}
.token.deleted {
background: #ff000d;
}
code.language-css .token.property,
code.language-css .token.property+.token.punctuation {
color: #a9b7c6;
}
code.language-css .token.id {
color: #ffc66d;
}
code.language-css .token.selector>.token.class,
code.language-css .token.selector>.token.attribute,
code.language-css .token.selector>.token.pseudo-class,
code.language-css .token.selector>.token.pseudo-element {
color: #ffc66d;
}
.token.class-name {
@apply font-medium text-slate-700 dark:text-slate-300;
font-style: italic;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
background: none;
}
.line-highlight.line-highlight {
margin-top: 36px;
background: linear-gradient(to right, rgba(179, 98, 255, 0.17), transparent);
}
.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
content: '';
}

0
src/data/posts.json Normal file
View File

8
src/data/posts.ts Normal file
View File

@ -0,0 +1,8 @@
// @ts-expect-error
import Posts from "./posts.json";
import type { Post } from "~/types";
export const posts: Post[] = Posts.map((p: Post) => ({
...p,
date: new Date(p.date),
}));

21
src/data/tags.ts Normal file
View File

@ -0,0 +1,21 @@
import { posts } from "~/data/posts";
import type { Tag } from "~/types";
export const tags: Record<string, Tag> = posts.reduce(
(a, p, i) => {
if (Array.isArray(p.tags)) {
for (const t of p.tags) {
if (!a[t]) {
a[t] = {
id: t,
posts: [],
};
}
a[t].posts.push(i);
}
}
return a;
},
{} as Record<string, Tag>,
);

View File

@ -0,0 +1,48 @@
---
date: 20204-05-11
tags:
- solidjs
- webdev
title: How to enumerate routes in solid-start
---
This website is built with [solid-start](https://start.solidjs.com/getting-started/what-is-solidstart).
I needed a way to enumerate all of the posts under `/blog` so that I could dynamically generate lists, both for the [home page](/), and for the tags pages: [/tags/solidjs](/tags/solidjs).
However, the solid-start `<FileRouter>` doesn't expose any of that information, either at build-time or run-time. We have to figure it out ourselves.
My first thought was to have a script that watches the directory and writes to a file.
That would've worked. But while browsing through the docs trying to figure out how to get a file watcher to be part of the vite dev server, I found out about [virtual modules](https://vitejs.dev/guide/api-plugin#virtual-modules-convention).
Essentially, you can define a `js` module that has dynamic content by exporting a `js` string.
```js lang="js" lines="2,14"
export default function myPlugin() {
const virtualModuleId = 'virtual:my-module'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'my-plugin', // required, will show up in warnings and errors
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const msg = "from virtual module"`
}
},
}
}
```
After adding `myPlugin` to your vite plugins list, you can import the virtual module anywhere in the code and use it as if it were a real file.
```js lang="js"
import {msg} from "virtual:my-module"
```
This seemed more "elegant" than a file watcher.

View File

@ -0,0 +1,34 @@
---
date: 20204-05-11
tags:
- solidjs
- webdev
title: This blog uses solid-start.
description: How to set up a blog using solid-start and mdx.
---
This website uses [solid-start](https://start.solidjs.com/getting-started/what-is-solidstart).
solid-start is a Next.js-like framework for creating SSR / SSG websites. But instead of react, it builds on top of solid-js.
I've been using solid and solid-start professionally for almost 2 years. And while solid-js feels rock-solid, solid-start still feels rough around the edges.
For each of the features I wanted to implement, I ran into issues that delayed this from being a weekend project into taking about 4 weekends instead.
Here's what I wanted to accomplish:
1. Use [mdx](https://mdxjs.com/) for writing the posts
2. Define metadata for each post in the same mdx file
3. Full SSG
4. Code highlighting at compile time
5. Tags
And here's how I did it:
## Using mdx
MDX is markdown that you can intersprinkle with jsx.
The initial scaffolding is easy, follow [these steps](https://docs.solidjs.com/solid-start/getting-started), and choose the `with-mdx` option.
I opted to have my posts live under the `/blog` path.

5
src/entry-client.tsx Normal file
View File

@ -0,0 +1,5 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
// biome-ignore lint/style/noNonNullAssertion: <explanation>
mount(() => <StartClient />, document.getElementById("app")!);

25
src/entry-server.tsx Normal file
View File

@ -0,0 +1,25 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<script>
{`document.documentElement.classList.toggle('dark', window.matchMedia('(prefers-color-scheme: dark)').matches)`}
</script>
{assets}
</head>
<body class="font-mono bg-white dark:bg-black dark:text-white">
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

1
src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

67
src/routes/(home).tsx Normal file
View File

@ -0,0 +1,67 @@
import { For } from "solid-js";
import { posts } from "~/data/posts";
import { Posts } from "~/components/Posts";
const links = [
"https://github.com/minhtrannhat",
"https://linkedin.com/in/minh-tran-nhat",
];
const Homepage = () => {
return (
<div>
<section class="flex flex-col sm:flex-row gap-2v sm:gap-3h">
<div class="font-medium">
<div class="flex items-end mb-1v gap-1h">
<img
class="inline-block h-2v select-none wave-image"
alt="wave emoji"
src="/images/wave-pixel.png"
/>
<p>Hi, Minh here.</p>
</div>
<p class="mb-1v">
I'm Minh Tran, a Computer Engineering student at Concordia
University, Montreal, Canada.
<br />
<br />
I'm most passionate about designing distributed systems that scales
but I'm also interested in compilers and systems programming. When
I'm not coding, I read books, listen to podcasts or study music
theory.
</p>
<p>
Say hi:{" "}
<a
class="underline"
target="_blank"
rel="noreferrer"
href="mailto:minh@minhtrannhat.com"
>
minh@minhtrannhat.com
</a>
</p>
</div>
<ul class="sm:mt-3v text-slate-600 dark:text-slate-200 text-base sm:text-sm leading-1">
<For each={links}>
{(link) => (
<li class="list-square hover:text-black dark:hover:text-white ml-2h leading-1">
<a
target="_blank"
rel="noreferrer"
href={link}
class="underline"
>
{link.replace("https://", "")}
</a>
</li>
)}
</For>
</ul>
</section>
<hr />
</div>
);
};
export default Homepage;

5
src/routes/[...404].mdx Normal file
View File

@ -0,0 +1,5 @@
import { HttpStatusCode } from "@solidjs/start";
<HttpStatusCode code={404} />
# Page Not Found

View File

@ -0,0 +1,30 @@
import { posts } from "~/data/posts";
import dayjs from "dayjs";
import { For } from "solid-js";
const Articles = () => {
return (
<div>
<ol class="flex flex-col gap-1v list-square ml-2h">
<For each={Object.values(posts)}>
{(post) => (
<li class="list-square ml-2h mb-1v">
<a
class="font-medium underline block"
href={`/blog/${post.slug}`}
>
{post.title}
</a>
<span class="text-xs leading-1 text-slate-600 dark:text-slate-400">
{dayjs(post.date).format("MMMM YYYY")}
</span>
</li>
)}
</For>
</ol>
</div>
);
};
export default Articles;

87
src/routes/blog.tsx Normal file
View File

@ -0,0 +1,87 @@
import { For, Show } from "solid-js";
import type { RouteSectionProps } from "@solidjs/router";
import { Meta, Title } from "@solidjs/meta";
import { posts } from "~/data/posts";
import { MDXProvider } from "solid-mdx";
import { markdownComponents, PostImage } from "~/components/Markdown";
import dayjs from "dayjs";
import "../css/prism-theme.css";
import type { Post } from "~/types";
const Blog = (props: RouteSectionProps<unknown>) => {
const meta = () =>
posts.find((p) => props.location.pathname.endsWith(p.slug)) as Post;
const index = () => posts.indexOf(meta());
const prevMeta = () =>
index() === posts.length - 1 ? undefined : posts[index() + 1];
const nextMeta = () => (index() === 0 ? undefined : posts[index() - 1]);
return (
<article class="pb-5v">
<Title>minhtrannhat.com - {meta()?.title}</Title>
<Meta name="og:title" content={meta().title} />
<Meta name="description" content={meta().description} />
<Meta name="og:description" content={meta().description} />
<Show when={meta().featuredImage}>
<PostImage
class="mb-3v saturate-0"
src={meta().featuredImage || ""}
alt={meta().featuredImageDesc || ""}
/>
</Show>
<h1 class="text-2v leading-2 font-bold mb-1v">{meta().title}</h1>
<div class="flex items-center gap-4h mb-2v text-sm leading-1">
<p>{dayjs(meta().date).format("D MMMM YYYY")}</p>
<div class="">
<For each={meta().tags}>
{(tag, index) => (
<>
<a
href={`/tags/${tag}`}
class="font-medium underline underline-offset-2 italic"
>
{tag}
</a>
{index() === meta().tags.length - 1 ? "" : ", "}
</>
)}
</For>
</div>
</div>
<MDXProvider components={markdownComponents}>
{props.children}
</MDXProvider>
<div class="mt-3v flex flex-col gap-1v">
<Show when={prevMeta()} fallback={<div />}>
<div class="flex gap-1h">
<span>Previous:</span>
<a
class="underline underline-offset-2"
href={`/blog/${prevMeta()?.slug}`}
>
{prevMeta()?.title}
</a>
</div>
</Show>
<Show when={nextMeta()} fallback={<div />}>
<div class="flex gap-1h">
<span>Next:</span>
<a
class="underline underline-offset-2"
href={`/blog/${nextMeta()?.slug}`}
>
{nextMeta()?.title}
</a>
</div>
</Show>
</div>
</article>
);
};
export default Blog;

View File

@ -0,0 +1,27 @@
import { For } from "solid-js";
import { tags } from "~/data/tags";
const Tags = () => {
return (
<div>
<h1 class="text-xl font-bold mt-2v mb-1v">All tags:</h1>
<ol class="flex flex-col gap-1v list-square ml-2h">
<For each={Object.values(tags)}>
{(tag) => (
<li class="">
<a class="underline underline-offset-2" href={`/tags/${tag.id}`}>
{tag.id}
</a>
<span>
{" "}
- {tag.posts.length} Post{tag.posts.length === 1 ? "" : "s"}
</span>
</li>
)}
</For>
</ol>
</div>
);
};
export default Tags;

20
src/routes/tags/[id].tsx Normal file
View File

@ -0,0 +1,20 @@
import type { RouteSectionProps } from "@solidjs/router";
import { type Component, Show } from "solid-js";
import { posts } from "~/data/posts";
import { Posts } from "~/components/Posts";
import { tags } from "~/data/tags";
const TagId: Component<RouteSectionProps<unknown>> = (props) => {
const tag = () => tags[props.params.id];
return (
<Show when={tag()} fallback={<div>No posts with that tag</div>}>
<div>
<h1 class="text-lg font-bold mb-6">Tag: {tag().id}</h1>
<Posts posts={tag().posts.map((i) => posts[i])} />
</div>
</Show>
);
};
export default TagId;

16
src/types.ts Normal file
View File

@ -0,0 +1,16 @@
export type Post = {
title: string;
date: Date;
slug: string;
tags: string[];
featuredImage?: string;
featuredImageDesc?: string;
description: string;
};
export type Tag = {
// id/name of tag
id: string;
// indexes of posts with tag (they point to the posts list coming from virtual:blog-posts)
posts: number[];
};

View File

@ -0,0 +1,73 @@
import type { Accessor } from "solid-js";
import { isServer } from "solid-js/web";
import { useRouteTransitionTiming } from "./useRouteTransitionTiming";
export const useDitherAnimation = (ref: Accessor<HTMLElement | undefined>) => {
if (!isServer) {
const d1 = document.createElement("div");
d1.classList.add("dither", "dither-1");
const d2 = document.createElement("div");
d2.classList.add("dither", "dither-2");
const d3 = document.createElement("div");
d3.classList.add("dither", "dither-3");
let started = false;
useRouteTransitionTiming(
300,
() => {
ref()?.appendChild(d1);
setTimeout(() => {
ref()?.appendChild(d2);
}, 100);
setTimeout(() => {
ref()?.appendChild(d3);
}, 200);
started = true;
},
() => {
const rnd = () =>
setTimeout(() => {
try {
d1.style.backgroundPosition = `${Math.round(
Math.random() * 100,
)}px ${Math.round(Math.random() * 100)}px`;
d2.style.backgroundPosition = `${Math.round(
Math.random() * 100,
)}px ${Math.round(Math.random() * 100)}px`;
d3.style.backgroundPosition = `${Math.round(
Math.random() * 100,
)}px ${Math.round(Math.random() * 100)}px`;
} catch (error) {
console.error(error);
}
if (started) {
rnd();
}
}, 100);
rnd();
},
() => {
started = false;
try {
ref()?.removeChild(d3);
} catch (error) {
console.error(error);
}
setTimeout(() => {
try {
ref()?.removeChild(d2);
} catch (error) {
console.error(error);
}
}, 100);
setTimeout(() => {
try {
ref()?.removeChild(d1);
} catch (error) {
console.error(error);
}
}, 200);
},
);
}
};

View File

@ -0,0 +1,24 @@
import { useIsRouting, useBeforeLeave } from "@solidjs/router";
import { createEffect } from "solid-js";
export const useRouteTransitionTiming = (
transitionTime: number,
onEnter: () => void,
onLoading: () => void,
onExit: () => void,
) => {
const isRouting = useIsRouting();
createEffect((oldR: boolean | undefined) => {
const r = isRouting();
if (oldR && !r) onExit();
return r;
});
useBeforeLeave((e) => {
e.preventDefault();
onEnter();
setTimeout(() => {
e.retry(true);
onLoading();
}, transitionTime);
});
};

76
tailwind.config.ts Normal file
View File

@ -0,0 +1,76 @@
import type { Config } from "tailwindcss";
const lineHeight = {
val: 1.2,
unit: "rem",
};
const buildLineHeights = () => {
const h: Record<string, string> = {};
for (let i = 1; i <= 10; i++) {
h[i.toString()] = `${i * lineHeight.val}${lineHeight.unit}`;
}
return h;
};
const buildSpacing = () => {
const h: Record<string, string> = {};
for (let i = 1; i <= 20; i++) {
h[`${i}v`] = `${i * lineHeight.val}${lineHeight.unit}`;
h[`${i}h`] = `${i}ch`;
}
return h;
};
const config: Config = {
content: ["./src/**/*.{html,tsx,mdx}"],
darkMode: "class",
theme: {
fontFamily: {
mono: '"JetBrains Mono", monospace;',
},
fontWeight: {
normal: "500",
medium: "600",
bold: "800",
},
maxWidth: {
thread: "calc(min(80ch, round(down, 100%, 1ch)))",
},
lineHeight: buildLineHeights(),
colors: {
black: "#000000",
"slate-50": "#f8fafc",
"slate-100": "#f1f5f9",
"slate-200": "#e2e8f0",
"slate-300": "#cbd5e1",
"slate-400": "#94a3b8",
"slate-500": "#64748b",
"slate-600": "#475569",
"slate-700": "#334155",
"slate-800": "#1e293b",
"slate-900": "#0f172a",
"slate-950": "#020617",
white: "#FFFFFF",
transparent: "transparent",
},
borderWidth: {
"2": "2px",
},
extend: {
spacing: buildSpacing(),
listStyleType: {
square: "square",
},
boxShadow: {
box: "3px 3px 0px",
},
fontSize: {
"2v": "2rem",
},
},
},
plugins: [],
};
export default config;

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vinxi/client", "vite/client", "solid-jsx/types"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}