Islands

Content is fully static by default and takes no part in client-side activation; all interactivity is concentrated in islands: at build time a component is pre-rendered into a <div data-ap-island> placeholder, and only these nodes execute code in the browser — the rest of the content stays pure HTML. The built-in list lives in src/shared/islands.ts, and the runtime registry has a compile-time coverage check (it must cover exactly the built-in list).

A sibling capability with the same tag syntax is build components (e.g. <RecentArticles />): they produce their final static HTML at build time, with zero client JS.

Syntax

Write PascalCase tags directly in markdown:

md
<Tag prop="string" :num="1" :ok="true">inner markdown</Tag>

Built-in islands

Six built in: Mermaid / G2Plot / ZoomedImg / ExpandableList / Giscus / PasswordGate.

island Usage Activation
Mermaid mermaid fence or <Mermaid chart="..."> write the fence or tag in markdown
G2Plot <G2Plot type :data :options> write the tag in markdown
ZoomedImg <ZoomedImg src alt title> write the tag in markdown
ExpandableList <ExpandableList> + @@@ entries write the tag and entries in markdown
Giscus auto-mounted at the end of article pages once configured no manual tag
PasswordGate auto-wraps pages matching encrypt rules no manual tag

Mermaid

The easiest form is a mermaid fence — a placeholder at build time, then the browser lazily loads mermaid (with the elk layout engine) on demand and renders SVG, re-rendering on light/dark theme switches:

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

Source:

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

You can also write the tag explicitly, with the chart attribute as the diagram source:

Source:

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

A page can hold any number of diagrams. The sequence diagram below exists to verify interaction: the rendered diagram is wrapped in a d3-zoom canvas — Ctrl/Cmd + wheel (pinch on trackpads is equivalent) zooms, drag pans, double-click or the top-right button resets; a plain wheel scroll does not zoom, the chart scrolls past and the page keeps scrolling:

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

Source:

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

G2Plot

type picks the chart type; :data and :options take JSON. options passes through wholesale to the G2Plot constructor, so native capabilities go straight into options — an x-axis slider pays off with many data points; drag the slider under the chart below to zoom the x-axis view:

Source:

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}}'
/>

Charts without axes likewise take plain options. Supported type values: line / area / bar / column / pie / scatter / radar / rose / funnel / histogram / gauge / liquid / progress / ring-progress / bullet / waterfall. A pie chart:

Source:

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

For the options shape of each chart type, defer to the G2Plot docs; the island passes options through without validation (the G2Plot runtime validates). The island's JSON attribute parsing window caps at 64KB, plenty for regular data; with many data items, compress keys (like m/v above), or move data into a TS data module of a site island and keep only the tag in markdown.

ZoomedImg

A click-to-zoom image island sharing a global photoSwipe lightbox (esc or click to close), suited to screenshots and diagram photos that need closer inspection. The one below opens on click:

Source:

md
<ZoomedImg src="https://github.com/github.png" alt="GitHub Octocat" title="Click to zoom" />

A remote URL src passes to the client as-is; relative paths (./ ../) and site-absolute paths (/images/...) resolve at build time through the same asset pipeline as regular markdown images (copied at build, rewritten per page depth). Two optional props help migrating old content: scale (container width percentage; "60%" / "0.6" / 60 all work, natural width by default) and mask (when true, the image hides behind a blur mask first, shows on click, and zooms on the next click).

ExpandableList

An expandable table list: entry data plus one markdown block per entry, aimed at search/sort/expand-style long lists (100+ entries are fine). At build time entries render into a static table (fully expanded without JS, indexed by SEO as usual); after activation it offers search, header-click sorting, and per-row folding. The "Click a table row to expand its details!" hint bubble, the search box, and the "n / n items" count line can each be hidden with a prop. Rows end with an arrow that rotates with the expand state, and expandable rows get a theme-colored outline on hover.

Entries are separated by @@@ Title lines (up to 3 leading spaces; the title is single-line plain text); content before the first @@@ is the preamble, rendered statically above the toolbar. Entry bodies are regular markdown — containers, tables, images, and code blocks all work. The list below is fully operational — try searching "container" or "inline code":

Nested containers

Entries can hold ::: containers, tables, and footnotes:

Note

Entry content renders independently at build time; its headings do not enter the page TOC.

Syntax Meaning
@@@ x new entry x
@@@@ regular text
Code blocks

