mirror of
https://github.com/sendevia/website.git
synced 2026-03-05 23:32:45 +08:00
Compare commits
18 Commits
26.2.25(29
...
26.3.4(316
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e0528c24 | |||
| 29885ae607 | |||
| dabb2f6164 | |||
| 1247943a5e | |||
| b822fe6227 | |||
| f01232241f | |||
| c8b917bda5 | |||
| aa3cdbc695 | |||
| 2bc889a388 | |||
| c093e395bd | |||
| 2d879372d2 | |||
| 0493426eaa | |||
| 042342f923 | |||
| 13548b222e | |||
| adb804f249 | |||
| d91f8b90aa | |||
| 69d117152a | |||
| b6e7649a4f |
65
.github/workflows/docker-build.yml
vendored
Normal file
65
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "package.json"
|
||||
- "Dockerfile"
|
||||
- ".github/workflows/docker-build.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep '"version"' package.json | sed 's/.*"\([^"]*\)".*/\1/' | sed 's/(.*)//g')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/website
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ node_modules
|
||||
package-lock.json
|
||||
cache/
|
||||
dist/
|
||||
.agents
|
||||
nginx-config.conf
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import packageJson from "../package.json";
|
||||
|
||||
// https://github.com/valeriangalliat/markdown-it-anchor
|
||||
import anchor from "markdown-it-anchor";
|
||||
// markdown-it plugins
|
||||
// https://mdit-plugins.github.io/align.html
|
||||
import { align } from "@mdit/plugin-align";
|
||||
@@ -12,7 +10,9 @@ import { footnote } from "@mdit/plugin-footnote";
|
||||
import { tasklist } from "@mdit/plugin-tasklist";
|
||||
// https://mdit-plugins.github.io/img-mark.html
|
||||
import { imgMark } from "@mdit/plugin-img-mark";
|
||||
import { wrapHeadingsAsSections } from "./theme/utils/sectionWrapper";
|
||||
import { sectionWrapper } from "./theme/utils/mdSectionWrapper";
|
||||
import { table } from "./theme/utils/mdTable";
|
||||
import { anchor } from "./theme/utils/mdCustomAnchor";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/",
|
||||
@@ -22,18 +22,6 @@ export default defineConfig({
|
||||
titleTemplate: ":title",
|
||||
description: "随便写写的博客",
|
||||
markdown: {
|
||||
anchor: {
|
||||
permalink: anchor.permalink.linkAfterHeader({
|
||||
style: "visually-hidden",
|
||||
symbol: "link",
|
||||
class: "title-anchor",
|
||||
assistiveText: () => "复制链接",
|
||||
visuallyHiddenClass: "visually-hidden",
|
||||
wrapper: ['<div class="title-with-achor">', "</div>"],
|
||||
placement: "before",
|
||||
space: false,
|
||||
}),
|
||||
},
|
||||
attrs: {
|
||||
allowedAttributes: ["id", "class"],
|
||||
},
|
||||
@@ -41,10 +29,14 @@ export default defineConfig({
|
||||
codeCopyButtonTitle: "复制代码",
|
||||
config(md) {
|
||||
md.use(align);
|
||||
md.use(anchor, {
|
||||
levels: [1, 2, 3, 4],
|
||||
});
|
||||
md.use(footnote);
|
||||
md.use(wrapHeadingsAsSections);
|
||||
md.use(tasklist, { label: true });
|
||||
md.use(imgMark);
|
||||
md.use(sectionWrapper);
|
||||
md.use(table);
|
||||
md.use(tasklist, { label: true });
|
||||
},
|
||||
image: {
|
||||
lazyLoading: true,
|
||||
|
||||
43
.vitepress/theme/components/AnchorLink.vue
Normal file
43
.vitepress/theme/components/AnchorLink.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useAchorLink } from "../composables/useAnchorLink";
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
/** 锚点显示的字符串 */
|
||||
symbol?: string;
|
||||
/** 锚点的 class 名称 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
symbol: "",
|
||||
className: "",
|
||||
});
|
||||
|
||||
const isCopied = ref(false);
|
||||
const { handleCopy } = useAchorLink(props.href, () => {
|
||||
isCopied.value = true;
|
||||
setTimeout(() => {
|
||||
isCopied.value = false;
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
/** 处理锚点点击事件 */
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
handleCopy();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="className">
|
||||
<a :href="href" class="symbol" @click="handleClick">{{ symbol }}</a>
|
||||
<span v-if="isCopied" class="feedback">已复制</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "sass:meta";
|
||||
@include meta.load-css("../styles/components/AnchorLink");
|
||||
</style>
|
||||
@@ -27,14 +27,8 @@ const siteVersion = theme.value.siteVersion;
|
||||
>
|
||||
</div>
|
||||
<div class="beian-info">
|
||||
<div class="beian-gongan">
|
||||
<img src="/assets/images/beian.webp" loading="eager" />
|
||||
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=23020002230215" target="_blank"
|
||||
>黑公网安备23020002230215</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank">黑ICP备2024016516号-1</a>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank">黑ICP备2024016516号</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,11 +48,21 @@ const copyShortLink = async () => {
|
||||
const collectHeadings = () => {
|
||||
if (!isClient()) return;
|
||||
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
|
||||
headings.value = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
text: n.textContent?.trim() || n.id,
|
||||
level: +n.tagName.replace("H", ""),
|
||||
}));
|
||||
|
||||
headings.value = nodes.map((n) => {
|
||||
// 克隆节点并移除行内锚点
|
||||
const clone = n.cloneNode(true) as HTMLElement;
|
||||
const inlineAnchors = Array.from(clone.querySelectorAll(".AnchorLink.inline")) as HTMLElement[];
|
||||
inlineAnchors.forEach((el) => el.remove());
|
||||
|
||||
const text = clone.textContent?.trim() || n.id;
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
text,
|
||||
level: +n.tagName.replace("H", ""),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** 更新指示器(高亮边框)的位置和尺寸 */
|
||||
|
||||
20
.vitepress/theme/composables/useAnchorLink.ts
Normal file
20
.vitepress/theme/composables/useAnchorLink.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
/**
|
||||
* 复制锚点链接到剪贴板
|
||||
* @param href - 锚点 URL
|
||||
* @param onCopied - 复制成功后的回调函数
|
||||
*/
|
||||
export const useAchorLink = (href: string, onCopied?: () => void) => {
|
||||
const { copy: copyToClipboard } = useClipboard();
|
||||
|
||||
const handleCopy = async () => {
|
||||
const anchorId = href.startsWith("#") ? href : `#${href}`;
|
||||
const fullUrl = `${window.location.origin}${window.location.pathname}${anchorId}`;
|
||||
|
||||
await copyToClipboard(fullUrl);
|
||||
onCopied?.();
|
||||
};
|
||||
|
||||
return { handleCopy };
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import Default from "./layouts/Default.vue";
|
||||
import NotFound from "./layouts/NotFound.vue";
|
||||
|
||||
// Components
|
||||
import AnchorLink from "./components/AnchorLink.vue";
|
||||
import AppBar from "./components/AppBar.vue";
|
||||
import ArticleMasonry from "./components/ArticleMasonry.vue";
|
||||
import Button from "./components/Button.vue";
|
||||
@@ -33,6 +34,7 @@ export default {
|
||||
|
||||
app.component("MainLayout", Default);
|
||||
|
||||
app.component("AnchorLink", AnchorLink);
|
||||
app.component("AppBar", AppBar);
|
||||
app.component("ArticleMasonry", ArticleMasonry);
|
||||
app.component("ButtonGroup", ButtonGroup);
|
||||
|
||||
@@ -85,25 +85,6 @@ const openImageViewer = (index: number, event: MouseEvent) => {
|
||||
showImageViewer.value = true;
|
||||
};
|
||||
|
||||
/** 复制锚点链接到剪贴板 */
|
||||
const handleAnchorClick = (event: MouseEvent) => {
|
||||
const anchor = (event.target as HTMLElement).closest("a.title-anchor") as HTMLAnchorElement;
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
const fullUrl = `${window.location.origin}${window.location.pathname}${href}`;
|
||||
copyToClipboard(fullUrl);
|
||||
|
||||
const label = anchor.querySelector("span.visually-hidden");
|
||||
if (label) {
|
||||
const originalText = label.textContent;
|
||||
label.textContent = "已复制";
|
||||
setTimeout(() => {
|
||||
label.textContent = originalText;
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
/** 处理列表 Bullet 旋转和有序列表对齐 */
|
||||
const enhanceDomStyles = () => {
|
||||
if (!articleContentRef.value) return;
|
||||
@@ -156,7 +137,7 @@ if (isClient()) {
|
||||
|
||||
<template>
|
||||
<Header />
|
||||
<main id="article-content" ref="articleContentRef" @click="handleAnchorClick">
|
||||
<main id="article-content" ref="articleContentRef">
|
||||
<Content />
|
||||
<PrevNext />
|
||||
</main>
|
||||
|
||||
102
.vitepress/theme/styles/components/AnchorLink.scss
Normal file
102
.vitepress/theme/styles/components/AnchorLink.scss
Normal file
@@ -0,0 +1,102 @@
|
||||
@use "../mixin";
|
||||
|
||||
.AnchorLink {
|
||||
@include mixin.typescale-style("body-large");
|
||||
|
||||
transition: var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
&.normal {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
position: absolute;
|
||||
left: -60px;
|
||||
top: -6px;
|
||||
|
||||
margin-block-start: 0px !important;
|
||||
|
||||
height: calc(100% + 6px);
|
||||
width: 60px;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
|
||||
opacity: 0;
|
||||
z-index: 999;
|
||||
|
||||
.symbol {
|
||||
@include mixin.material-symbols($size: 24);
|
||||
|
||||
display: block;
|
||||
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
|
||||
color: var(--md-sys-color-on-surface);
|
||||
line-height: 54px;
|
||||
text-align: center;
|
||||
text-decoration: none !important;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback {
|
||||
@include mixin.typescale-style("label-medium");
|
||||
|
||||
position: sticky;
|
||||
top: 57px;
|
||||
|
||||
padding-block: 3px;
|
||||
padding-inline: 9px;
|
||||
|
||||
word-break: keep-all;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-small);
|
||||
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.inline {
|
||||
@include mixin.material-symbols($size: 24);
|
||||
|
||||
display: none;
|
||||
|
||||
color: var(--md-sys-color-on-surface);
|
||||
text-decoration: none !important;
|
||||
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px) {
|
||||
&.normal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,6 @@ table {
|
||||
|
||||
tr {
|
||||
th {
|
||||
min-width: 10ch;
|
||||
|
||||
padding: 16px 24px;
|
||||
|
||||
text-transform: capitalize;
|
||||
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
@@ -52,8 +48,6 @@ table {
|
||||
vertical-align: top;
|
||||
|
||||
td {
|
||||
padding: 16px 24px;
|
||||
|
||||
vertical-align: inherit;
|
||||
|
||||
code {
|
||||
@@ -63,9 +57,18 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
:is(td, th) {
|
||||
td,
|
||||
th {
|
||||
min-width: 16ch;
|
||||
|
||||
padding: 16px 24px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
padding-block: 0px !important;
|
||||
}
|
||||
|
||||
@@ -358,95 +358,13 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title-anchor {
|
||||
@include mixin.typescale-style("body-large");
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
position: absolute;
|
||||
left: -60px;
|
||||
top: 0px;
|
||||
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
|
||||
color: var(--md-sys-color-on-surface);
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
|
||||
opacity: 0;
|
||||
transition: var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
z-index: 2;
|
||||
|
||||
span:nth-of-type(1) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
span:nth-of-type(1) {
|
||||
@include mixin.material-symbols($size: 24);
|
||||
|
||||
display: block;
|
||||
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
|
||||
line-height: 54px;
|
||||
text-align: center;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
span.visually-hidden {
|
||||
@include mixin.typescale-style("label-medium");
|
||||
|
||||
padding-block: 3px;
|
||||
padding-inline: 9px;
|
||||
|
||||
word-break: keep-all;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-small);
|
||||
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.title-anchor {
|
||||
&:hover .AnchorLink {
|
||||
opacity: 1;
|
||||
|
||||
.symbol {
|
||||
opacity: 1;
|
||||
|
||||
span:nth-of-type(1) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
span:nth-of-type(1):hover {
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
|
||||
+ span.visually-hidden {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
82
.vitepress/theme/utils/mdCustomAnchor.ts
Normal file
82
.vitepress/theme/utils/mdCustomAnchor.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
|
||||
/** 锚点配置项 */
|
||||
interface AnchorOptions {
|
||||
/** 锚点层级,例如 [1, 2, 3] 代表 h1, h2, h3 */
|
||||
levels?: number[];
|
||||
/** 锚点显示的字符串 */
|
||||
symbol?: string;
|
||||
/** 锚点的类名称 */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为标题添加可复制的锚点链接,将同时生成两种锚点:
|
||||
* 1. 标题行内锚点
|
||||
* 2. 组件锚点
|
||||
* @param mdit - MarkdownIt 实例
|
||||
* @param options - 锚点配置项
|
||||
*/
|
||||
export function anchor(mdit: MarkdownIt, options: AnchorOptions = {}): void {
|
||||
const { levels = [1, 2, 3, 4, 5, 6], symbol = "link", class: className = "AnchorLink" } = options;
|
||||
|
||||
// 在 core 规则中拦截 tokens,在每个 heading 后添加 AnchorLink 组件以及行内锚点
|
||||
mdit.core.ruler.push("add_anchor_links", (state) => {
|
||||
const tokens = state.tokens;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
// 在 heading_close 处进行处理,此时 heading_open 和 inline 都在前面
|
||||
if (token.type === "heading_close") {
|
||||
const level = parseInt(token.tag.slice(1));
|
||||
if (!levels.includes(level)) continue;
|
||||
|
||||
const headingOpenToken = tokens[i - 2];
|
||||
const inlineToken = tokens[i - 1];
|
||||
const idAttr = headingOpenToken?.attrGet("id");
|
||||
|
||||
if (idAttr) {
|
||||
// 移除默认渲染的锚点(VitePress 默认会添加 header-anchor)
|
||||
if (inlineToken && inlineToken.children) {
|
||||
let anchorStartIndex = -1;
|
||||
let anchorEndIndex = -1;
|
||||
|
||||
for (let j = 0; j < inlineToken.children.length; j++) {
|
||||
const child = inlineToken.children[j];
|
||||
// 查找带有 header-anchor 类名的 link_open
|
||||
if (child.type === "link_open" && child.attrGet("class")?.includes("header-anchor")) {
|
||||
anchorStartIndex = j;
|
||||
}
|
||||
// 找到对应的 link_close
|
||||
if (anchorStartIndex !== -1 && child.type === "link_close") {
|
||||
anchorEndIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了默认锚点,将其从 children 中移除
|
||||
if (anchorStartIndex !== -1 && anchorEndIndex !== -1) {
|
||||
inlineToken.children.splice(anchorStartIndex, anchorEndIndex - anchorStartIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加标题行内锚点
|
||||
if (inlineToken && inlineToken.children) {
|
||||
const inlineAnchor = new state.Token("html_inline", "", 0);
|
||||
inlineAnchor.content = `<a href="#${idAttr}" class="${className} inline">${symbol}</a>`;
|
||||
// 将行内锚点添加到标题文本的末端
|
||||
inlineToken.children.push(inlineAnchor);
|
||||
}
|
||||
|
||||
// 添加 AnchorLink 组件
|
||||
const anchorToken = new state.Token("html_block", "", 0);
|
||||
anchorToken.content = `<AnchorLink href="#${idAttr}" symbol="${symbol}" className="${className} normal" />`;
|
||||
// 将组件插入到 heading_close 之后
|
||||
tokens.splice(i + 1, 0, anchorToken);
|
||||
i++; // 跳过新插入的 token
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,12 +4,7 @@ import type MarkdownIt from "markdown-it";
|
||||
* 将连续的标题块包裹为独立的 section(headline-block),便于样式与交互处理
|
||||
* @param mdit - MarkdownIt 实例
|
||||
*/
|
||||
export function wrapHeadingsAsSections(mdit: MarkdownIt): void {
|
||||
if (!mdit || !mdit.core || !mdit.core.ruler) {
|
||||
console.warn("Invalid MarkdownIt instance provided");
|
||||
return;
|
||||
}
|
||||
|
||||
export function sectionWrapper(mdit: MarkdownIt): void {
|
||||
mdit.core.ruler.before("inline", "group_sections", (state) => {
|
||||
const tokens = state.tokens;
|
||||
const newTokens: any[] = [];
|
||||
131
.vitepress/theme/utils/mdTable.ts
Normal file
131
.vitepress/theme/utils/mdTable.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
type StateBlock = any;
|
||||
|
||||
/**
|
||||
* 一个简易的 markdown-it 表格插件
|
||||
* @param mdit MarkdownIt 实例
|
||||
*/
|
||||
export const table = (mdit: MarkdownIt) => {
|
||||
// 禁用原有的表格规则
|
||||
mdit.disable("table");
|
||||
|
||||
/** 表格块解析规则 */
|
||||
const tableBlock = (state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean => {
|
||||
let nextLine = startLine;
|
||||
let lineText = state.getLines(nextLine, nextLine + 1, 0, false).trim();
|
||||
|
||||
// 检查是否包含表格分隔符
|
||||
if (!lineText.includes("|")) return false;
|
||||
|
||||
const rows: string[] = [];
|
||||
while (nextLine < endLine) {
|
||||
lineText = state.getLines(nextLine, nextLine + 1, 0, false).trim();
|
||||
if (!lineText.includes("|") && lineText !== "") break;
|
||||
if (lineText !== "") rows.push(lineText);
|
||||
nextLine++;
|
||||
}
|
||||
|
||||
if (rows.length < 1) return false;
|
||||
|
||||
if (silent) return true;
|
||||
|
||||
const token = state.push("simple_table", "table", 0);
|
||||
token.map = [startLine, nextLine];
|
||||
token.content = rows.join("\n");
|
||||
|
||||
state.line = nextLine;
|
||||
return true;
|
||||
};
|
||||
|
||||
mdit.block.ruler.before("paragraph", "simple_table", tableBlock);
|
||||
|
||||
// 渲染逻辑
|
||||
mdit.renderer.rules.simple_table = (tokens, idx) => {
|
||||
const content = tokens[idx].content;
|
||||
const lines = content.split("\n").filter((l) => l.trim() !== "");
|
||||
if (lines.length === 0) return "";
|
||||
|
||||
let html = "<table>\n";
|
||||
|
||||
// 检查第二行是否是分隔行
|
||||
const hasSeparator = lines.length > 1 && /^[|:\-\s]+$/.test(lines[1]) && lines[1].includes("-");
|
||||
|
||||
let headerLines: string[] = [];
|
||||
let bodyLines: string[] = [];
|
||||
|
||||
if (hasSeparator) {
|
||||
headerLines = [lines[0]];
|
||||
bodyLines = lines.slice(2);
|
||||
} else {
|
||||
// 如果没有分隔行,用第一行作为表头
|
||||
headerLines = [lines[0]];
|
||||
bodyLines = lines.slice(1);
|
||||
}
|
||||
|
||||
// 分割单元格并清理首尾空的装饰管线
|
||||
const getCells = (line: string) => {
|
||||
const cells = line.split("|");
|
||||
if (cells.length > 0 && cells[0].trim() === "") cells.shift();
|
||||
if (cells.length > 0 && cells[cells.length - 1].trim() === "") cells.pop();
|
||||
return cells.map((c) => c.trim());
|
||||
};
|
||||
|
||||
// 解析单元格中的属性
|
||||
const parseAttributes = (cellContent: string) => {
|
||||
const attrRegex = /\{([^{}]+)\}\s*$/;
|
||||
const match = cellContent.match(attrRegex);
|
||||
const attrs: Record<string, string> = {};
|
||||
let cleanedContent = cellContent;
|
||||
|
||||
if (match) {
|
||||
cleanedContent = cellContent.replace(attrRegex, "").trim();
|
||||
const attrString = match[1];
|
||||
const parts = attrString.split(/\s+/);
|
||||
parts.forEach((part) => {
|
||||
const [key, value] = part.split("=");
|
||||
if (key && (key === "colspan" || key === "rowspan")) {
|
||||
attrs[key] = value || "1";
|
||||
}
|
||||
});
|
||||
}
|
||||
return { cleanedContent, attrs };
|
||||
};
|
||||
|
||||
// 渲染表头
|
||||
if (headerLines.length > 0) {
|
||||
html += "<thead>\n";
|
||||
headerLines.forEach((line) => {
|
||||
html += "<tr>\n";
|
||||
getCells(line).forEach((cell) => {
|
||||
const { cleanedContent, attrs } = parseAttributes(cell);
|
||||
const attrStr = Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join("");
|
||||
html += `<th${attrStr}>${mdit.renderInline(cleanedContent)}</th>\n`;
|
||||
});
|
||||
html += "</tr>\n";
|
||||
});
|
||||
html += "</thead>\n";
|
||||
}
|
||||
|
||||
// 渲染表体
|
||||
if (bodyLines.length > 0) {
|
||||
html += "<tbody>\n";
|
||||
bodyLines.forEach((line) => {
|
||||
html += "<tr>\n";
|
||||
getCells(line).forEach((cell) => {
|
||||
const { cleanedContent, attrs } = parseAttributes(cell);
|
||||
const attrStr = Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join("");
|
||||
html += `<td${attrStr}>${mdit.renderInline(cleanedContent)}</td>\n`;
|
||||
});
|
||||
html += "</tr>\n";
|
||||
});
|
||||
html += "</tbody>\n";
|
||||
}
|
||||
|
||||
html += "</table>";
|
||||
return html;
|
||||
};
|
||||
};
|
||||
26
Dockerfile
26
Dockerfile
@@ -1,11 +1,27 @@
|
||||
FROM node:lts-alpine
|
||||
# 构建阶段
|
||||
FROM node:trixie-slim AS builder
|
||||
|
||||
# 安装 git
|
||||
RUN apt-get update && apt-get install -y git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
# 拉取项目代码
|
||||
RUN git clone https://github.com/sendevia/website .
|
||||
|
||||
RUN npm install
|
||||
# 安装依赖并构建
|
||||
RUN npm i && npm run docs:build
|
||||
|
||||
EXPOSE 4173
|
||||
# 最终阶段
|
||||
FROM nginx:stable-perl
|
||||
|
||||
CMD ["npm", "run", "docs:preview"]
|
||||
# 从构建阶段复制 dist 产物到 workdir
|
||||
COPY --from=builder /app/.vitepress/dist /app/dist
|
||||
|
||||
# 暴露端口(80 HTTP 和 443 HTTPS)
|
||||
EXPOSE 80 443
|
||||
|
||||
# 启动 nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "26.2.25(298)",
|
||||
"version": "26.3.4(316)",
|
||||
"scripts": {
|
||||
"update-version": "bash ./scripts/update-version.sh",
|
||||
"docs:dev": "vitepress dev",
|
||||
@@ -17,7 +17,7 @@
|
||||
"@mdit/plugin-footnote": "^0.23.0",
|
||||
"@mdit/plugin-img-mark": "^0.23.0",
|
||||
"@mdit/plugin-tasklist": "^0.23.0",
|
||||
"markdown-it-anchor": "^9.2.0",
|
||||
"@waline/client": "^3.13.0",
|
||||
"sass-embedded": "^1.97.3",
|
||||
"vitepress": "^2.0.0-alpha.16",
|
||||
"vue": "^3.5.29"
|
||||
|
||||
@@ -59,17 +59,17 @@ external_links:
|
||||
|
||||
下面是所有头信息的详解:
|
||||
|
||||
| name | | description | type | default |
|
||||
| ------------ | ------------- | ---------------- | ------- | --------------------------- |
|
||||
| 文章相关 | title | 文章标题 | text | 使用 `_config.yml` 中的配置 |
|
||||
| ^^ | description | 文章简介 | ^^ | ^^ |
|
||||
| ^^ | author | 文章作者 | ^^ | ^^ |
|
||||
| ^^ | color | 文章主题颜色 | ^^ | ^^ |
|
||||
| ^^ | impression | 文章头图 | ^^ | ^^ |
|
||||
| ^^ | categories | 目录分类 | list | 未定义 |
|
||||
| ^^ | tags | 文章标签 | ^^ | ^^ |
|
||||
| ^^ | published | 是否发布文章 | boolean | true |
|
||||
| ^^ | toc | 是否生成文章目录 | ^^ | true |
|
||||
| 页面导航相关 | segment_icon | 导航栏中的图标 | text | - |
|
||||
| ^^ | segment_title | 导航栏中的标题 | ^^ | ^^ |
|
||||
| ^^ | navigation | 是否在导航中显示 | boolean | ^^ |
|
||||
| name { colspan=2 } | | description | type | default |
|
||||
| -------------------------- | ------------- | ---------------- | --------------------- | ----------------------------------------- |
|
||||
| 文章相关 { rowspan=9 } | title | 文章标题 | text { rowspan=5 } | 使用 `_config.yml` 中的配置 { rowspan=5 } |
|
||||
| | description | 文章简介 | | |
|
||||
| | author | 文章作者 | | |
|
||||
| | color | 文章主题颜色 | | |
|
||||
| | impression | 文章头图 | | |
|
||||
| | categories | 目录分类 | list { rowspan=2 } | 未定义 { rowspan=2 } |
|
||||
| | tags | 文章标签 | | |
|
||||
| | published | 是否发布文章 | boolean { rowspan=2 } | true { rowspan=2 } |
|
||||
| | toc | 是否生成文章目录 | | |
|
||||
| 页面导航相关 { rowspan=3 } | segment_icon | 导航栏中的图标 | text { rowspan=2 } | - { rowspan=3 } |
|
||||
| | segment_title | 导航栏中的标题 | | |
|
||||
| | navigation | 是否在导航中显示 | boolean | |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user