1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-06 15:42:34 +08:00

11 Commits

15 changed files with 377 additions and 137 deletions

65
.github/workflows/docker-build.yml vendored Normal file
View 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
View File

@@ -5,3 +5,5 @@ node_modules
package-lock.json
cache/
dist/
.agents
nginx-config.conf

View File

@@ -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";
@@ -14,6 +12,7 @@ import { tasklist } from "@mdit/plugin-tasklist";
import { imgMark } from "@mdit/plugin-img-mark";
import { sectionWrapper } from "./theme/utils/mdSectionWrapper";
import { table } from "./theme/utils/mdTable";
import { anchor } from "./theme/utils/mdCustomAnchor";
export default defineConfig({
base: "/",
@@ -23,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"],
},
@@ -42,11 +29,14 @@ export default defineConfig({
codeCopyButtonTitle: "复制代码",
config(md) {
md.use(align);
md.use(anchor, {
levels: [1, 2, 3, 4],
});
md.use(footnote);
md.use(sectionWrapper);
md.use(tasklist, { label: true });
md.use(imgMark);
md.use(sectionWrapper);
md.use(table);
md.use(tasklist, { label: true });
},
image: {
lazyLoading: true,

View 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>

View File

@@ -28,7 +28,7 @@ const siteVersion = theme.value.siteVersion;
</div>
<div class="beian-info">
<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>

View File

@@ -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", ""),
};
});
};
/** 更新指示器(高亮边框)的位置和尺寸 */

View 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 };
};

View File

@@ -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);

View File

@@ -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>

View 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;
}
}
}

View File

@@ -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,7 +57,12 @@ table {
}
}
:is(td, th) {
td,
th {
min-width: 16ch;
padding: 16px 24px;
border: 1px solid var(--md-sys-color-outline);
&:empty {

View File

@@ -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;
}
}
}
}
}
}

View 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
}
}
}
});
}

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# 构建阶段
FROM node:trixie-slim AS builder
# 安装 git
RUN apt-get update && apt-get install -y git \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 拉取项目代码
RUN git clone https://github.com/sendevia/website .
# 安装依赖并构建
RUN npm i && npm run docs:build
# 最终阶段
FROM nginx:stable-perl
# 从构建阶段复制 dist 产物到 workdir
COPY --from=builder /app/.vitepress/dist /app/dist
# 暴露端口80 HTTP 和 443 HTTPS
EXPOSE 80 443
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,5 +1,5 @@
{
"version": "26.2.28(305)",
"version": "26.3.4(316)",
"scripts": {
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",
@@ -18,7 +18,6 @@
"@mdit/plugin-img-mark": "^0.23.0",
"@mdit/plugin-tasklist": "^0.23.0",
"@waline/client": "^3.13.0",
"markdown-it-anchor": "^9.2.0",
"sass-embedded": "^1.97.3",
"vitepress": "^2.0.0-alpha.16",
"vue": "^3.5.29"