An @@@ line inside a fenced code block is content, not a separator:

md
@@@ this line sits inside a code fence and is not split

<Tag> inside inline code is not scanned either.

Nested island

Entry content can contain other islands (islands remain block-level):

Click-to-zoom works after activation; when search/sort moves an entry out of the list and back, it remounts.

Empty title

@@@ with no title still works; the list shows it as an untitled entry.

Source (excerpt):

Props (all optional):

With search and sort off, the toolbar keeps only the count (:showCount="false" removes it too):

Folding only

With search and sort off, per-entry folding still works.

Second entry

A shorter toolbar, a quieter list.

Source:

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

@@@ Folding only

With search and sort off, per-entry folding still works.

@@@ Second entry

A shorter toolbar, a quieter list.

</ExpandableList>

Table columns and headers

The line after an @@@ line can carry one @@ meta line (single line, inline markdown), rendered as that row's meta cells — when migrating "table + expanding row" lists, it holds column info like duration or rating. Meta splits into columns on | (each column is still inline markdown; \| is an escaped pipe, and | inside inline code does not split); the :columns prop (JSON array) provides the header for each column: item 1 is the title column, the rest map to meta's | segments in order. When meta has fewer segments than headers, cells are padded empty; extra segments still render (headers padded empty). A lone - is the empty-cell placeholder (rendered dimmed), and date-like cells (a ~ range or a YYYY-MM prefix) stay on one line. The framework does no content sniffing — semantic styling like badge pills, score emphasis or row highlights comes from the typed fields of the data-driven table (XList) below. On narrow screens (<768px) the title column widens, and wide tables with ≥4 meta columns scroll horizontally inside the container:

Title Period Length Notes
Design of Design
2021-08-25 ~ 2021-09-026h12minpaperback
Refactoring
2021-08-25 ~ ?3h49minchapter 2

Source:

md
<ExpandableList :columns='["Title","Period","Length","Notes"]'>

@@@ Design of Design
@@ 2021-08-25 ~ 2021-09-02 | 6h12min | paperback

@@@ Refactoring
@@ 2021-08-25 ~ ? | 3h49min | chapter 2

</ExpandableList>

Old-style single-segment meta without | (@@ 23h · worth reading) still renders as one column — fully backward compatible.

Behavior details:

Giscus

The comment island is auto-mounted by the build layer at the end of article pages; no manual tag in markdown. Non-article pages like the home page and archives get none. Site config:

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

This site carries real giscus credentials, so every article (including this page) has a comment section at the bottom — scroll down to see it. The comment iframe follows the site's light/dark theme via postMessage; flipping the theme neither reloads the iframe nor loses comment state. When comments coexist with the related-article graph, the graph sits above the comment section. Setup steps and repo prerequisites are in Search and comments.

PasswordGate

The password-gate island is likewise auto-applied by the build layer to pages matching encrypt rules — nothing handwritten. The encryption demo page is a live example; mechanism and limits are in Encryption.

Site-defined island: Counter

