YumeLog:我写的高颜值个性化主页系统

|13 天前
最近我把自己的个人主页系统整理并开源了,项目名字叫做 yumeLog(ユメログ)。项目已经支持全页面SEO(BASE: VITE-SSG),每次编译你都可以将你所有博客文章生成专为robot提供的 index.html 。经过测试telegram/QQ/Google都能正确识别并生成卡片。 一个编译后连代码高亮js部分只有1M的极简主页/博客系统项目基于 Vue3 + TypeScript + NaiveUI开发,目标不是做传统博客,而是做一个更像“个人数字名片”的网站。GitHub 地址:点我查看项目仓库
yumeLog Home 桌面端预览
yumeLog Home 桌面端预览
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 桌面端预览
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里面正常情况下你不需要动。
友链展示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"
    }
  ]
}
From Now Time 模块:
fromNowyaml
fromNow: #起始块不能删
  - time: "20161225" #时间
    photo: "" #预留 还没做
    names:
      - type: "zh"
        content: "你好"
      - type: "en"
        content: "Hello!"
      - type: "ja"
        content: "天気がいいから、散歩しましょう"
      - type: "other"
        content: "Hello!"
        #请注意,自己的东西都得自己翻译
list.json配置: 请注意,这必须是数组!
list.jsonjson
[
  "post1.yaml",
  "post2.yaml",
  "post3.yaml"
]
纪念日预览
纪念日预览
照片展示墙预览
照片展示墙预览
Maimai 玩家模块:如果你是WMC,这个功能会非常有意思。项目原生支持:Maimai DX 成绩展示可以在主页直接展示你的:段位 Rating 成绩列表,数据来自 Aqua 服务器 API。
maimai配置文件json
{
  "baseUrl": "aqua.server.com",
  "aimeID": "1145141919810"
}
maimai成绩展示预览
maimai成绩展示预览
如果你也想搭建一个:极简但高颜值的个人主页,可以看看这个项目:GitHub 仓库地址,如果觉得有意思的话。 欢迎点个 Star ⭐