parent
b04fb7f381
commit
2a48d4b6da
@ -0,0 +1,225 @@
|
||||
.blog {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: rgb(241, 241, 241);
|
||||
|
||||
a {
|
||||
color: #258fb8;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media all and (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__main-col {
|
||||
@media all and (min-width: 768px) {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
@media all and (min-width: 1080px) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-post {
|
||||
margin: 50px 0;
|
||||
|
||||
&__title {
|
||||
padding: 20px 20px 0 20px;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
color: #555;
|
||||
padding: 0 20px;
|
||||
|
||||
code {
|
||||
background: #eee;
|
||||
text-shadow: 0 1px #fff;
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: #2d2d2d;
|
||||
padding: 15px 20px;
|
||||
margin: 0 -20px;
|
||||
border-style: solid;
|
||||
border-color: #ddd;
|
||||
border-width: 1px 0;
|
||||
overflow: auto;
|
||||
color: #ccc;
|
||||
line-height: 22px;
|
||||
width: calc(100% + 40px);
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
text-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
line-height: 1.1em;
|
||||
margin: 1.1em 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p,
|
||||
table {
|
||||
margin: 1.6em 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
dl {
|
||||
margin: 0 20px;
|
||||
line-height: 1.6em;
|
||||
padding: 0;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-bottom: 1em;
|
||||
margin-left: 5px;
|
||||
font-size: 14px;
|
||||
|
||||
a {
|
||||
letter-spacing: 2px;
|
||||
color: #999;
|
||||
line-height: 1em;
|
||||
text-shadow: 0 1px #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
font-size: 0.85em;
|
||||
line-height: 1.6em;
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 1.6em;
|
||||
margin: 0 20px 20px;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
.blog-post__tag {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
&-block {
|
||||
margin: 50px 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 0.85em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: #999;
|
||||
margin-bottom: 1em;
|
||||
margin-left: 5px;
|
||||
line-height: 1em;
|
||||
text-shadow: 0 1px #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&-content {
|
||||
color: #777;
|
||||
text-shadow: 0 1px #fff;
|
||||
background: #ddd;
|
||||
box-shadow: 0 -1px 4px #ccc inset;
|
||||
border: 1px solid #ccc;
|
||||
padding: 15px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.6em;
|
||||
word-wrap: break-word;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #258fb8;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag-cloud a {
|
||||
margin-right: 5px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<article class="blog-post">
|
||||
<div class="blog-post__meta">
|
||||
<a :href="'/blog/' + post.fullSlug">
|
||||
<time :datetime="new Date(post.date).toISOString()">
|
||||
{{ post.date }}
|
||||
</time>
|
||||
</a>
|
||||
</div>
|
||||
<div class="blog-post__inner">
|
||||
<header class="blog-post__title">
|
||||
<h1>
|
||||
<template v-if="detail">
|
||||
{{ post.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href="'/blog/' + post.fullSlug">{{ post.title }}</a>
|
||||
</template>
|
||||
</h1>
|
||||
</header>
|
||||
<div class="blog-post__content" v-html="post.html"></div>
|
||||
<div class="blog-post__footer">
|
||||
<div class="blog-post__tags">
|
||||
<a
|
||||
v-for="tag of post.tags"
|
||||
:href="'/blog/tags/' + tag"
|
||||
class="blog-post__tag"
|
||||
>#{{ tag }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ post: any; detail?: boolean }>();
|
||||
</script>
|
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="blog__sidebar-block">
|
||||
<h2 class="blog__sidebar-title">{{ title }}</h2>
|
||||
<div class="blog__sidebar-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>();
|
||||
</script>
|
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="blog">
|
||||
<header class="blog__header">
|
||||
<h1><a href="/blog">Blog</a></h1>
|
||||
</header>
|
||||
|
||||
<section class="blog__content">
|
||||
<div class="blog__main-col">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="blog__sidebar">
|
||||
<BlogSidebar title="Tags">
|
||||
<ul>
|
||||
<li v-for="tag of tags">
|
||||
<a :href="'/tags/' + tag.name">{{ tag.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</BlogSidebar>
|
||||
|
||||
<BlogSidebar title="Tag cloud">
|
||||
<div class="tag-cloud">
|
||||
<a
|
||||
v-for="tag of tags"
|
||||
:href="'/blog/tags/' + tag.name"
|
||||
:style="{ fontSize: getFontSize(tag) }"
|
||||
>{{ tag.name }}</a
|
||||
>
|
||||
</div>
|
||||
</BlogSidebar>
|
||||
|
||||
<BlogSidebar title="Archive">
|
||||
<ul>
|
||||
<li v-for="archive of monthList">
|
||||
<a :href="archive.href">{{ archive.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</BlogSidebar>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: tags } = await useFetch('/api/blog/tags');
|
||||
const { data: archive } = await useFetch('/api/blog/archive');
|
||||
|
||||
const monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const monthList = computed(() => {
|
||||
let links = [];
|
||||
const res = archive.value;
|
||||
|
||||
for (const year of Object.keys(res).sort((a, b) => Number(b) - Number(a))) {
|
||||
for (const month of Object.keys(res[year]).sort(
|
||||
(a, b) => Number(b) - Number(a)
|
||||
)) {
|
||||
const monthName = monthNames[new Date(`${year}-${month}-01`).getMonth()];
|
||||
|
||||
links.push({
|
||||
name: `${monthName} ${year}`,
|
||||
href: `/blog/archive/${year}/${month}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
});
|
||||
|
||||
const minTag = computed(() =>
|
||||
tags.value.reduce<number>(
|
||||
(min, current) => (min > current.count ? current.count : min),
|
||||
100
|
||||
)
|
||||
);
|
||||
|
||||
const maxTag = computed(() =>
|
||||
tags.value.reduce<number>(
|
||||
(max, current) => (max < current.count ? current.count : max),
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
function convertRange(value: number, r1: number[], r2: number[]) {
|
||||
return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0];
|
||||
}
|
||||
|
||||
const getFontSize = (tag: any): string => {
|
||||
return convertRange(tag.count, [minTag.value, maxTag.value], [10, 20]) + 'px';
|
||||
};
|
||||
</script>
|
@ -0,0 +1,167 @@
|
||||
import { join } from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import yaml from 'yaml';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import { getDateObject } from '../utils/date-object';
|
||||
|
||||
const dir = join('content', 'blog');
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
code: (code, info, escaped): string => {
|
||||
const language = hljs.getLanguage(info) ? info : 'plaintext';
|
||||
const created = hljs.highlight(code, { language }).value;
|
||||
return `<div class="codeblock"><pre><code class="hljs language-${language}">${created}</code></pre></div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export async function readBlogPosts(
|
||||
condition?: (body: any) => boolean,
|
||||
render = true,
|
||||
includeContent = true
|
||||
) {
|
||||
const files = await fs.readdir(dir);
|
||||
const readMD = files.filter((file) => file.endsWith('.md'));
|
||||
const readFiles = [];
|
||||
for (const file of readMD) {
|
||||
const post = await readBlogPost(
|
||||
file.replace('.md', ''),
|
||||
condition,
|
||||
render,
|
||||
includeContent
|
||||
);
|
||||
if (!post) continue;
|
||||
readFiles.push(post);
|
||||
}
|
||||
readFiles.sort((a, b) =>
|
||||
new Date(b.date)
|
||||
.toISOString()
|
||||
.localeCompare(new Date(a.date).toISOString(), 'en', { numeric: true })
|
||||
);
|
||||
return readFiles;
|
||||
}
|
||||
|
||||
export async function readBlogPost(
|
||||
slug: string,
|
||||
condition?: (body: any) => boolean,
|
||||
render = true,
|
||||
includeContent = true
|
||||
): Promise<any> {
|
||||
const decoded = decodeURIComponent(slug);
|
||||
if (!slug || decoded.includes('/') || decoded.includes('.'))
|
||||
throw new Error('Invalid post slug');
|
||||
|
||||
const file = `${slug}.md`;
|
||||
const mdpath = join(dir, file);
|
||||
const read = await fs.readFile(mdpath, { encoding: 'utf-8' });
|
||||
|
||||
const { header: parsedHeader, length: headerLength } = await readHeader(read);
|
||||
const renderedMd = render
|
||||
? await marked(read.substring(headerLength), { async: true })
|
||||
: undefined;
|
||||
const { year, month, day } = getDateObject(parsedHeader);
|
||||
const content = {
|
||||
...parsedHeader,
|
||||
file,
|
||||
slug,
|
||||
fullSlug: `${year}/${month}/${day}/${slug}`,
|
||||
markdown: includeContent ? read.substring(headerLength) : undefined,
|
||||
html: renderedMd,
|
||||
};
|
||||
|
||||
if (condition) {
|
||||
if (!condition(content)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export async function getTags() {
|
||||
const posts = await readBlogPosts(undefined, false, false);
|
||||
const obj = [];
|
||||
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags || []) {
|
||||
const find = obj.find((item) => item.name === tag);
|
||||
if (find) {
|
||||
find.count++;
|
||||
find.posts.push(post.slug);
|
||||
continue;
|
||||
}
|
||||
|
||||
obj.push({
|
||||
name: tag,
|
||||
count: 1,
|
||||
posts: [post.slug],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
export async function getArchiveTree() {
|
||||
const posts = await readBlogPosts(undefined, false, false);
|
||||
const obj = {};
|
||||
|
||||
for (const post of posts) {
|
||||
const { year, month, day } = getDateObject(post);
|
||||
|
||||
if (!obj[year]) {
|
||||
obj[year] = {};
|
||||
}
|
||||
|
||||
if (!obj[year][month]) {
|
||||
obj[year][month] = {};
|
||||
}
|
||||
|
||||
if (!obj[year][month][day]) {
|
||||
obj[year][month][day] = [];
|
||||
}
|
||||
|
||||
obj[year][month][day].push(post.slug);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
export async function generatePaths(prefix = '/') {
|
||||
const tree = await getArchiveTree();
|
||||
const paths = [];
|
||||
|
||||
for (const year of Object.keys(tree)) {
|
||||
paths.push(`${prefix}archive/${year}`);
|
||||
for (const month of Object.keys(tree[year])) {
|
||||
paths.push(`${prefix}archive/${year}/${month}`);
|
||||
for (const day of Object.keys(tree[year][month])) {
|
||||
paths.push(`${prefix}archive/${year}/${month}/${day}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths.sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function readHeader(fileContents: string) {
|
||||
const splitter = fileContents.split('\n');
|
||||
let header = '';
|
||||
|
||||
for (const line of splitter) {
|
||||
if (line === '---') {
|
||||
if (!header.length) continue;
|
||||
break;
|
||||
}
|
||||
header += line + '\n';
|
||||
}
|
||||
|
||||
return {
|
||||
header: yaml.parse(header),
|
||||
length: header.length + 9,
|
||||
};
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
export function getDateObject(post) {
|
||||
const date = new Date(post.date);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return { year, month, day };
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<NuxtLayout name="blog">
|
||||
<BlogPost :post="post" :detail="true" />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { data: post } = await useFetch(`/api/blog/${route.params.slug}`);
|
||||
</script>
|
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<NuxtLayout name="blog">
|
||||
<template v-for="post of posts">
|
||||
<BlogPost :post="post" :detail="false" />
|
||||
</template>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { data: posts } = await useFetch(
|
||||
`/api/blog?year=${route.params.year}&month=${route.params.month}&day=${route.params.day}`
|
||||
);
|
||||
</script>
|
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<NuxtLayout name="blog">
|
||||
<template v-for="post of posts">
|
||||
<BlogPost :post="post" :detail="false" />
|
||||
</template>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { data: posts } = await useFetch(
|
||||
`/api/blog?year=${route.params.year}&month=${route.params.month}`
|
||||
);
|
||||
</script>
|
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<NuxtLayout name="blog">
|
||||
<template v-for="post of posts">
|
||||
<BlogPost :post="post" :detail="false" />
|
||||
</template>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { data: posts } = await useFetch(`/api/blog?year=${route.params.year}`);
|
||||
</script>
|
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<NuxtLayout name="blog">
|
||||
<template v-for="post of posts">
|
||||
<BlogPost :post="post" :detail="false" />
|
||||
</template>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: posts } = await useFetch('/api/blog');
|
||||
</script>
|
@ -0,0 +1,10 @@
|
||||
import { H3Error } from 'h3';
|
||||
import { readBlogPost } from '~~/lib/blog/read-posts';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug');
|
||||
try {
|
||||
const post = await readBlogPost(slug);
|
||||
return post;
|
||||
} catch (e) {}
|
||||
});
|
@ -0,0 +1,5 @@
|
||||
import { getArchiveTree } from '~~/lib/blog/read-posts';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
return getArchiveTree();
|
||||
});
|
@ -0,0 +1,30 @@
|
||||
import { readBlogPosts } from '~~/lib/blog/read-posts';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const include = (content) => {
|
||||
const dateObj = new Date(content.date);
|
||||
|
||||
if (query.year) {
|
||||
if (Number(query.year) !== dateObj.getFullYear()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.month) {
|
||||
if (Number(query.month) !== dateObj.getMonth() + 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.day) {
|
||||
if (Number(query.day) !== dateObj.getDate()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return readBlogPosts(include);
|
||||
});
|
@ -0,0 +1,5 @@
|
||||
import { getTags } from '~~/lib/blog/read-posts';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
return getTags();
|
||||
});
|
Loading…
Reference in new issue