文章

Post VS Page

FrontMatter ​

提示

更多配置项可参见:

PostFrontmatter Types
post.ts
ts
import type { PageFrontMatter } from './page'

export type ExcerptType = 'md' | 'html' | 'text' | 'ai'

export interface PostFrontMatter extends PageFrontMatter {
  /**
   * @description:en-US Custom post title class in post list
   * @description:zh-CN 文章列表中 自定义标题样式
   */
  postTitleClass: string

  /**
   * @description:en-US Post Card Type, can be bilibili/yuque/... (need theme support)
   * @description:zh-CN 卡片类型,可以是 bilibili/yuque/... (需主题支持)
   */
  type: 'bilibili' | 'yuque' | string
  /**
   * @en override url, and jump directly
   * @zh 覆盖 post url,直接跳转
   */
  url: string
  /**
   * @description:en-US custom excerpt, `excerpt_type` will be invalid
   * @description 手动指定摘要,此时 `excerpt_type` 将会无效
   */
  excerpt: string
  /**
   * @description 摘要类型
   * @default 'html'
   * render type of excerpt
   * - md: render as raw markdown
   * - html: render as html
   * - text: render as text
   */
  excerpt_type: 'md' | 'text' | 'html' | 'ai'

  /**
   * @description:en-US Category, if it is an array, it represents multiple folders in order
   * @description:zh-CN 分类,若为数组,则按顺序代表多层文件夹
   */
  categories: string | string[]
  /**
   * @description:en-US Tags, can have multiple
   * @description:zh-CN 标签,可以有多个
   */
  tags: string[]

  /**
   * @description:en-US Whether to display the previous and next navigation
   * @description:zh-CN 是否显示前一篇、后一篇导航
   */
  nav: boolean

  /**
   * @description:en-US Pin to top, the larger the number, the closer to the front
   * @description:zh-CN 置顶,数字越大越靠前
   */
  top: number

  /**
   * @description:en-US Whether it is a draft, it will only be displayed during development
   * @description:zh-CN 是否为草稿,将仅在开发时被展示
   */
  draft: boolean
  /**
   * hide in index
   * - true/`all`: hide in index & archive
   * - `index`: hide in index
   * @description 是否隐藏
   */
  hide: 'index' | boolean

  /**
   * @en
   * when the post is updated more than 30 days ago, show a warning
   * default 30 days, you can set `time_warning` in frontmatter to change it
   *
   * @zh
   * 当文章更新时间超过 30 天时,显示一个警告
   * 默认 30 天,你可以在 frontmatter 中设置 `time_warning` (数字)来修改,单位 ms
   * @example 3600000
   */
  time_warning: boolean | number

  /**
   * @protected
   * @see https://valaxy.site/guide/config/#%E9%98%85%E8%AF%BB%E7%BB%9F%E8%AE%A1
   * @tutorial ⚠️ DO NOT SET MANUALLY (generated by `site.config.ts` -> `statistics.enable: true`)
   * You can use `statistics.readTime.speed` to change the speed of reading time.
   * @description:en-US Reading time
   * @description:zh-CN 阅读时间
   */
  readingTime: number
  /**
   * @protected
   * @see https://valaxy.site/guide/config/#%E9%98%85%E8%AF%BB%E7%BB%9F%E8%AE%A1
   * @tutorial ⚠️ DO NOT SET MANUALLY (generated by `site.config.ts` -> `statistics.enable: true`)
   * You need enable `statistics` in site config to use this feature.
   * @description:en-US Word count
   * @description:zh-CN 字数统计
   */
  wordCount: string
}

文章(post)继承自页面(page),因此页面中的 Front Matter 通用被文章支持。

单篇文章支持的配置项。

譬如:

md
---
title: Title
hide: true
---
  • title: 文章标题
  • hide: 你可以在文章头部添加 hide 属性,来临时隐藏某篇文章。(该文章仍然会被渲染)
    • true / all: 当设置为 true 或 all 时,该文章仍然会被渲染,你可以直接访问链接进行查看。但不会被显示在展示的文章卡片与归档中。
    • index: 设置为 index 时,将只在首页隐藏,归档中仍然展示。(譬如放一些没有必要放在首页的笔记,并在归档中方便自己查看。)

