feat: Initialize new repo

This commit is contained in:
Jesse Wierzbinski 2024-10-19 22:46:11 +02:00
commit e7941231a5
No known key found for this signature in database
23 changed files with 858 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

5
app.vue Normal file
View file

@ -0,0 +1,5 @@
<template>
<NuxtLayout name="app">
<NuxtPage />
</NuxtLayout>
</template>

81
biome.json Normal file
View file

@ -0,0 +1,81 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"all": true,
"correctness": {
"noNodejsModules": "off",
"useHookAtTopLevel": "off",
"noUnusedVariables": "off",
"noUnusedImports": "off"
},
"performance": {
"useTopLevelRegex": "off"
},
"complexity": {
"noExcessiveCognitiveComplexity": "off"
},
"suspicious": {
"noMisplacedAssertion": "off",
"noConsole": "off"
},
"style": {
"noDefaultExport": "off",
"noParameterProperties": "off",
"noNamespaceImport": "off",
"useFilenamingConvention": "off",
"useDefaultSwitchClause": "off",
"useNamingConvention": {
"level": "warn",
"options": {
"requireAscii": false,
"strictCase": false,
"conventions": [
{
"selector": {
"kind": "typeProperty"
},
"formats": [
"camelCase",
"CONSTANT_CASE",
"PascalCase",
"snake_case"
]
},
{
"selector": {
"kind": "objectLiteralProperty",
"scope": "any"
},
"formats": [
"camelCase",
"CONSTANT_CASE",
"PascalCase",
"snake_case"
]
}
]
}
}
},
"nursery": {
"noDuplicateElseIf": "warn"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4
},
"javascript": {
"globals": ["Bun", "HTMLRewriter", "BufferEncoding"]
},
"files": {
"ignore": ["node_modules", "dist", ".nuxt"]
}
}

BIN
bun.lockb Executable file

Binary file not shown.

View file

@ -0,0 +1,62 @@
<template>
<!-- <article
class="$style.content mx-auto max-w-3xl prose prose-invert mt-10 prose-code:before:content-none prose-code:after:content-none prose-a:text-orange-500 prose-a:underline"
v-html="body"></article> -->
<article :class="[$style.content, 'prose prose-invert prose-code:before:content-none prose-code:after:content-none']" v-html="body"></article>
</template>
<script lang="ts" setup>
import { defineProps } from "vue";
defineProps<{
body: string;
}>();
</script>
<style lang="postcss" module>
.content h1,
.content h2,
.content h3,
.content h4,
.content h5 {
scroll-margin-top: 8rem;
@apply block relative;
}
.content img {
@apply drop-shadow-2xl w-full rounded bg-zinc-900 ring-1 ring-white/10 mx-auto;
}
.content figure figcaption img {
@apply h-6 w-6 flex-none rounded-full bg-gray-800;
}
.content :global .header-anchor {
@apply no-underline absolute w-16 md:w-auto text-right left-[calc(100%-3.75rem)] md:-left-10 text-gray-200
}
.content :global .header-anchor::before {
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='1.5em' width='1.5em' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23e5e7eb' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m9 15l6-6m-4-3l.463-.536a5 5 0 0 1 7.071 7.072L18 13m-5 5l-.397.534a5.068 5.068 0 0 1-7.127 0a4.972 4.972 0 0 1 0-7.071L6 11'/%3E%3C/svg%3E");
}
.content ul li input[type="checkbox"],
.content ol li input[type="checkbox"] {
@apply rounded text-orange-500 mb-0 mt-0 mr-3;
}
.content pre:has(code) {
white-space: pre;
word-break: normal;
word-spacing: normal;
overflow-x: auto;
@apply ring-1 ring-white/10 mt-4 bg-white/5 px-4 py-3 rounded;
}
.content pre code {
@apply block p-0;
}
.content code:not(pre code) {
@apply rounded px-2 py-1 ring-1 ring-white/10 bg-white/5;
}
</style>

View file

@ -0,0 +1,12 @@
<template>
<nuxt-img :src="image" width="800" format="webp" alt=""
class="drop-shadow-2xl w-full rounded bg-zinc-900 ring-1 ring-white/10" />
</template>
<script lang="ts" setup>
import { defineProps } from "vue";
defineProps<{
image: string;
}>();
</script>

