Islands

正文默认纯静态、不参与客户端激活;交互能力全部收敛在 island 上:构建期把组件预渲染成 <div data-ap-island> 占位,浏览器端只有这些节点会执行代码,正文其余部分始终是纯 HTML。内置名单在 src/shared/islands.ts,runtime 注册表有编译期覆盖检查(必须恰好覆盖内置名单)。

另一类同语法的能力是构建组件(如 <RecentArticles />):构建期直接产出最终静态 HTML,全程零客户端 JS。

语法

在 markdown 里直接写 PascalCase 标签:

md
<Tag prop="字符串" :num="1" :ok="true">内部 markdown</Tag>

内置 island

内置六个:Mermaid / G2Plot / ZoomedImg / ExpandableList / Giscus / PasswordGate。

island 用法 激活方式
Mermaid mermaid 围栏或 <Mermaid chart="..."> markdown 写围栏或标签
G2Plot <G2Plot type :data :options> markdown 写标签
ZoomedImg <ZoomedImg src alt title> markdown 写标签
ExpandableList <ExpandableList> + @@@ 条目 markdown 写标签与条目
Giscus 站点配置后文章页尾部自动挂载 不需要手写标签
PasswordGate 命中 encrypt 规则的页面自动包裹 不需要手写标签

Mermaid

最省事的写法是 mermaid 围栏——构建期占位,浏览器端按需懒加载 mermaid(含 elk 布局引擎)渲染成 SVG,随亮暗主题重渲染:

flowchart LR
  A[markdown 围栏] --> B{构建期}
  B -->|识别 mermaid| C[占位 div]
  C --> D[懒加载 mermaid]
  D --> E[SVG 渲染]
  E --> F[随主题重渲染]

源码:

md
```mermaid
flowchart LR
  A[markdown 围栏] --> B{构建期}
  B -->|识别 mermaid| C[占位 div]
  C --> D[懒加载 mermaid]
  D --> E[SVG 渲染]
  E --> F[随主题重渲染]
```

也可以显式写标签,chart 属性作图源:

源码:

md
<Mermaid chart="graph LR; A[Markdown] --> B{island?}; B -->|是| C[预渲染+激活]; B -->|否| D[纯静态 HTML];" />

同一页面可以放任意多张图。下面这张时序图用来验证交互:渲染后的图外面包了一层 d3-zoom 画布,Ctrl/Cmd + 滚轮(触控板捏合等价)缩放,按住拖拽平移,双击或点右上角按钮复位;普通滚轮不缩放,图表滚过即走,页面照常滚动:

sequenceDiagram
  participant B as 浏览器
  participant S as 静态主机
  B->>S: GET /guide/islands
  S-->>B: 静态 HTML(图表仅占位)
  B->>B: 懒加载 mermaid chunk 并渲染 SVG

源码:

md
```mermaid
sequenceDiagram
  participant B as 浏览器
  participant S as 静态主机
  B->>S: GET /guide/islands
  S-->>B: 静态 HTML(图表仅占位)
  B->>B: 懒加载 mermaid chunk 并渲染 SVG
```

G2Plot

type 选图表类型,:data 与 :options 传 JSON。options 是全量透传给 G2Plot 构造器的,所以原生能力直接写在 options 里即可——数据点多时横轴 slider 很实用,拖动下方图表的滑块可以缩放横轴视野:

源码:

md
<G2Plot
  type="line"
  :data='[{"m":"1","v":14},{"m":"2","v":17},{"m":"3","v":15},{"m":"4","v":21},{"m":"5","v":25},{"m":"6","v":23},{"m":"7","v":29},{"m":"8","v":31},{"m":"9","v":27},{"m":"10","v":33},{"m":"11","v":30},{"m":"12","v":36}]'
  :options='{"xField":"m","yField":"v","smooth":true,"height":300,"animation":false,"slider":{"start":0,"end":1}}'
/>

不指定坐标轴的图同样直接写 options。支持的 type:line / area / bar / column / pie / scatter / radar / rose / funnel / histogram / gauge / liquid / progress / ring-progress / bullet / waterfall。一张饼图:

源码:

md
<G2Plot
  type="pie"
  :data='[{"type":"内置","value":5},{"type":"自定义","value":1}]'
  :options='{"angleField":"value","colorField":"type","height":260,"legend":{"position":"bottom"},"label":{"type":"outer"}}'
/>

