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
7 changes: 7 additions & 0 deletions .changeset/huge-candies-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'myst-common': minor
'myst-roles': minor
'myst-cli': minor
---

Add new role: embed
73 changes: 73 additions & 0 deletions docs/embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,79 @@ It can be used like so:

![](#myLabel)

(embed-role)=

### The {myst:role}`embed` role

The {myst:role}`embed` role allows you to embed **content from labeled blocks** inline within your text, with control over whether to preserve markdown formatting or extract plain text only.

By default, the role preserves all markdown formatting including **bold**, *italic*, `` `inline code` ``, [links](url), inline math, and MyST roles like {sub}`subscript` and {sup}`superscript`.

#### Basic usage with markdown formatting (default)

For example, if you have a labeled paragraph with formatting:

```markdown
(my-text)=
This is **bold** and *italic* with `code` and [a link](https://example.com).
```

You can embed it with formatting preserved:

```markdown
The document states: {embed}`my-text`
```

Which renders as: "The document states: This is **bold** and *italic* with `code` and [a link](https://example.com)."

#### Plain text mode

If you need just the plain text without formatting, use the `format=text` option:

```markdown
{embed format=text}`my-text`
```

Which renders as: "This is bold and italic with code and a link."

#### Explicit markdown format

You can also explicitly specify markdown format (though this is the default):

```markdown
{embed format=markdown}`my-text`
```

#### Cross-file embedding

Like the {myst:directive}`embed` directive, {myst:role}`embed` supports cross-file references:

```markdown
{embed}`other-file.md#my-label`
{embed format=text}`other-file.md#my-label`
```

#### External MyST project embedding

You can also embed content from external MyST projects using the `xref:` or `myst:` prefixes (requires the project to be listed in your `references` configuration):

```markdown
{embed}`xref:project#label`
{embed format=text}`xref:project#label`
```

::::{seealso} Comparison with `{embed}` directive
The {myst:directive}`embed` directive embeds the full content as a block with all its styling (figures, admonitions, code blocks, etc.), while {myst:role}`embed` role extracts content for inline use.

**Use {myst:directive}`embed`** when you want to reuse a complete block of content with its styling.

**Use {myst:role}`embed`** when you need to reference content inline, with or without markdown formatting.

**Format options:**
- `format=markdown` (default): Preserves **bold**, *italic*, `code`, [links](url), math, and MyST roles
- `format=text`: Extracts plain text only
::::

### Embed images into figures

If you have a labeled image in your documentation, you can embed it as a Figure so that it contains figure metadata (like a caption, or adding alt-text).
Expand Down
3 changes: 3 additions & 0 deletions docs/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ description: A full list of the roles included in MyST Markdown by default.
:::{myst:role} download
:::

:::{myst:role} embed
:::

:::{myst:role} term
:::

Expand Down
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/myst-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@
"jtex": "^1.0.20",
"latest-version": "^7.0.0",
"mdast": "^3.0.0",
"mdast-util-gfm-footnote": "^1.0.2",
"mdast-util-gfm-table": "^1.0.7",
"mdast-util-to-markdown": "^1.5.0",
"meca": "^1.0.8",
"mime-types": "^2.1.35",
"myst-cli-utils": "^2.0.13",
"myst-common": "^1.9.3",
"myst-config": "^1.9.3",
"myst-migrate": "^1.7.1",
"myst-execute": "^0.3.3",
"myst-ext-button": "^0.0.1",
"myst-ext-card": "^1.0.9",
Expand All @@ -82,6 +84,7 @@
"myst-ext-reactive": "^1.0.9",
"myst-ext-tabs": "^1.0.9",
"myst-frontmatter": "^1.9.3",
"myst-migrate": "^1.7.1",
"myst-parser": "^1.6.3",
"myst-spec": "^0.0.5",
"myst-spec-ext": "^1.9.3",
Expand Down
6 changes: 4 additions & 2 deletions packages/myst-cli/src/process/mdast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import type { RendererData } from '../transforms/types.js';

import {
checkLinksTransform,
embedTransform,
embedTransform as embedDirectiveTransform,
embedRoleTransform,
importMdastFromJson,
includeFilesTransform,
liftCodeMetadataToBlock,
Expand Down Expand Up @@ -357,7 +358,8 @@ export async function postProcessMdast(
await transformLinkedRORs(session, vfile, mdast, file);
resolveReferencesTransform(mdast, vfile, { state, transformers });
await transformMystXRefs(session, vfile, mdast, frontmatter);
await embedTransform(session, mdast, file, dependencies, state);
await embedDirectiveTransform(session, mdast, file, dependencies, state);
await embedRoleTransform(session, mdast, file, state);
const pipe = unified();
session.plugins?.transforms.forEach((t) => {
if (t.stage !== 'project') return;
Expand Down
140 changes: 140 additions & 0 deletions packages/myst-cli/src/transforms/embed-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { MystTransformer, SphinxTransformer, type IReferenceStateResolver } from 'myst-transforms';
import type { GenericNode } from 'myst-common';
import { normalizeLabel, fileError, selectMdastNodes } from 'myst-common';
import type { CrossReference, Link } from 'myst-spec-ext';
import type { VFile } from 'vfile';
import type { ISession } from '../session/types.js';
import { castSession } from '../session/cache.js';
import type { MystData } from './crossReferences.js';
import { fetchMystLinkData, fetchMystXRefData, nodesFromMystXRefData } from './crossReferences.js';
import { fileFromSourceFolder, getSourceFolder } from './links.js';

/**
* Initialize references and transformers for embed operations
*/
export function initializeEmbedReferences(session: ISession) {
const references = Object.values(castSession(session).$externalReferences);
const mystTransformer = new MystTransformer(references);
const sphinxTransformer = new SphinxTransformer(references);
return { references, mystTransformer, sphinxTransformer };
}

/**
* Resolve remote MyST reference (xref: or myst: prefix)
* Returns target nodes or null on error
*/
export async function resolveRemoteMystReference({
session,
label,
mystTransformer,
sphinxTransformer,
vfile,
node,
}: {
session: ISession;
label: string;
mystTransformer: MystTransformer;
sphinxTransformer: SphinxTransformer;
vfile: VFile;
node: GenericNode;
}): Promise<GenericNode[] | null> {
if (!mystTransformer.test(label)) {
let note: string;
if (sphinxTransformer.test(label)) {
note = 'Embed target must be a MyST project, not intersphinx.';
} else {
note = 'Embed target must be a MyST project and included in your project references.';
}
fileError(vfile, `Cannot embed "${label}"`, { node, note });
return null;
}

const referenceLink: Link = {
type: 'link',
url: label,
urlSource: label,
children: [],
};

const transformed = mystTransformer.transform(referenceLink, vfile);
const referenceXRef = referenceLink as any as CrossReference;

if (!transformed) return null;

let data: MystData | undefined;
let targetNodes: GenericNode[] | undefined;

if (referenceXRef.identifier) {
data = await fetchMystXRefData(session, referenceXRef, vfile);
if (!data) return null;
targetNodes = nodesFromMystXRefData(data, referenceXRef.identifier, vfile, {
urlSource: label,
});
} else {
data = await fetchMystLinkData(session, referenceLink, vfile);
if (!data?.mdast) return null;
targetNodes = data.mdast.children;
}

if (!targetNodes?.length) return null;

return targetNodes;
}

/**
* Resolve local file reference (with optional #anchor)
* Returns target nodes or null on error
*/
export async function resolveLocalReference({
session,
label,
file,
state,
vfile,
node,
}: {
session: ISession;
label: string;
file: string;
state: IReferenceStateResolver;
vfile: VFile;
node: GenericNode;
}): Promise<GenericNode[] | null> {
let hash = label;
let linkFile: string | undefined;

if (label.includes('#')) {
const sourceFileFolder = getSourceFolder(label, file, session.sourcePath());
const linkFileWithTarget = fileFromSourceFolder(label, sourceFileFolder);
if (!linkFileWithTarget) return null;
linkFile = linkFileWithTarget.split('#')[0];
hash = linkFileWithTarget.slice(linkFile.length + 1);
}

const { identifier } = normalizeLabel(hash) ?? {};
if (!identifier) {
fileError(vfile, 'Embed node does not have label', { node });
return null;
}

const stateProvider = state.resolveStateProvider(identifier, linkFile);
if (!stateProvider) return null;

const cache = castSession(session);
const pageMdast = cache.$getMdast(stateProvider.filePath)?.post?.mdast;
if (!pageMdast) return null;

let targetNodes: GenericNode[];
if (stateProvider.getFileTarget(identifier)) {
targetNodes = pageMdast.children;
} else {
targetNodes = selectMdastNodes(pageMdast, identifier).nodes;
}

if (!targetNodes?.length) {
fileError(vfile, `Embed target for "${label}" not found`, { node });
return null;
}

return targetNodes;
}
Loading
Loading