View file

@ -0,0 +1,31 @@
<template>
<div class="mx-auto max-w-2xl text-center flex items-center justify-center gap-8 flex-col">
<h1 v-if="title" class="text-4xl font-bold tracking-tight text-gray-50 sm:text-5xl">
{{ title }}
</h1>
<div>
<time data-allow-mismatch v-if="created_at" :datetime="created_at" class="text-gray-500">
{{ formatDate(created_at) }}
</time>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineProps } from "vue";
defineProps<{
title: string;
created_at: string;
}>();
const formatDate = (date?: string) => {
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
}).format(Date.parse(date ?? new Date().toISOString()));
};
</script>

View file

@ -0,0 +1,83 @@
import { access, opendir, readFile } from "node:fs/promises";
import { join } from "node:path";
import type { FrontMatter, Post } from "~/types/posts";
import {
getMarkdownRenderer,
parseFrontMatter,
stripFrontMatter,
} from "~/utils/markdown";
export const getPost = async (path: string): Promise<Post | null> => {
const filePath = join(process.cwd(), "content", `${path}.md`);
if (
!(await access(filePath)
.then(() => true)
.catch(() => false))
) {
return null;
}
const contents = await readFile(filePath, "utf8");
const header = parseFrontMatter<FrontMatter>(contents);
if (!header) {
return null;
}
const renderedBody = await getMarkdownRenderer().then((renderer) =>
renderer.render(stripFrontMatter(contents)),
);
return {
author: {
handle: header.author_handle,
image: header.author_image,
name: header.author,
},
private: header.private === "true",
content: renderedBody,
created_at: new Date(Number(header.created_at || 0)).toISOString(),
description: header.description,
image: header.image,
title: header.title,
path,
};
};
export const getPostList = async (): Promise<Post[] | null> => {
const directoryPath = join(process.cwd(), "content");
const dir = await opendir(directoryPath, {
recursive: true,
});
const files: string[] = [];
for await (const dirent of dir) {
if (dirent.isFile() && dirent.name.endsWith(".md")) {
files.push(
// Remove process.cwd() and .md from the path
join(dirent.parentPath, dirent.name.replace(".md", "")).replace(
join(process.cwd(), "content"),
"",
),
);
}
}
const results: Post[] = [];
for (const file of files) {
const post = await getPost(file.replace(".md", ""));
if (post) {
results.push(post);
}
}
return results.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
};

30
content/test/test-file.md Normal file
View file