图片与 Vue 组件封面 ​

Yun 的文章卡片和文章头部都支持图片封面与可交互的 Vue 组件封面。组件封面不需要图片,可以完全使用内联 SVG、CSS 或 Canvas 绘制。

用 Vue 与 SVG 绘制封面 ​

下面的天空、云层、轨道和星座全部由内联 SVG 绘制。点击按钮会连起星座并启动动画,再次点击暂停。不使用生成图片、图片地址或额外动画依赖。

Hello, Valaxy! 让文字,连成新的星座。
  1. 在博客中创建 components/covers/HelloValaxyCover.vue。默认博客脚手架已包含该组件;已有博客可复制下面的完整代码。
  2. 在文章 Front Matter 中指定组件即可,cover 图片可省略:
yaml
---
title: Hello, Valaxy!
coverComponent: HelloValaxyCover
coverProps:
  subtitle: 让文字,连成新的星座。
---
完整 Vue + SVG 组件代码
HelloValaxyCover.vue
vue
<script setup lang="ts">
import { onMounted, onUnmounted, shallowRef, useId, useTemplateRef } from 'vue'

withDefaults(defineProps<{
  context?: 'card' | 'page' | 'body'
  subtitle?: string
}>(), {
  context: 'body',
  subtitle: 'Your words, a new constellation.',
})

const id = useId()
const root = shallowRef<HTMLElement>()
const svg = useTemplateRef<SVGSVGElement>('svg')
defineExpose({ svg })
const illuminated = shallowRef(false)
const inView = shallowRef(false)
const pageVisible = shallowRef(true)
// Fixed coordinates keep the server render and the first client render identical.
const stars = Array.from({ length: 36 }, (_, i) => ({
  x: (i * 137 + 41) % 960,
  y: (i * 73 + 29) % 410,
  r: i % 4 === 0 ? 2 : 1,
}))
const constellation = [[530, 110], [570, 165], [615, 228], [660, 290], [707, 215], [754, 150], [794, 88]]
let observer: IntersectionObserver | undefined
function updateVisibility() {
  pageVisible.value = document.visibilityState !== 'hidden'
}
onMounted(() => {
  updateVisibility()
  document.addEventListener('visibilitychange', updateVisibility)
  if (typeof IntersectionObserver !== 'undefined') {
    observer = new IntersectionObserver(([entry]) => {
      inView.value = entry.isIntersecting
    })
    if (root.value)
      observer.observe(root.value)
  }
  else {
    inView.value = true
  }
})
onUnmounted(() => {
  observer?.disconnect()
  document.removeEventListener('visibilitychange', updateVisibility)
})
</script>

