Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ issue](https://github.com/jannis-baum/vivify/issues/new/choose) or
- links to other files: [relative links like in
GitHub](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#relative-links)
as well as absolute file links
- [Obsidian-style embeds](docs/embeds.md) (`![[file]]`) and wiki-links
(`[[file]]`)
- [add styles, classes, ids or other attributes directly from
Markdown](https://github.com/arve0/markdown-it-attrs?tab=readme-ov-file#examples)
- table of contents with `[[toc]]`
Expand Down
86 changes: 86 additions & 0 deletions docs/embeds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Embeds and wiki-links

Vivify supports [Obsidian-style](https://help.obsidian.md/) wiki-links and
embeds in addition to standard Markdown links.

## Wiki-links

With the syntax `[[page]]` you link to another file. If the link target has no
file extension, a `.md` extension is assumed, matching Obsidian's behaviour:

```md
See [[other-note]], or [[other-note.md]] if you want an explicit extension.
```

To use custom link text, append a pipe and the desired text:

```md
[[other-note.md|My Other Note]]
[[other-note|My Other Note]]
```

The left side is used for the link `href` (with `.md` appended when no
extension is present), and the right side is shown as the link's text.

## Embeds

Prefixing a wiki-link with `!` turns it into an inline **embed** instead of a
link:

```md
![[image.jpg]]
```

The path is resolved relative to the document you are viewing, just like regular
relative links and images. Which HTML element is generated depends on the file
type, detected from the file extension:

- **Images** (`jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `bmp`, `ico`, `avif`)
are embedded with an `<img>` element, just like a standard
`![](image.jpg)` reference.
- **PDFs** (`pdf`) are embedded in an `<iframe>` rendered by the browser's
built-in PDF viewer.
- **Videos** (`mp4`, `webm`, `ogv`, `mkv`, `mov`) are embedded with a
`<video controls>` player.
- **Audio** (`mp3`, `wav`, `ogg`, `m4a`, `aac`, `flac`) is embedded with an
`<audio controls>` player.
- **Any other file type** is also embedded in an `<iframe>`, so `![[note.md]]`
displays the rendered note inline.

If the embed target has no file extension, a `.md` extension is appended
before looking up the file, so `![[note]]` is equivalent to
`![[note.md]]`.

## Resizing embeds

Append a pipe and a size after the path to control the dimensions of an embed.
A width in pixels is supported, optionally combined with a height
(`width x height`):

```md
![[image.jpg|300]] <!-- 300 pixels wide -->
![[image.jpg|300x200]] <!-- 300 x 200 pixels -->
```

The same `|size` syntax works for videos, audio and iframes. An unparseable
value (for example `![[image.jpg|large]]`) is ignored and the embed uses its
default size.

## Styling embeds

Embeds use the following CSS classes, which you can override in your [custom
styles](customization.md):

- `.wiki-embed` — base class for all non-image embeds
- `.wiki-embed-pdf` — PDFs
- `.wiki-embed-iframe` — fallback iframes for other file types
- `.wiki-embed-video` — `<video>` players
- `.wiki-embed-audio` — `<audio>` players

For example, to render PDFs at a different default height:

```css
.wiki-embed-pdf {
height: 800px;
}
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"viv": "VIV_PORT=3000 node --import ./loader.mjs src/app.ts",
"lint": "eslint src static",
"lint-markdown": "markdownlint-cli2 --config .github/.markdownlint-cli2.yaml",
"test": "node --import ./loader.mjs --test tests/unit/cli.ts tests/unit/alerts.ts",
"test": "node --import ./loader.mjs --test tests/unit/cli.ts tests/unit/alerts.ts tests/unit/embeds.ts tests/unit/wiki-links.ts",
"deduplicate": "yarn-deduplicate"
},
"type": "module",
Expand Down
135 changes: 135 additions & 0 deletions src/parser/embeds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import MarkdownIt from 'markdown-it';
import { basename as pbasename, extname as pextname } from 'path';

// Obsidian-style embeds use the `![[...]]` syntax to inline media files.
// The path is treated as relative to the document being viewed, exactly like
// regular wiki-links (`[[...]]`) and standard Markdown relative links, so the
// browser resolves it through Vivify's `/viewer/<...>` route.

const IMAGE_EXTS = new Set(['avif', 'bmp', 'gif', 'ico', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
const VIDEO_EXTS = new Set(['mkv', 'mov', 'mp4', 'ogv', 'webm']);
const AUDIO_EXTS = new Set(['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav']);

// Explicit MIME types for <source> fallbacks; omitted when unknown so the
// browser infers from the file extension itself.
const VIDEO_MIME: Record<string, string> = {
mkv: 'video/x-matroska',
mov: 'video/quicktime',
mp4: 'video/mp4',
ogv: 'video/ogg',
webm: 'video/webm',
};
const AUDIO_MIME: Record<string, string> = {
aac: 'audio/aac',
flac: 'audio/flac',
m4a: 'audio/mp4',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
};

// Escape a value for use inside a double-quoted HTML attribute.
function escAttr(value: string): string {
return value.replace(/["&]/g, (char) => (char === '"' ? '&quot;' : '&amp;'));
}

function fileExtension(filePath: string): string {
return pextname(filePath).slice(1).toLowerCase();
}

// Split `![[content]]` inner content into `filePath | options`.
function parseContent(content: string): { filePath: string; options: string } {
const pipe = content.indexOf('|');
if (pipe === -1) {
return { filePath: content.trim(), options: '' };
}
return {
filePath: content.slice(0, pipe).trim(),
options: content.slice(pipe + 1).trim(),
};
}

// Parse a size spec such as "300" (width) or "300x200" (width x height).
function parseSize(options: string): { width?: string; height?: string } {
if (!options) return {};
const match = /^(\d+)(?:x(\d+))?$/.exec(options);
if (!match) return {};
return { width: match[1], height: match[2] ?? undefined };
}

function sizeAttrs(size: { width?: string; height?: string }): string {
let attrs = '';
if (size.width) attrs += ` width="${escAttr(size.width)}"`;
if (size.height) attrs += ` height="${escAttr(size.height)}"`;
return attrs;
}

function renderEmbedHtml(content: string): string {
const { filePath, options } = parseContent(content);
const src = escAttr(filePath);
const extn = fileExtension(filePath);
const sizing = sizeAttrs(parseSize(options));

// Image: inline like a regular Markdown image, honouring explicit sizing.
if (IMAGE_EXTS.has(extn)) {
const alt = escAttr(pbasename(filePath));
return `<img src="${src}" alt="${alt}"${sizing}>`;
}

// PDF: render inside the browser's native PDF viewer via an iframe.
if (extn === 'pdf') {
return `<iframe src="${src}" class="wiki-embed wiki-embed-pdf"${sizing} loading="lazy"></iframe>`;
}

// Video: <video> player with a <source> carrying the best known MIME type.
if (VIDEO_EXTS.has(extn)) {
const type = VIDEO_MIME[extn];
const typeAttr = type ? ` type="${type}"` : '';
return `<video controls class="wiki-embed wiki-embed-video"${sizing}><source src="${src}"${typeAttr}></video>`;
}

// Audio: <audio> player.
if (AUDIO_EXTS.has(extn)) {
const type = AUDIO_MIME[extn];
const typeAttr = type ? ` type="${type}"` : '';
return `<audio controls class="wiki-embed wiki-embed-audio"${sizing} src="${src}"${typeAttr}></audio>`;
}

// Fallback: any other file type is embedded in an iframe, so e.g. other
// Markdown notes get rendered through Vivify and displayed inline. When
// the path has no extension, assume it is a Markdown note and append `.md`
// so the viewer route serves it as rendered Markdown rather than raw.
if (!extn) {
return `<iframe src="${src}.md" class="wiki-embed wiki-embed-iframe"${sizing} loading="lazy"></iframe>`;
}
return `<iframe src="${src}" class="wiki-embed wiki-embed-iframe"${sizing} loading="lazy"></iframe>`;
}

export default function embeds(md: MarkdownIt): void {
md.inline.ruler.before('link', 'wiki_embed', (state, silent) => {
const max = state.posMax;
const start = state.pos;

// Look for opening `![[`
if (state.src.charCodeAt(start) !== 0x21 /* ! */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5b /* [ */) return false;
if (state.src.charCodeAt(start + 2) !== 0x5b /* [ */) return false;

// Find closing `]]` (content may not contain a `]`, mirroring wiki-links)
let end = start + 3;
while (end < max && state.src.charCodeAt(end) !== 0x5d /* ] */) end++;
if (end + 1 >= max || state.src.charCodeAt(end + 1) !== 0x5d /* ] */) return false;
end += 2;

if (!silent) {
const content = state.src.slice(start + 3, end - 2);
// Emit the generated HTML as raw inline markup, the same technique
// `front-matter.ts` uses with `html_block` tokens.
const token = state.push('html_inline', '', 0);
token.content = renderEmbedHtml(content);
}

state.pos = end;
return true;
});
}
2 changes: 2 additions & 0 deletions src/parser/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import highlight from './highlight.js';
import graphviz from './dot.js';
import mermaid from './mermaid.js';
import wikiLinks from './wiki-links.js';
import embeds from './embeds.js';
import alerts from './alerts.js';
import config from '../config.js';
import { Renderer } from './parser.js';
Expand Down Expand Up @@ -77,6 +78,7 @@ mdit.use(graphviz);
mdit.use(alerts);
mdit.use(mermaid);
mdit.use(wikiLinks);
mdit.use(embeds);

const renderMarkdown: Renderer = (content: string) => {
return mdit.render(content);
Expand Down
9 changes: 6 additions & 3 deletions src/parser/wiki-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ export default function wikiLinkPlugin(md: MarkdownIt): void {

if (!silent) {
const content = state.src.slice(start + 2, end - 2);
const hasExtension = pbasename(content).indexOf('.') > -1;
const href = hasExtension ? content : content + '.md';
const pipe = content.indexOf('|');
const filePath = pipe === -1 ? content : content.slice(0, pipe).trim();
const displayText = pipe === -1 ? filePath : content.slice(pipe + 1).trim();
const hasExtension = pbasename(filePath).indexOf('.') > -1;
const href = hasExtension ? filePath : filePath + '.md';

// Create link tokens
const token = state.push('link_open', 'a', 1);
token.attrSet('href', href);

state.push('text', '', 0).content = content;
state.push('text', '', 0).content = displayText;
state.push('link_close', 'a', -1);
}

Expand Down
27 changes: 27 additions & 0 deletions static/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ h1, h2 {
/* images */
img, svg { max-width: 100%; }

/* --------------------------------------------------------------------------
* WIKI-STYLE EMBEDS --------------------------------------------------------- */
/* Obsidian-style embeds using the `![[...]]` syntax */
.wiki-embed {
display: block;
margin: 0.5rem 0;
}
.wiki-embed.wiki-embed-pdf,
.wiki-embed.wiki-embed-iframe {
width: 100%;
height: 600px;
border: 1px solid var(--border-regular);
border-radius: 6px;
overflow: hidden;
}
.wiki-embed.wiki-embed-iframe {
height: 500px;
}
.wiki-embed.wiki-embed-video {
width: 100%;
height: auto;
max-width: 100%;
}
.wiki-embed.wiki-embed-audio {
width: 100%;
}

/* keyboard */
kbd {
background-color: var(--bg-secondary);
Expand Down
38 changes: 37 additions & 1 deletion tests/rendering/markdown-additional.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,43 @@ See basic syntax [here](markdown-basic.md), and extended syntax [here](markdown-

With the syntax `[[relative-file]]` we can also link to other files with an
implicit `.md`, e.g. to [[markdown-basic]], or also to any file with explicit
extensions like [[markdown-basic.md]]
extensions like [[markdown-basic.md]]. Custom display text can be specified
after a pipe: [[markdown-basic|Basic Syntax]].

### Embeds

With a leading `!` the wiki-link syntax `![[...]]` turns a link into an inline
embed instead, similar to [Obsidian](https://help.obsidian.md/). The path is
relative to the current document, just like regular relative links.

Images are embedded inline and can be resized by specifying a width (or
`width x height`) after a pipe:

![[photo.jpg]]

![[photo.png|300]]

![[photo.gif|300x200]]

PDFs are embedded in an `iframe` rendered by the browser's built-in PDF viewer:

![[document.pdf]]

Videos and audio use the respective HTML `<video>` / `<audio>` players:

![[clip.mp4]]

![[clip.webm|640]]

![[podcast.mp3]]

An embed without a file extension resolves to Markdown:

![[notes]]

Any other file type is also embedded in an `iframe`:

![[notes.md]]

## Math

Expand Down
Loading
Loading