@ -0,0 +1,30 @@
---
title: A test post for the Versia Blog
created_at: 1700020246000
description: This is a test post for the Versia Blog. I write this post to test the blog system.
image: https://images.pexels.com/photos/2646237/pexels-photo-2646237.jpeg
author: CPlusPatch
author_image: https://mk-cdn.cpluspatch.com/uploads/5cd850d3-6b6b-4543-97ca-9854b9dbf9f3.webp
author_handle: @jessewh
---
Lorem ipsum odor amet, consectetuer adipiscing elit. Habitasse augue eu phasellus volutpat aliquam venenatis dui. Fusce vitae vivamus per lectus, semper tristique. Lacus semper nam natoque cras gravida facilisis accumsan vivamus. Cras euismod non taciti ligula aptent cras. Mauris adipiscing curae mauris aliquet mi venenatis tempor. Quis congue sollicitudin ullamcorper purus non netus nascetur. Ipsum congue scelerisque tristique lobortis amet.
# I love headers
![Image](https://images.pexels.com/photos/2646237/pexels-photo-2646237.jpeg)
Faucibus habitant non tortor maximus bibendum suscipit accumsan scelerisque felis. Mattis aliquet sociosqu montes pretium fusce cras nibh. Diam morbi habitant vulputate morbi; risus suscipit. Vulputate facilisis quam primis penatibus vel elementum dolor tempor. Posuere dictumst est tempus purus rutrum risus faucibus faucibus. Sem per donec nisl sociosqu inceptos eu velit. Non aenean sagittis lacus vivamus donec ac. Ipsum morbi luctus parturient, dignissim justo massa ipsum. Metus augue nascetur ornare mattis sagittis nec blandit vitae. Semper vivamus accumsan suspendisse risus senectus molestie. `moe lester`.
```python
def hello():
print("Hello, World!")
```
## Me too
Eleifend platea conubia turpis enim iaculis nisi. Habitant congue proin elementum sed ultrices turpis aptent. Sem arcu magnis sollicitudin convallis ullamcorper vitae. Aporta dignissim praesent vitae efficitur habitant. Gravida tincidunt quam facilisis, pulvinar ante et conubia. Sapien quisque ex tortor bibendum ut feugiat felis faucibus ornare. Magna auctor platea vel non massa laoreet venenatis dis auctor. Iaculis mus tempor hendrerit ullamcorper fringilla odio donec ex. Amet rutrum magna efficitur a ad per accumsan.
Vulputate est bibendum lobortis cubilia quisque habitasse. Ante suspendisse libero consequat quis suspendisse aenean. Eget pharetra turpis arcu varius sapien? Cras vel tempus fermentum volutpat ad fusce mauris. Dignissim ad ligula lacinia cursus sodales. Condimentum erat mattis arcu mus velit vitae fames. Mollis faucibus consectetur varius in finibus duis sollicitudin aliquam torquent. Venenatis imperdiet mollis velit maximus duis enim habitant.
Faucibus tempus massa senectus malesuada vestibulum tristique. Platea nibh erat euismod libero, felis luctus egestas. Dis iaculis nascetur platea dis urna varius tempor condimentum lacinia. Ullamcorper tempus ad et proin tortor. Odio odio lobortis ac posuere maecenas nibh tempus dis. Ipsum pretium senectus pretium eu vulputate. Taciti viverra rhoncus ipsum egestas natoque praesent. Congue interdum scelerisque consequat, ornare penatibus tincidunt litora.

11
layouts/app.vue Normal file
View file

@ -0,0 +1,11 @@
<template>
<div :class="[$style.content, 'w-full h-full bg-gradient-to-tr from-zinc-900 to-zinc-800']">
<slot />
</div>
</template>
<style lang="css" module>
.content {
font-family: Inter, sans-serif;
}
</style>

76
nuxt.config.ts Normal file
View file

@ -0,0 +1,76 @@
import { defineNuxtConfig } from "nuxt/config";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
"@nuxt/image",
"nuxt-security",
"@nuxt/fonts",
"@nuxtjs/tailwindcss",
"@vueuse/nuxt",
"@nuxt/icon",
"@nuxtjs/seo",
],
compatibilityDate: "2024-04-03",
devtools: { enabled: true },
// Disable automatic component imports (for readability)
components: false,
robots: {
blockNonSeoBots: true,
disallow: [
"AI2Bot",
"Ai2Bot-Dolma",
"Amazonbot",
"anthropic-ai",
"Applebot",
"Applebot-Extended",
"Bytespider",
"CCBot",
"ChatGPT-User",
"Claude-Web",
"ClaudeBot",
"cohere-ai",
"Diffbot",
"FacebookBot",
"facebookexternalhit",
"FriendlyCrawler",
"Google-Extended",
"GoogleOther",
"GoogleOther-Image",
"GoogleOther-Video",
"GPTBot",
"iaskspider/2.0",
"ICC-Crawler",
"ImagesiftBot",
"img2dataset",
"ISSCyberRiskCrawler",
"Kangaroo Bot",
"Meta-ExternalAgent",
"Meta-ExternalFetcher",
"OAI-SearchBot",
"omgili",
"omgilibot",
"PerplexityBot",
"PetalBot",
"Scrapy",
"Sidetrade indexer bot",
"Timpibot",
"VelenPublicWebCrawler",
"Webzio-Extended",
"YouBot",
],
},
future: {
compatibilityVersion: 4,
},
image: {
domains: ["images.pexels.com"],
},
nitro: {
preset: "bun",
minify: true,
prerender: {
failOnError: true,
},
},
});