<template>
  <div
    ref="root" class="hello-valaxy-cover" :data-context="context"
    :class="{ illuminated, playing: illuminated && inView && pageVisible, compact: context === 'card' }"
  >
    <svg ref="svg" class="hello-valaxy-sky" viewBox="0 0 960 540" preserveAspectRatio="xMinYMid slice" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient :id="`${id}-sky`" x2="0.8" y2="1">
          <stop stop-color="#102751" />
          <stop offset="0.65" stop-color="#225d93" />
          <stop offset="1" stop-color="#7cc9da" />
        </linearGradient>
        <radialGradient :id="`${id}-halo`">
          <stop stop-color="#95e5ed" stop-opacity="0.3" />
          <stop offset="1" stop-color="#95e5ed" stop-opacity="0" />
        </radialGradient>
        <linearGradient :id="`${id}-caption`">
          <stop stop-color="#091b3b" stop-opacity="0.65" />
          <stop offset="0.8" stop-color="#091b3b" stop-opacity="0" />
        </linearGradient>
      </defs>
      <path d="M0 0H960V540H0Z" :fill="`url(#${id}-sky)`" />
      <circle cx="670" cy="210" r="250" :fill="`url(#${id}-halo)`" />
      <g fill="#daefff" opacity="0.6">
        <circle v-for="(star, i) in stars" :key="i" :cx="star.x" :cy="star.y" :r="star.r" />
      </g>
      <g class="hello-valaxy-orbits" fill="none" stroke="#b8e5f3">
        <circle cx="660" cy="208" r="158" opacity="0.2" />
        <ellipse cx="660" cy="208" rx="212" ry="66" transform="rotate(-28 660 208)" opacity="0.25" />
        <g class="hello-valaxy-orbit hello-valaxy-motion">
          <circle cx="818" cy="208" r="5" fill="#b5f1ee" stroke="none" />
          <circle cx="818" cy="208" r="11" opacity="0.3" />
        </g>
      </g>
      <g fill="none" stroke="#c4f6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M530 110 570 165 615 228 660 290 707 215 754 150 794 88" opacity="0.18" />
        <path class="hello-valaxy-constellation" d="M530 110 570 165 615 228 660 290 707 215 754 150 794 88" pathLength="1" />
      </g>
      <g class="hello-valaxy-stars" fill="#e0fcff">
        <g v-for="([x, y], i) in constellation" :key="i">
          <circle class="hello-valaxy-glow" :cx="x" :cy="y" r="12" />
          <path :d="`M${x - 6} ${y}h12M${x} ${y - 6}v12`" stroke="currentColor" />
          <circle :cx="x" :cy="y" r="3" />
        </g>
      </g>
      <g class="hello-valaxy-clouds hello-valaxy-motion">
        <path d="M-40 460Q30 366 110 420Q148 340 242 408Q304 369 374 433Q440 347 528 408Q605 350 690 425Q790 335 886 410Q954 385 1000 443V570H-40Z" fill="#6faecb" opacity="0.35" />
        <path d="M-40 494Q60 416 148 474Q223 403 326 473Q405 428 473 487Q565 391 660 466Q748 412 835 474Q920 404 1000 478V570H-40Z" fill="#b4e4e9" opacity="0.4" />
      </g>
      <path d="M0 0H960V540H0Z" :fill="`url(#${id}-caption)`" />
      <g fill="#f4fbff">
        <text x="60" y="342" :style="{ fontFamily: 'Georgia, serif', fontSize: context === 'card' ? '74px' : '64px', fontWeight: 800 }">Hello, Valaxy!</text>
        <text x="60" y="388" :style="{ fontFamily: 'sans-serif', fontSize: context === 'card' ? '28px' : '22px' }">{{ subtitle }}</text>
      </g>
    </svg>
    <div class="hello-valaxy-caption">
      <span class="hello-valaxy-description">Hello, Valaxy! {{ subtitle }}</span>
      <button type="button" :aria-pressed="illuminated" @click="illuminated = !illuminated">
        {{ illuminated ? 'Stars are shining' : 'Light up the stars' }}
      </button>
    </div>
  </div>
</template>

<style scoped>
.hello-valaxy-cover {
  position: relative;
  isolation: isolate;
  width: 100%;
  height: auto;
  min-height: 12rem;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  color: #f4fbff;
  background: #102751;
  border-radius: inherit;
}

.hello-valaxy-cover[data-context="card"],
.hello-valaxy-cover[data-context="page"] {
  height: 100%;
}