各图表类型的 options 形状以 G2Plot 文档为准,island 只透传不校验(由 G2Plot 运行时校验)。island 的 JSON 属性解析窗口上限 64KB,常规数据随便写;数据项特别多时压缩 key(如上面的 m/v),或把数据交给站点 island 的 TS 数据模块、markdown 只留标签。

ZoomedImg

点击放大的图片 island,共享全局 photoSwipe lightbox(esc 或点击关闭),适合截图、图表照片这类需要看细节的图。下面这张可以点开:

源码:

md
<ZoomedImg src="https://github.com/github.png" alt="GitHub Octocat" title="点击放大" />

src 为远程 URL 时原样传给客户端;相对路径(./ ../)与站点内绝对路径(/images/...)由构建期解析,走与普通 markdown 图片相同的资源管线(构建时复制、按页面深度重写)。迁移旧站内容还可用两个可选属性:scale(容器宽度百分比,"60%" / "0.6" / 60 均可,缺省为自然宽度)与 mask(true 时先以模糊遮罩隐藏、点击后显示,再点击走放大)。

ExpandableList

可展开表格列表:条目数据 + 每条一段 markdown 内容,面向「搜索/排序/展开收起」式长列表(条目数 100+ 也没问题)。构建期把条目渲染成一张静态表格(无 JS 时全部展开,SEO 照常收录),激活后提供搜索、表头点击排序与单行折叠;「点击表格行可以展开详细内容哦!」提示气泡、搜索框与「n / n 条」计数行都可用 prop 逐个隐藏。行尾有随展开状态旋转的箭头,可展开行 hover 时有主题色描边。

条目用 @@@ 标题 行分隔(行首 ≤3 空格缩进,标题是单行纯文本);第一个 @@@ 之前的内容是 preamble,静态显示在工具栏上方。条目正文就是普通 markdown,容器、表格、图片、代码块都可用。下面这个列表可以实际操作——试试搜索「容器」、「行内代码」:

嵌套容器

条目里可以放 ::: 容器、表格、脚注:

注意

条目内容在构建期独立渲染,标题不进页面 TOC。

语法 含义
@@@ x 新条目 x
@@@@ 普通文本
代码块

围栏代码块里的 @@@ 行是内容不是分隔符:

md
@@@ 这一行在代码围栏里,不会被切分

行内代码里的 <Tag> 也不扫描。

嵌套 island

条目内容里可以再写别的 island(island 依旧块级使用):

激活后照常可点击放大;被搜索/排序移出列表再移回来时会重新挂载。

空标题

@@@ 后面不写标题也能成立,列表里显示「未命名条目」。

源码(节选):

Props(均可选):

关闭搜索与排序后,工具栏只剩计数(:showCount="false" 可以连它也去掉):

只剩折叠

关闭搜索与排序后,单条折叠仍然可用。

第二条

工具栏高度更矮,列表更安静。

源码:

md
<ExpandableList :searchable="false" :sortable="false">

@@@ 只剩折叠

关闭搜索与排序后,单条折叠仍然可用。

@@@ 第二条

工具栏高度更矮,列表更安静。

</ExpandableList>

表格列与表头

@@@ 行的下一行可以紧跟一条 @@ 元信息 行(单行、行内 markdown),渲染为该行的 meta 列单元格——迁移「表格 + 展开行」式列表时用来承载时长/评分这类列信息。meta 用 | 切分成多列(每列仍是行内 markdown;\| 是转义的竖线,行内代码里的 | 不切分),:columns 属性(JSON 数组)传入各列表头:第 1 项是标题列,其余依次对应 meta 的 | 分列。meta 段数少于列头时补空单元格,多出的段仍渲染(表头补空);单独的 - 是空单元格占位符(弱化渲染),日期样式单元格(含 ~ 区间或 YYYY-MM 前缀)保持单行。框架不做内容识别——badge 药丸、评分强调色、行高亮这类语义样式由下文的数据驱动表格(XList)以类型化字段表达。窄屏(<768px)下标题列放宽、meta 列 ≥4 的宽表在容器内横向滚动:

书名 阅读区间 时长 备注
设计中的设计
2021-08-25 ~ 2021-09-026h12min纸质书
重构
2021-08-25 ~ ?3h49min第 2 章

源码:

md
<ExpandableList :columns='["书名","阅读区间","时长","备注"]'>

@@@ 设计中的设计
@@ 2021-08-25 ~ 2021-09-02 | 6h12min | 纸质书