Capabilities beyond the built-in list are added via the site config islands (this site's vite.config.ts registers Counter: 'docs/islands/Counter.tsx'), after which any markdown page can use it directly. This is a real working counter:

Inner markdown is pre-rendered at build time; the Counter component itself does not render childrenHtml and shows a button after activation.

Source:

md
<Counter :initial="5" label="Clicks">

**Inner markdown** is pre-rendered at build time; the Counter component itself does not render `childrenHtml` and shows a button after activation.

</Counter>

The component source is about twenty lines — the minimal reference for a custom island:

Key points: the module default-exports an IslandComponent (import type { IslandComponent } from 'absolute-press/client'); the tag name (Counter) matches the config key, PascalCase. IslandProps carries an optional childrenHtml (the pre-rendered HTML of the inner markdown) on top of its own props. When you need page metadata (locale, base, page title, etc.), read the page payload via pagePayload() from the same entry: every call reads live, and on client-side soft navigation the router syncs the script content to the current page.

Framework utilities available to site islands

absolute-press/client also exports the same set of shared utilities the built-in islands use; site islands import them directly instead of reinventing wheels or deep-importing internal paths:

Export Purpose
pagePayload() read the current page payload (locale, base, title, navbar/sidebar, etc.); follows soft navigation automatically
hydrateIslands(root?) activate [data-ap-island] placeholders under root; re-activates nested islands after an island rebuilds DOM
islandRegistry() the full current island registry (built-in + site)
mountComponent(comp, el, props) mount a Solid component onto an existing DOM node (direct mount under MPA, not full-page activation); event delegation works out of the box
cx(...cls) conditional className joining (static strings, scannable by UnoCSS)
flagOn(value, fallback?) parse a boolean prop: JSON booleans, "false"/"0", and bare attributes handled uniformly
pageMessages() UI message table by <html lang> (unknown languages fall back to Chinese)
formatMessage(tpl, params) fill {key} placeholders in message templates
Fold inline fold primitive: two-line clamp with a right fade, click or Enter/Space to expand; short content never reads as interactive

These are the same building blocks the built-in islands use — for example, ExpandableList's search toggle uses flagOn, its table class names use cx, and its toolbar strings use pageMessages() + formatMessage. A minimal combined example:

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;

Reusing the entry pipeline for site islands (entryList)

When a site island wants ExpandableList's "@@@ entries + static table skeleton" behavior, add the island name to the site config entryListIslands: ['MyList', …], and the build splits <MyList> children through the same pipeline: identical @@@/@@ syntax, fence protection, and static skeleton rendering (islands not in the list stay literal). The client component receives this static HTML via childrenHtml, splits it back into entries, and fills in its own data (the typical case is a list component where "a TS data module owns the meta columns and markdown keeps only the slot bodies"); nested islands inside entries are re-activated with hydrateIslands exported from absolute-press/client.

Implementation details of pipeline splitting and activation timing are in Design and implementation: the islands runtime.

Data-driven tables (XList)

ExpandableList reads its row data from @@ meta lines, rendering cells as authored markdown with no content sniffing; when a list grows large or the meta columns need precise control, the sturdier split is a TS data module as the single source with markdown keeping only the @@@ slot bodies. The XList component and its assembly helpers exported from absolute-press/client serve that path: a site writes a thin island wrapper that maps the data module onto typed items and hands them to XList, which renders header-click cyclic sorting (every column sortable by default: sortValue numeric columns sort desc → asc → default, the rest by natural cell-text order asc → desc → default; sorters overrides the comparator per column), multi-term search (hidden aliases included), toolbar exclusion filters, rich title cells (links/badges/folded text) and expandable rows.

These docs register such an island (DemoXList, see the islands + entryListIslands config in the root vite.config.ts). It is fully operational: 语言 sorts by natural text order, Stars numerically (descending first), the 状态 header stays triangle-free by the whitelist, the filter hides inactive projects when checked, and clicking a row expands its slot body:

ripgrep

Recursively search with regex; a fast grep alternative.

fd

A modern find with intuitive syntax.

bat

A cat clone with syntax highlighting.

fzf

The general-purpose fuzzy finder.

jq

The command-line JSON processor.

eza

A modern ls, successor of exa.

xsv

A CSV toolkit, succeeded by qsv.

A minimal wrapper (the data module games.ts exports an array):

Registration matches any other site island on the entry pipeline: list it under config islands as GameList: '…/GameList.tsx' and add the name to entryListIslands (previous section) so the @@@ slot bodies run through the build-time pipeline; the markdown keeps only the tag and the slots:

md
<GameList>

@@@ CLANNAD

The entry body is plain markdown.

</GameList>

Behavior notes:

Reading site-wide data (site-data)

Islands that need the site-wide page inventory (archive indexes, stat cards) need not scan content themselves: the site config's onScan hook receives the page inventory after each scan and returns arbitrary JSON data, which is injected into the virtual:absolute-press/site-data virtual module. An island imports that module directly; its default export is the onScan return value. In dev, content edits trigger a fresh scan and the data refreshes automatically. The hook signature and the SiteScanPage fields are in the configuration reference:

ts
// vite.config.ts (excerpt)
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 ?? [],
    })),
  }),
});

Reading it on the island side:

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;

The module ships no built-in types; declare it in the site's own ambient declaration file (e.g. src/env.d.ts), typing the default export as the shape of the onScan return value — the framework does the same for its own islands virtual module (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;
}

Register the component through the site config islands as usual and use it in markdown. Importing the module without onScan configured fails the build with an error pointing at the onScan config; the data is injected JSON-serialized, so non-JSON values in the return value (like Date) do not reach the island as-is.

Constraints and notes