YumeLog:我写的高颜值个性化主页系统
|13 天前
最近我把自己的个人主页系统整理并开源了,项目名字叫做 yumeLog(ユメログ)。项目已经支持全页面SEO(BASE: VITE-SSG),每次编译你都可以将你所有博客文章生成专为robot提供的 index.html 。经过测试telegram/QQ/Google都能正确识别并生成卡片。 一个编译后连代码高亮js部分只有1M的极简主页/博客系统项目基于 Vue3 + TypeScript + NaiveUI开发,目标不是做传统博客,而是做一个更像“个人数字名片”的网站。GitHub 地址:点我查看项目仓库

yumeLog Home 桌面端预览

yumeLog Home 移动端预览
为什么写这个项目?为什么不用通用解决方案捏:不符合我胃口,UI设计不喜欢。其次是比较重,大多需要后端,部署复杂,维护火葬场,我的vps就300G月流量要干很多事情。以及个人信息展示能力很弱。所以我干脆自己写了一:一个完全静态的个人主页 + 博客。没有后端,没有数据库,只需要托管静态文件即可运行。担心国内被劫持访问不了?没关系支持热备。
核心技术特点自研文本解析引擎:项目没有使用 Markdown,而是自己写了一套轻量级解析器。支持:bold underline strike center link thin code info warning 。而且支持 无限嵌套。你可以使用- type: "divider"为文本进行分段,也可以用- type: "image"为文章添加图片和描述。
示例text
@meta
layout: common # 布局模板
time: 20260316 # 日期 (YYYYMMDD)
lang: zh # 语言 (zh/en/ja等)
id: bangkok-life # 唯一 ID (用于路由访问)
pin: true # 是否置顶
title: 曼谷的午后喵 # 文章标题
@end
@image //注意不能加空格
\\@image // \可以跳过该行不被format
- desc: "I \"love\" Bangkok" //输出为I "love" Bangkok
- desc: |
这是多行支持
123
- desc: 请注意这是完全缩进语言
@end
@text
\@image --> @image
\\@image --> \@image
\\\@image --> \\@image
- desc: "I \"love\" BKK" --> I "love" BKK
- path: "C:\\\\Users" --> C:\\Users
- mixed: "\\\\\\" " --> \\"
@end
yumeLog Blog 桌面端预览