@@@ 重构
@@ 2021-08-25 ~ ? | 3h49min | 第 2 章

</ExpandableList>

不带 | 的旧式单段 meta(@@ 23h · 值得一读)仍渲染为单列,完全向后兼容。

行为细节:

Giscus

评论 island 由构建层在文章页尾部自动挂载,不需要在 markdown 手写标签;首页、归档等非文章页不挂。站点配置:

ts
giscus: {
  repo: 'owner/repo',
  repoId: 'R_xxx',
  category: 'General',
  categoryId: 'DIC_xxx',
}

本站配置了真实的 giscus 凭证,所以每一篇文章(包括本页)底部都有评论区,往下滚动就能看到。评论 iframe 的亮暗主题通过 postMessage 跟随站点切换,翻转主题不重载 iframe、不丢评论状态;评论与关联文章图共存时,关联图位于评论区上方。接入步骤与仓库前提见搜索与评论。

PasswordGate

密码门 island 同样由构建层自动包裹命中 encrypt 规则的页面,不手写。加密演示页是一个真实生效的例子,机制与边界见加密。

站点自定义 island:Counter

内置名单之外的能力,经站点配置 islands 追加(本站 vite.config.ts 注册了 Counter: 'docs/islands/Counter.tsx'),之后任何 markdown 页面都能直接用。这是一个真实运行的计数器:

内部 markdown 在构建期预渲染;Counter 组件本身不渲染 childrenHtml,激活后显示按钮。

源码:

md
<Counter :initial="5" label="点击次数">

**内部 markdown** 在构建期预渲染;Counter 组件本身不渲染 `childrenHtml`,激活后显示按钮。

</Counter>

组件源码只有二十来行,是自定义 island 的最小参考:

实现要点:模块默认导出 IslandComponent(import type { IslandComponent } from 'absolute-press/client');标签名(Counter)与配置 key 一致,PascalCase。IslandProps 除自身 props 外带可选的 childrenHtml(内部 markdown 的预渲染 HTML)。需要页面元数据(locale、base、页面标题等)时,用同入口导出的 pagePayload() 读取页面 payload:每次调用实时读取,客户端软导航时路由会把脚本内容同步到当前页。

站点 island 可用的框架工具

absolute-press/client 还导出一组内置 island 同款的公共工具,站点 island 直接 import 即可,不必重复造轮子或深 import 内部路径:

导出 用途
pagePayload() 读当前页 payload(locale、base、标题、navbar/sidebar 等),软导航自动跟随
hydrateIslands(root?) 激活 root 下 [data-ap-island] 占位;island 重建 DOM 后重新激活嵌套 island
islandRegistry() 当前完整 island 注册表(内置 + 站点)
mountComponent(comp, el, props) 把 Solid 组件挂到已有 DOM 节点(MPA 下直接挂载,非整页激活),事件委托开箱即用
cx(...cls) 条件 className 拼接(静态字符串,UnoCSS 可扫描)
flagOn(value, fallback?) 解析布尔 prop:JSON 布尔、"false"/"0"、裸属性统一处理
pageMessages() 按 <html lang> 取 UI 文案表(未知语言回退中文)
formatMessage(tpl, params) 填充文案模板里的 {key} 占位符
Fold 行内折叠原语:两行钳制 + 右侧渐隐,点击或 Enter/Space 展开/收起;短内容不会显示为可折叠

这些就是内置 island 用的同一批积木——例如 ExpandableList 的搜索框开关用 flagOn、表格类名用 cx、工具栏文案用 pageMessages() + formatMessage。一个组合使用的最小示例:

ts
import type { IslandComponent } from 'absolute-press/client';
import { cx, flagOn, formatMessage, pageMessages } from 'absolute-press/client';

const Notice: IslandComponent = props => {
  const t = pageMessages();
  const el = document.createElement('div');
  el.className = cx('my-notice', flagOn(props.compact, false) && 'is-compact');
  const text = typeof props['text'] === 'string' ? props['text'] : '';
  el.textContent = formatMessage(text || '{n} 条新回复', { n: 3 });
  return el;
};

export default Notice;

站点 island 复用条目管线(entryList)