.hello-valaxy-sky {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.hello-valaxy-caption {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  padding: clamp(1rem, 4vw, 2.5rem);
  pointer-events: none;
}

.hello-valaxy-description {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.hello-valaxy-caption button {
  padding: 0.4rem 0.8rem;
  border: 1px solid rgb(220 245 255 / 0.65);
  border-radius: 2rem;
  color: inherit;
  background: #153c64;
  font: inherit;
  font-size: 0.8rem;
  cursor: pointer;
  pointer-events: auto;
}

.hello-valaxy-caption button:focus-visible {
  outline: 2px solid #c4f6ff;
  outline-offset: 4px;
}

.hello-valaxy-constellation {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset 900ms ease;
}

.illuminated .hello-valaxy-constellation {
  stroke-dashoffset: 0;
}

.hello-valaxy-glow {
  opacity: 0;
  transition: opacity 600ms ease;
}

.illuminated .hello-valaxy-glow {
  opacity: 0.2;
}

.hello-valaxy-orbit {
  transform-origin: 660px 208px;
  animation: hello-valaxy-orbit 24s linear infinite;
}

.hello-valaxy-clouds {
  animation: hello-valaxy-drift 12s ease-in-out infinite alternate;
}

.hello-valaxy-motion {
  animation-play-state: paused;
}

.playing .hello-valaxy-motion {
  animation-play-state: running;
}

.compact .hello-valaxy-caption {
  padding: 1rem;
}

@keyframes hello-valaxy-orbit {
  to { transform: rotate(360deg); }
}

@keyframes hello-valaxy-drift {
  to { transform: translate(18px, -8px); }
}

@media (prefers-reduced-motion: reduce) {
  .hello-valaxy-motion {
    animation: none;
  }

  .hello-valaxy-constellation,
  .hello-valaxy-glow {
    transition: none;
  }
}
</style>
  • coverComponent 是文件名对应的 PascalCase 组件名,不是文件路径或模板字符串。
  • coverProps 只使用可序列化的值,例如字符串、数字、布尔值、数组和普通对象。
  • 容器传入 context(卡片为 card,文章头部为 page)和可选的 src(原始 cover 地址)。这两个保留属性优先于 coverProps 中的同名值;纯图形组件可以忽略 src。
  • 组件按需加载,并参与静态生成。JavaScript 启动前已有 SVG 画面,水合后即可交互。
  • 卡片中的组件区域可以独立交互;标题及卡片其他区域仍可打开文章。

也支持 src/components/covers/,以及主题和插件的 components/covers/。同名组件遵循正常的根目录覆盖顺序;同一目录树内的文件应使用唯一组件名。

正文中复用组件 ​

正文可以直接使用同一个组件,并传入独立的属性:

md
<HelloValaxyCover subtitle="在正文中,也能点亮星空。" />

示例组件直接使用时默认 context="body",卡片、文章头部和正文实例各自保存开关状态。Yun 已接入组件封面;其他主题可以使用核心的 <ValaxyCover :src="cover" :component="coverComponent" :component-props="coverProps" /> 接入。正文 Vue 组件的支持不受主题限制,更多见 在 Markdown 中使用 Vue。

动画与渲染 ​

示例通过 Vue 状态与 CSS 过渡、关键帧驱动 SVG,无需额外动画库。点击后开始动画,移出视口或切换到后台标签页时暂停;系统开启减少动态效果时保留开关功能,并停用运动和过渡。

SVG 渐变、遮罩等 ID 使用 useId(),避免同一页面多个实例相互影响。初始坐标应保持确定,不要在 setup 中调用 Math.random() 或读取 window。IntersectionObserver 等浏览器 API 放在 onMounted 中,并在卸载时清理。需要 DOM 的 Canvas/WebGL 库可以放在 <ClientOnly> 中,并提供 SVG 静态回退。

Motion 是可选项。Yun 已提供 @vueuse/motion;也可以在博客中执行 pnpm add motion-v,使用它的 <motion.path> 和 SVG 描边动画。这是两个不同的库,API 不能混用。参见 Motion for Vue:SVG 描边动画。在封面组件内导入动画库,并保留可见性、减少动态效果和 SSR 处理即可。

可选图片与分享预览 ​

纯图片封面仍使用 cover: /images/my-cover.jpg。使用组件封面时,cover 可以提供组件缺失或运行失败后的图片回退;未设置时就没有图片回退。分享图片可以通过独立的 ogImage 指定:

yaml
coverComponent: HelloValaxyCover
# 两项都可省略;需要时使用自己的图片文件。
# cover: /images/cover-fallback.jpg
# ogImage: /images/share-preview.png

Valaxy 不会在构建时自动截图 Vue 组件。你可以用下方的导出功能保存静态分享图。分享预览按 ogImage、cover、正文首图、站点 favicon 的顺序取值。SVG 组件封面的显示不依赖这两个图片字段。需要加载图片的组件可以用 withBase(src) 适配子路径部署。

从同一份 SVG 导出分享图 ​

点击上方示例的「导出 PNG 分享图」即可下载 1200 × 630 的 PNG。背景、星座、云层和文字都来自正在展示的 SVG,CSS 动画会定格为导出时的画面;HTML 按钮不进入图片,也不会改变当前开关状态。

将下载的 hello-valaxy-og.png 放入博客的 public/images/,然后设置:

yaml
ogImage: /images/hello-valaxy-og.png

修改组件或文案后重新导出即可。无需生图服务、上传或额外构建依赖。

自定义导出入口可以使用 <ValaxySvgExport>,传入已挂载的 SVG 元素。示例封面通过 defineExpose({ svg }) 暴露它:

vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const cover = useTemplateRef<{ svg: SVGSVGElement | null }>('cover')
</script>

<template>
  <HelloValaxyCover ref="cover" />
  <ValaxySvgExport :svg="cover?.svg" filename="hello-valaxy-og.png" label="导出 PNG 分享图" />
</template>

也可以 import { svgToPng } from 'valaxy',调用 await svgToPng(svg, { width: 1200, height: 630 }) 获取 PNG Blob,自行处理保存。尺寸变化遵循 SVG 的 viewBox 和 preserveAspectRatio;本例会略微裁切上下边缘。

导出针对自包含 SVG:图片资源需内嵌为 data URL,文字建议使用系统字体;不支持 HTML/foreignObject、SMIL 动画,也不会自动嵌入外部字体。请在浏览器挂载后触发导出,不能在 SSR setup 中调用。

排查组件封面 ​

开发模式下,组件名不存在会在封面区域和控制台提示检查 components/covers/ 中的文件名;组件加载或渲染失败会显示组件名,并在控制台保留原始错误。设置了 cover 时仍保留图片回退。生产环境不展示这些开发提示。

摘要 ​

你可以通过插入 <!-- more --> 的方式生成摘要(excerpt)。 可通过设置 excerpt_type 设置摘要渲染类型。

  • excerpt: 自定义摘要(优先级高于 <!-- more -->)
  • excerpt_type: 预览列表摘要的渲染类型(与 <!-- more --> 配合使用)
    • md: 展示原始 Markdown
    • html: 以 HTML 形式展示
    • text: 以纯文本形式展示(去除 HTML 标签)
md
---
title: 'excerpt_type: text'
excerpt_type: text
---

## Header

![yun-bg](https://cdn.yunyoujun.cn/img/bg/stars-timing-0-blur-30px.jpg)

<!-- more -->

Main Content
md
---
title: 'excerpt_type: md'
excerpt_type: md
---

## Header

![yun-bg](https://cdn.yunyoujun.cn/img/bg/stars-timing-0-blur-30px.jpg)

<!-- more -->

Main Content
md
---
title: 'excerpt_type: html'
excerpt_type: html
---

## Header

![yun-bg](https://cdn.yunyoujun.cn/img/bg/stars-timing-0-blur-30px.jpg)

<!-- more -->

Main Content
md
---
title: 'custom excerpt'
excerpt: This is a custom excerpt.
---

## Header

![yun-bg](https://cdn.yunyoujun.cn/img/bg/stars-timing-0-blur-30px.jpg)

Main Content

You will get excerpt:

md
HEADER yun-bg
md
## Header ![yun-bg](https://cdn.yunyoujun.cn/img/bg/stars-timing-0-blur-30px.jpg)
md
<!-- Rendered HTML -->
md
This is a custom excerpt.

插入 ​

组件 ​

  • 如想在文章中插入现有公共组件,请参照 组件。
  • 如想在文章中插入自定义组件,请参照 自定义组件。

脚本 ​

可直接通过 useScriptTag 使用,封装为组件或直接添加在文章中。

vue
<script lang="ts" setup>
useScriptTag('https://static.codepen.io/assets/embed/ei.js')
</script>

强制规范 ​

由于 Valaxy 支持解析 Vue 组件渲染,因此当您输入 <CustomComponent></CustomComponent> 时,它会解析 components 目录下的 CustomComponent.vue 组件并渲染。

当您不需要其被渲染时,请务必使用反引号包裹,如:

md
`<CustomComponent></CustomComponent>`

To Be Continued.

贡献者