63
package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "@versia/blog",
"private": true,
"type": "module",
"description": "The source code for Versia's blog.",
"author": {
"email": "contact@cpluspatch.com",
"name": "Jesse Wierzbinski",
"url": "https://cpluspatch.com"
},
"bugs": {
"url": "https://github.com/versia-pub/blog/issues"
},
"license": "CC0-1.0",
"maintainers": [
{
"email": "contact@cpluspatch.com",
"name": "Jesse Wierzbinski",
"url": "https://cpluspatch.com"
}
],
"repository": {
"type": "git",
"url": "git+https://github.com/versia-pub/blog.git"
},
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "bunx @biomejs/biome check .",
"typecheck": "bunx tsc -p ."
},
"dependencies": {
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@nuxt/fonts": "^0.10.0",
"@nuxt/icon": "^1.5.6",
"@nuxt/image": "^1.8.1",
"@nuxtjs/seo": "^2.0.0-rc.23",
"@nuxtjs/tailwindcss": "^6.12.2",
"@shikijs/markdown-it": "^1.22.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@vueuse/nuxt": "^11.1.0",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-container": "^4.0.0",
"markdown-it-toc-done-right": "^4.2.0",
"nuxt": "^3.13.2",
"nuxt-security": "^2.0.0",
"shiki": "^1.22.0",
"vue": "latest",
"vue-router": "latest"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.1.11",
"@types/markdown-it-container": "^2.0.10",
"tailwindcss": "^3.4.14"
},
"trustedDependencies": ["@biomejs/biome", "esbuild", "sharp", "vue-demi"]
}

View file