yumeLog Blog 移动端预览
YAML 内容驱动:博客文章全部使用 YAML 编写,例如:id / time / title / blocks,每个文章由多个 block组成。支持:文本块 图片块 多图片展示。因为是纯数据驱动,所以:不需要数据库,也不需要后端
富文本解析器代码typescript
// noinspection DuplicatedCode
import { $message } from "@/components/ts/msgUtils.ts";
import commonI18n from "@/data/I18N/commonI18n.json";
import { lang } from "@/components/ts/setupLang.ts";
import { BLOCK_TYPES, BlockType, RICH_TYPES, RichType, TagHandler, TextToken } from "../../d.ts";
type I18nMap = Record<string, string>;
const TAG_PREFIX = "$$";
const TAG_OPEN = "(";
const TAG_CLOSE = ")";
const TAG_DIVIDER = "|";
const END_TAG = ")$$";
const RAW_OPEN = ")%";
const RAW_CLOSE = "%end$$";
const ESCAPE_CHAR = "\\";
const ESCAPABLE_CHARS = new Set(
[TAG_OPEN, TAG_CLOSE, TAG_DIVIDER, ESCAPE_CHAR]
.flatMap((str) => str.split(""))
.filter((char) => !/[a-zA-Z0-9]/.test(char))
.filter((char, i, self) => self.indexOf(char) === i),
);
const RICH_TYPE_SET: ReadonlySet<RichType> = new Set(RICH_TYPES);
const BLOCK_TYPES_SET: ReadonlySet<BlockType> = new Set(BLOCK_TYPES);
const isRichType = (tag: string): tag is RichType => RICH_TYPE_SET.has(tag as RichType);
const isTagChar = (c: string) =>
(c >= "a" && c <= "z") ||
(c >= "A" && c <= "Z") ||
(c >= "0" && c <= "9") ||
c === "_" ||
c === "-";
const unescapeInline = (str: string): string => {
let result = "";
let i = 0;
while (i < str.length) {
if (str[i] === ESCAPE_CHAR && i + 1 < str.length && ESCAPABLE_CHARS.has(str[i + 1])) {
result += str[i + 1];
i += 2;
continue;
}
result += str[i++];
}
return result;
};
const readEscaped = (text: string, i: number): [string, number] => {
if (text[i] === ESCAPE_CHAR && i + 1 < text.length) {
const nextChar = text[i + 1];
if (ESCAPABLE_CHARS.has(nextChar)) {
return [ESCAPE_CHAR + nextChar, i + 2];
}
}
return [text[i], i + 1];
};
const findRawClose = (text: string, start: number): number => {
let pos = start;
while (pos < text.length) {
let lineEnd = text.indexOf("\n", pos);
if (lineEnd === -1) {
lineEnd = text.length;
} else if (lineEnd > pos && text[lineEnd - 1] === "\r") {
lineEnd--;
}
if (text.startsWith(RAW_CLOSE, pos) && pos + RAW_CLOSE.length === lineEnd) {
return pos;
}
pos = lineEnd + 1;
}
return -1;
};
const extractText = (tokens?: TextToken[]): string => {
if (!tokens?.length) return "";
return tokens.map((t) => (typeof t.value === "string" ? t.value : extractText(t.value))).join("");
};
const ALLOWED_LANGS = ["typescript", "bash", "json", "yaml", "vue", "html", "text"] as const;
type SupportedLang = (typeof ALLOWED_LANGS)[number];
const normalizeLang = (codeLang?: string): SupportedLang => {
const tsAliases = ["js", "javascript", "ts", "typescript"];
if (!codeLang) return "typescript";
const normalized = codeLang.trim().toLowerCase();
if (tsAliases.includes(normalized)) {
return "typescript";
}
if (!(ALLOWED_LANGS as unknown as string[]).includes(normalized)) {
const unsupportedCodeLanguage = commonI18n.unsupportedCodeLanguage as I18nMap;
const unsupportedCodeLanguageMsg = (
unsupportedCodeLanguage[lang.value] || unsupportedCodeLanguage.en
).replace("{language}", String(codeLang));
$message.error(unsupportedCodeLanguageMsg, true, 3000);
return "text";
}
return normalized as SupportedLang;
};
const splitTokensByPipe = (tokens: TextToken[]): TextToken[][] => {
const parts: TextToken[][] = [[]];
for (const token of tokens) {
if (token.type !== "text" || typeof token.value !== "string") {
parts[parts.length - 1].push(token);
continue;
}
let buffer = "";
let i = 0;
const val = token.value;
const flushText = () => {
if (buffer) {
parts[parts.length - 1].push({ type: "text", value: buffer });
buffer = "";
}
};
while (i < val.length) {
if (val[i] === ESCAPE_CHAR && i + 1 < val.length) {
buffer += ESCAPE_CHAR + val[i + 1];
i += 2;
continue;
}
if (val[i] === TAG_DIVIDER) {
flushText();
parts.push([]);
i++;
while (i < val.length && val[i] === " ") i++;
continue;
}
buffer += val[i];
i++;
}
flushText();
}
return parts;
};
const TAG_HANDLERS: Record<string, TagHandler> = {
link: {
inline: (tokens) => {
const parts = splitTokensByPipe(tokens);
const titlePart = parts.shift() ?? [];
return {
type: "link",
url: unescapeInline(extractText(titlePart)).trim(),
value: parts.length ? parts.flat() : titlePart,
};
},
},
info: {
inline: (tokens) => {
const parts = splitTokensByPipe(tokens);
if (parts.length === 1) return { type: "info", title: "Info:", value: parts[0] };
const titlePart = parts.shift() ?? [];
return {
type: "info",
title: unescapeInline(extractText(titlePart)).trim(),
value: parts.flat(),
};
},
raw: (title, content) => ({
type: "info",
title: title || "Info:",
value: [{ type: "text", value: content }],
}),
},
warning: {
inline: (tokens) => {
const parts = splitTokensByPipe(tokens);
if (parts.length === 1) return { type: "warning", title: "Warning:", value: parts[0] };
const titlePart = parts.shift() ?? [];
return {
type: "warning",
title: unescapeInline(extractText(titlePart)).trim(),
value: parts.flat(),
};
},
raw: (title, content) => ({
type: "warning",
title: title || "Warning:",
value: [{ type: "text", value: content }],
}),
},
"raw-code": {
raw: (arg, content) => {
const parts = splitTokensByPipe([{ type: "text", value: arg ?? "" }]);
const codeLang = normalizeLang(unescapeInline(extractText(parts[0] ?? [])).trim());
const title = unescapeInline(extractText(parts[1] ?? [])).trim();
const label = unescapeInline(extractText(parts[2] ?? [])).trim();
return { type: "raw-code", codeLang, title: title || "Code:", label, value: content.trim() };
},
},
};
const unescapeAST = (tokens: TextToken[]): TextToken[] => {
return tokens.map((t) => {
if (t.type === "text" && typeof t.value === "string") {
return { ...t, value: unescapeInline(t.value) };
}
if (Array.isArray(t.value)) {
return { ...t, value: unescapeAST(t.value) };
}
return t;
});
};
export const parseRichText = (text: string, depthLimit = 50, silent = false): TextToken[] => {
if (!text) return [];
const root: TextToken[] = [];
const stack: { tag: string; tokens: TextToken[] }[] = [];
let ignoredDepth = 0;
let buffer = "";
let i = 0;
const current = () => (stack.length ? stack[stack.length - 1].tokens : root);
const pushText = (str: string) => {
if (!str) return;
const tokens = current();
const last = tokens[tokens.length - 1];
if (last?.type === "text" && typeof last.value === "string") {
last.value += str;
} else {
tokens.push({ type: "text", value: str });
}
};
while (i < text.length) {
if (text.startsWith(TAG_PREFIX, i)) {
let j = i + TAG_PREFIX.length;
while (j < text.length && isTagChar(text[j])) j++;
if (text.startsWith(TAG_OPEN, j)) {
pushText(buffer);
buffer = "";
const tag = text.slice(i + TAG_PREFIX.length, j);
if (stack.length >= depthLimit || ignoredDepth > 0) {
if (stack.length === depthLimit && ignoredDepth === 0) {
const depthEntry = commonI18n.richTextDepthLimit as I18nMap;
const depthMsg = (depthEntry[lang.value] || depthEntry.en)
.replace("{depthLimit}", String(depthLimit))
.replace("{i}", String(i));
if (!silent) $message.error(depthMsg, true, 3000);
}
ignoredDepth++;
buffer += text.slice(i, j + TAG_OPEN.length);
i = j + TAG_OPEN.length;
continue;
}
const handler = TAG_HANDLERS[tag];
if (handler?.raw) {
let k = j + 1;
let depth = 1;
while (k < text.length && depth > 0) {
const ch = text[k];
if (ch === TAG_OPEN) depth++;
else if (ch === TAG_CLOSE) depth--;
k++;
}
if (depth !== 0) {
buffer += text.slice(i, j + TAG_OPEN.length);
i = j + TAG_OPEN.length;
continue;
}
k--;
if (k < text.length && text.startsWith(RAW_OPEN, k)) {
pushText(buffer);
buffer = "";
const arg = text.slice(j + 1, k).trim();
const contentStart = k + RAW_OPEN.length;
const end = findRawClose(text, contentStart);
if (end === -1) {
const rawEntry = commonI18n.richTextRawNotClosed as I18nMap;
const rawMsg = (rawEntry[lang.value] || rawEntry.en).replace("{i}", String(i));
if (!silent) $message.error(rawMsg, true, 3000);
buffer += text.slice(i, k + RAW_OPEN.length);
i = k + RAW_OPEN.length;
continue;
}
const content = text
.slice(contentStart, end)
.split(ESCAPE_CHAR + RAW_CLOSE)
.join(RAW_CLOSE);
current().push(handler.raw(arg, content));
i = end + RAW_CLOSE.length;
if (text.startsWith("\r\n", i)) i += 2;
else if (text[i] === "\n") i++;
continue;
}
}
stack.push({ tag, tokens: [] });
i = j + TAG_OPEN.length;
continue;
}
}
if (text.startsWith(END_TAG, i)) {
if (ignoredDepth > 0) {
ignoredDepth--;
buffer += END_TAG;
i += END_TAG.length;
continue;
}
if (stack.length === 0) {
const closeEntry = commonI18n.richTextUnexpectedClose as I18nMap;
const closeMsg = (closeEntry[lang.value] || closeEntry.en).replace("{i}", String(i));
if (!silent) $message.error(closeMsg, true, 3000);
buffer += END_TAG;
i += END_TAG.length;
continue;
}
pushText(buffer);
buffer = "";
const node = stack.pop()!;
if (!isRichType(node.tag)) {
const richTextUnknownTag = commonI18n.richTextUnknownTag as I18nMap;
const richTextUnknownTagMsg = (richTextUnknownTag[lang.value] || richTextUnknownTag.en)
.replace("{tag}", String(node.tag))
.replace("{i}", String(i));
if (!silent) $message.error(richTextUnknownTagMsg, true, 3000);
node.tokens.forEach((t) => {
if (t.type === "text" && typeof t.value === "string") pushText(unescapeInline(t.value));
else current().push(t);
});
} else {
const handler = TAG_HANDLERS[node.tag];
current().push(
handler?.inline ? handler.inline(node.tokens) : { type: node.tag, value: node.tokens },
);
}
i += END_TAG.length;
if (BLOCK_TYPES_SET.has(node.tag as BlockType)) {
if (text.startsWith("\r\n", i)) i += 2;
else if (text[i] === "\n") i++;
}
continue;
}
if (text[i] === ESCAPE_CHAR && i + 1 < text.length) {
const [char, next] = readEscaped(text, i);
buffer += char;
i = next;
continue;
}
buffer += text[i];
i++;
}
pushText(buffer);
while (stack.length) {
const node = stack.pop()!;
const fallback = TAG_PREFIX + node.tag + TAG_OPEN;
const tokens = current();
const last = tokens[tokens.length - 1];
if (last?.type === "text" && typeof last.value === "string") {
last.value += fallback;
} else {
tokens.push({ type: "text", value: fallback });
}
tokens.push(...node.tokens);
}
return unescapeAST(root);
};
export const stripRichText = (text?: string): string => {
if (!text) return "";
const tokens = parseRichText(text, 50, true);
return extractText(tokens);
};完全静态架构:所有数据都来自仓库文件:YAML JSON 图片资源,所以部署非常简单:GitHub Pages Cloudflare Pages Vercel Nginx都可以直接部署。
url配置示例json
{
"blog": {
"listUrl": "https://YOUR-LIST_URL",
"url": "https://YOUR_URL",
"spareUrl": "/YOUR_SPARE-URL",
"spareListUrl": "/YOUR_SPARE_LIST_URL"
},
"main": {
"url": "这里主要是你个人简介那堆东西的地址,就是在/public/data/main里的那堆东西,你当然也可以远程,但是丢服务器就行了",
"spareUrl": "/YOUR_SPARE-URL",
"listUrl": "/YOUR_SPARE_LIST_URL"
}
}个人主页模块:除了博客系统之外,yumeLog 还包含完整的个人展示模块:主题系统 多语言支持 纪念日时间线 友链展示 联系方式 照片墙,本质上它更像是:一个完整的个人网站框架全融合的主题: yumelog本身会使用你在json内配置的主题色来生成: 浅色及加深色供不同区域使用以增强美观度或可读性。 友链展示说明:socialLinks内每个都可以删掉,删掉后ui会自动取消渲染该内容。platforms里面正常情况下你不需要动。 From Now Time 模块: list.json配置: 请注意,这必须是数组!
友链展示json
{
"socialLinks": {
"twitter": "https://x.com/HoshinoYumeka2",
"tron": "https://tronscan.org/#/address/TVB16jV3Jx2HTn9U1KjyBSN1u9MQ29FArs",
"areth": "https://arbiscan.io/address/0x3eb232c80307961795C1310374368834c25A41e6",
"eth": "https://etherscan.io/address/0x3eb232c80307961795C1310374368834c25A41e6",
"polygon": "https://polygonscan.com/address/0x3eb232c80307961795C1310374368834c25A41e6",
"bsc": "https://bscscan.com/address/0x3eb232c80307961795C1310374368834c25A41e6",
"solana": "https://solscan.io/account/CwmEwePc5TxyQG57e3f4WBufTvGFv264KAGfVRoSZd7V",
"telegram": "https://t.me/chiba2333",
"email": "mailto:qwq@qwwq@org",
"github": "https://github.com/chiba233"
},
"platforms": [
{
"id": "telegram",
"label": "Telegram",
"type": "link"
},
{
"id": "wechat",
"label": "WeChat",
"type": "modal"
},
{
"id": "line",
"label": "LINE",
"type": "modal"
},
{
"id": "email",
"label": "E-Mail",
"type": "link"
},
{
"id": "twitter",
"label": "Twitter",
"type": "link"
},
{
"id": "github",
"label": "GitHub",
"type": "link"
},
{
"id": "tron",
"label": "Tron",
"type": "link"
},
{
"id": "eth",
"label": "Ethereum",
"type": "link"
},
{
"id": "areth",
"label": "Arbitrum",
"type": "link"
},
{
"id": "bsc",
"label": "BSC-BNB",
"type": "link"
},
{
"id": "polygon",
"label": "Polygon",
"type": "link"
},
{
"id": "solana",
"label": "Solana",
"type": "link"
},
{
"id": "maimai",
"label": "maimai",
"type": "func"
},
{
"id": "cat",
"label": "cat",
"type": "func"
}
]
}fromNowyaml
fromNow: #起始块不能删
- time: "20161225" #时间
photo: "" #预留 还没做
names:
- type: "zh"
content: "你好"
- type: "en"
content: "Hello!"
- type: "ja"
content: "天気がいいから、散歩しましょう"
- type: "other"
content: "Hello!"
#请注意,自己的东西都得自己翻译list.jsonjson
[
"post1.yaml",
"post2.yaml",
"post3.yaml"
]
纪念日预览

照片展示墙预览
Maimai 玩家模块:如果你是WMC,这个功能会非常有意思。项目原生支持:Maimai DX 成绩展示可以在主页直接展示你的:段位 Rating 成绩列表,数据来自 Aqua 服务器 API。
maimai配置文件json
{
"baseUrl": "aqua.server.com",
"aimeID": "1145141919810"
}
maimai成绩展示预览