initial commit
29
.gitignore
vendored
Normal 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
@ -0,0 +1,11 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"andi",
|
||||
"mapbox",
|
||||
"owickstrom",
|
||||
"prismjs",
|
||||
"rehype",
|
||||
"solidjs",
|
||||
"vinxi"
|
||||
]
|
||||
}
|
32
README.md
Normal 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
@ -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
@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
49
build-helpers/blogPostsPlugin.ts
Normal 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
@ -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;
|
||||
}
|
||||
};
|
45
package.json
Normal 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
11
postcss.config.js
Normal 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
After Width: | Height: | Size: 487 B |
6
public/favicon.svg
Normal 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
After Width: | Height: | Size: 86 KiB |
BIN
public/images/brutalist_building.jpg
Normal file
After Width: | Height: | Size: 73 KiB |
BIN
public/images/dither_light.pyxel
Normal file
BIN
public/images/dither_light_3.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
public/images/magic.jpeg
Normal file
After Width: | Height: | Size: 488 KiB |
BIN
public/images/toggle.aseprite
Normal file
BIN
public/images/toggle.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
public/images/wave-pixel-dark.png
Normal file
After Width: | Height: | Size: 262 B |
BIN
public/images/wave-pixel.png
Normal file
After Width: | Height: | Size: 258 B |
BIN
public/images/wave-pixel.pyxel
Normal file
BIN
public/images/wobbly_wheel.png
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
public/logo-dark.png
Normal file
After Width: | Height: | Size: 245 KiB |
BIN
public/logo-light.png
Normal file
After Width: | Height: | Size: 388 KiB |
BIN
public/resume.pdf
Normal file
331
src/app.css
Normal 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
@ -0,0 +1,5 @@
|
||||
declare const styles: {
|
||||
readonly "visually-hidden": string;
|
||||
};
|
||||
export = styles;
|
||||
|
38
src/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
src/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
69
src/components/DarkModeToggle.tsx
Normal 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
@ -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
@ -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
@ -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>
|
||||
);
|
||||
};
|
20
src/components/TextHoverJump.tsx
Normal 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
@ -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
@ -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
8
src/data/posts.ts
Normal 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
@ -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>,
|
||||
);
|
48
src/drafts/solid-start-enumerate-routes.mdx
Normal 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.
|
34
src/drafts/this-blog-uses-solid-start.mdx
Normal 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
@ -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
@ -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
@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
67
src/routes/(home).tsx
Normal 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
@ -0,0 +1,5 @@
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
|
||||
<HttpStatusCode code={404} />
|
||||
|
||||
# Page Not Found
|
30
src/routes/articles/index.tsx
Normal 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
@ -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;
|
27
src/routes/tags/(tags).tsx
Normal 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
@ -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
@ -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[];
|
||||
};
|
73
src/useDitherAnimation.tsx
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
24
src/useRouteTransitionTiming.tsx
Normal 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
@ -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
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|