@ -0,0 +1,75 @@
<template>
<div v-if="post" class="mx-auto max-w-3xl py-24 sm:py-32 px-6 lg:px-8 flex flex-col items-center gap-10">
<Title v-if="post.title" :created_at="post.created_at" :title="post.title" />
<Image v-if="post.image" :image="post.image" />
<Content :body="body" />
</div>
</template>
<script lang="ts" setup>
import Content from "~/components/article/content.vue";
import Image from "~/components/article/image.vue";
import Title from "~/components/article/title.vue";
import type { Post } from "~/types/posts";
import {
createError,
defineArticle,
useFetch,
useImage,
useRoute,
useSchemaOrg,
useServerSeoMeta,
} from "#imports";
const filePath = (useRoute().params.path as string[]).join("/");
const { data: post } = await useFetch<Post>(
`/api/article?path=${encodeURIComponent(`/${filePath}`)}`,
);
if (!post.value) {
throw createError({
statusCode: 404,
message: "Post not found",
});
}
useSchemaOrg([
defineArticle({
// author: post.value.author.name,
datePublished: post.value.created_at,
image: post.value.image,
description: post.value.description,
inLanguage: "en-US",
thumbnailUrl: post.value.image,
}),
]);
useServerSeoMeta({
title: post.value.title,
ogTitle: post.value.title,
author: post.value.author.name,
description: post.value.description,
ogDescription: post.value.description,
ogImage: post.value.image,
twitterCard: "summary_large_image",
});
let body = post.value.content;
// Fix for optimizing images during prerendering
const img = useImage();
// Find all links of type /_ipx/ in body
const ipxLinks = body.match(/\/_ipx\/[^"]+/g) || [];
for (const ipxLink of ipxLinks) {
body = body.replace(
ipxLink,
// Replace the link with the optimized image
img(`/${ipxLink.split("/").slice(3).join("/")}` || "", {
width: 800,
format: "webp",
}),
);
}
</script>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

24
server/api/article.get.ts Normal file
View file

@ -0,0 +1,24 @@
import { join } from "node:path";
import { getPost } from "~/composables/server/Post";
import { createError, defineEventHandler } from "#imports";
export default defineEventHandler((event) => {
const url = new URL(
event.node.req.url ?? "",
`http://${event.node.req.headers.host}`,
);
// Get the path query parameter
const filePath = decodeURIComponent(url.searchParams.get("path") ?? "");
const post = getPost(filePath);
if (!post) {
throw createError({
statusCode: 404,
message: "Post not found",
});
}
return post;
});

View file

@ -0,0 +1,8 @@
import { getPostList } from "~/composables/server/Post";
import { defineEventHandler } from "#imports";
export default defineEventHandler(async () => {
const files = await getPostList();
return files ?? [];
});

3
server/tsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

9
tailwind.config.ts Normal file
View file

@ -0,0 +1,9 @@
import forms from "@tailwindcss/forms";
import typography from "@tailwindcss/typography";
import type { Config } from "tailwindcss";
// Default are on https://tailwindcss.nuxtjs.org/tailwind/config#default-configuration
export default (<Partial<Config>>{
plugins: [forms, typography],
content: [],
});

4
tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

1
types.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module "@hackmd/markdown-it-task-lists";

28
types/posts.ts Normal file
View file

@ -0,0 +1,28 @@
export interface FrontMatter {
title: string;
description: string;
image: string;
created_at: string;
private?: string;
author: string;
author_image: string;
author_handle: string;
}
export interface Post {
title: string;
description: string;
image: string;
banner?: string;
author: Author;
private: boolean;
created_at: string;
content: string;
path: string;
}
export interface Author {
name: string;
image: string;
handle: string;
}

153
utils/markdown.ts Normal file
View file

@ -0,0 +1,153 @@
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import { fromHighlighter } from "@shikijs/markdown-it/core";
import MarkdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { createHighlighterCore } from "shiki/core";
const highlighter = createHighlighterCore({
themes: [import("shiki/themes/rose-pine.mjs")],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
import("shiki/langs/python.mjs"),
import("shiki/langs/toml.mjs"),
import("shiki/langs/rust.mjs"),
import("shiki/langs/sql.mjs"),
import("shiki/langs/json.mjs"),
import("shiki/langs/html.mjs"),
import("shiki/langs/css.mjs"),
import("shiki/langs/scss.mjs"),
import("shiki/langs/bash.mjs"),
import("shiki/langs/shell.mjs"),
import("shiki/langs/yaml.mjs"),
],
loadWasm: import("shiki/wasm"),
});
export const getMarkdownRenderer = async () => {
const renderer = MarkdownIt({
html: true,
linkify: true,
});
const otherRenderer = MarkdownIt({
html: true,
linkify: true,
});
for (const ren of [renderer, otherRenderer]) {
ren.use(
// @ts-ignore
fromHighlighter(await highlighter, {
theme: "rose-pine",
}),
);
ren.use(markdownItAnchor, {
permalink: markdownItAnchor.permalink.ariaHidden({
symbol: "",
placement: "before",
}),
});
ren.use(markdownItTocDoneRight, {
containerClass: "toc",
level: [1, 2, 3, 4],
listType: "ul",
listClass: "toc-list",
itemClass: "toc-item",
linkClass: "toc-link",
});
ren.use(markdownItTaskLists);
ren.use(markdownItContainer, "spoiler");
}
renderer.use((md) => {
md.renderer.rules.html_block = (tokens, idx) => {
// Modify figure tags
if (tokens[idx]?.content.startsWith("<figure")) {
const imageUrl = (tokens[idx].content.match(
/src="([^"]+)"/,
) ?? [null, null])[1];
if (!imageUrl) {
return otherRenderer.render(tokens[idx].content);
}
// Delete the first / if it exists
const newUrl = `/_ipx/w_800&f_webp/${imageUrl.replace(
/^\//,
"",
)}`;
return tokens[idx].content.replace(imageUrl, newUrl);
}
return tokens[idx]
? otherRenderer.render(tokens[idx]?.content)
: "";
};
md.renderer.rules.image = (tokens, idx, options, env, self) => {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const output = otherRenderer.renderer.rules.image!(
tokens,
idx,
options,
env,
self,
);
const imageUrl = (output.match(/src="([^"]+)"/) ?? [null, null])[1];
if (!imageUrl) {
return output;
}
const newUrl = `/_ipx/w_800&f_webp/${imageUrl.replace(/^\//, "")}`;
return output.replace(imageUrl, newUrl);
};
});
return renderer;
};
// Extract the FrontMatter header from raw markdown
export const parseFrontMatter = <T>(frontMatter: string): T | null => {
const regex = /---\n([\s\S]+?)\n---/;
const match = frontMatter.match(regex);
if (!match) {
return null;
}
const [, frontMatterString] = match;
if (!frontMatterString) {
return null;
}
const frontMatterObject = frontMatterString
.split("\n")
.map((line) => line.split(": "))
.reduce(
(acc, [key, value]) => {
if (!(key && value)) {
return acc;
}
acc[key] = value;
return acc;
},
{} as Record<string, string>,
);
return frontMatterObject as T;
};
export const stripFrontMatter = (markdown: string) => {
// Also strip the --- --- around the front matter
return markdown.replace(/---\n([\s\S]+?)\n---/, "");
};