站点 island 想复用 ExpandableList 同款「@@@ 条目 + 静态表格骨架」行为时,把 island 名加进站点配置 entryListIslands: ['MyList', …],构建期就按同一条管线拆分 <MyList> 的 children:@@@/@@ 语法、围栏保护、静态骨架渲染完全一致(未加入名单的 island 保持字面量)。客户端组件从 childrenHtml 接住这份静态 HTML,按条目拆回并填入自己的数据(典型场景是「TS 数据模块管 meta 列、markdown 只留插槽正文」的列表组件);条目里的嵌套 island 用 absolute-press/client 导出的 hydrateIslands 重新激活。

管线拆分与激活时序的实现细节见设计与实现:islands 运行时。

数据驱动表格(XList)

ExpandableList 的行数据来自 @@ meta 行,单元格按 markdown 原样渲染、无内容识别;条目多、meta 列需要精确控制时,更稳的做法是 TS 数据模块做单一数据源,markdown 只留 @@@ 插槽正文。absolute-press/client 导出的 XList 组件与配套装配工具就是这条路径:站点写一个薄包装 island,把数据映射成类型化条目交给 XList,即可得到表头点击循环排序(所有列默认可排序:带 sortValue 的数值列按数值 降→升→默认,其余列按单元格文本自然序 升→降→默认;sorters 可按列覆盖比较器)、多关键字搜索(含隐藏别名)、工具栏排除过滤器、富标题格(链接/badge/折叠文本)与可展开行。

本文档站就注册了一个这样的 island(DemoXList,配置见根 vite.config.ts 的 islands + entryListIslands)。可以实际操作:语言列按文本自然序、Stars 列按数值排序(降序优先),状态列按白名单不画排序三角,过滤器勾选后隐藏非活跃项目,点行展开插槽正文:

ripgrep

递归正则搜索,grep 的高速替代。

fd

find 的现代替代,语法直觉。

bat

带语法高亮的 cat 克隆。

fzf

通用模糊查找器。

jq

命令行 JSON 处理器。

eza

ls 的现代替代,exa 的继任者。

xsv

CSV 命令行工具箱,已被 qsv 接棒。

一个最小包装(数据模块 games.ts 导出数组):

注册与其他走条目管线的站点 island 相同:配置 islands 登记 GameList: '…/GameList.tsx',并把名字加进 entryListIslands(上一节),@@@ 插槽正文才会走构建期管线;markdown 里只剩标签与插槽:

md
<GameList>

@@@ CLANNAD

条目正文就是普通 markdown。

</GameList>

行为要点:

站点 island 读取全站数据(site-data)

需要全站页面清单的 island(归档索引、统计卡片这类)不必自己扫内容:站点配置的 onScan 钩子每轮扫描后拿到页面清单并返回任意 JSON 数据,返回值注入 virtual:absolute-press/site-data 虚拟模块。island 直接 import 这个模块,default export 就是 onScan 的返回值;dev 下内容编辑触发新一轮扫描,数据自动刷新。配置签名与 SiteScanPage 字段见配置参考:

ts
// vite.config.ts(节选)
import { defineSiteConfig } from 'absolute-press';
import type { SiteScanContext } from 'absolute-press';

export default defineSiteConfig({
  // ...
  onScan: (ctx: SiteScanContext) => ({
    pages: ctx.pages.map(page => ({
      route: page.route,
      date: page.createdAt,
      tags: page.frontmatter.tag ?? [],
    })),
  }),
});

island 侧的读取:

ts
import type { IslandComponent } from 'absolute-press/client';
import siteData from 'virtual:absolute-press/site-data';

const PageIndex: IslandComponent = () => {
  const list = document.createElement('ul');
  for (const page of siteData.pages) {
    const item = document.createElement('li');
    item.textContent = `${page.route}${page.date ? ` (${page.date.slice(0, 10)})` : ''}`;
    list.append(item);
  }
  return list;
};

export default PageIndex;

模块没有内置类型,站点在自己的环境声明文件(如 src/env.d.ts)里声明,default export 写成 onScan 返回值的形状——框架对自己的 islands 虚拟模块也是这么做的(src/client/runtime/env.d.ts):

ts
declare module 'virtual:absolute-press/site-data' {
  const siteData: {
    pages: { route: string; date: string | null; tags: string[] }[];
  };
  export default siteData;
}

组件照常经站点配置 islands 注册后在 markdown 里使用。未配置 onScan 时 import 该模块会在构建期报错,错误信息指向 onScan 配置;数据经 JSON 序列化注入,返回值里的 Date 等非 JSON 值不会原样到达 island。

约束与注意