mirror of
https://github.com/versia-pub/blog.git
synced 2025-12-06 00:48:18 +01:00
feat: ✨ Initialize new repo
This commit is contained in:
commit
e7941231a5
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
75
README.md
Normal 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
5
app.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="app">
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
81
biome.json
Normal file
81
biome.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
components/article/content.vue
Normal file
62
components/article/content.vue
Normal 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>
|
||||||
12
components/article/image.vue
Normal file
12
components/article/image.vue
Normal 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>
|
||||||
31
components/article/title.vue
Normal file
31
components/article/title.vue
Normal 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>
|
||||||
83
composables/server/Post.ts
Normal file
83
composables/server/Post.ts
Normal 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
30
content/test/test-file.md
Normal 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
11
layouts/app.vue
Normal 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
76
nuxt.config.ts
Normal 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
63
package.json
Normal 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"]
|
||||||
|
}
|
||||||
75
pages/articles/[...path].vue
Normal file
75
pages/articles/[...path].vue
Normal 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
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
24
server/api/article.get.ts
Normal 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;
|
||||||
|
});
|
||||||
8
server/api/articles.get.ts
Normal file
8
server/api/articles.get.ts
Normal 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
3
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
9
tailwind.config.ts
Normal file
9
tailwind.config.ts
Normal 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
4
tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
1
types.d.ts
vendored
Normal file
1
types.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
declare module "@hackmd/markdown-it-task-lists";
|
||||||
28
types/posts.ts
Normal file
28
types/posts.ts
Normal 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
153
utils/markdown.ts
Normal 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---/, "");
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue