UNPKG

myst-cli

Version:
270 lines (269 loc) 12 kB
import fs from 'node:fs'; import path from 'node:path'; import { filter } from 'unist-util-filter'; import { remove } from 'unist-util-remove'; import { selectAll } from 'unist-util-select'; import { MystTransformer, SphinxTransformer, } from 'myst-transforms'; import { copyNode, liftChildren, normalizeLabel, fileError, isTargetIdentifierNode, selectMdastNodes, NotebookCell, } from 'myst-common'; import { selectFile } from '../process/file.js'; import { watch } from '../store/reducers.js'; import { castSession } from '../session/cache.js'; import { fetchMystLinkData, fetchMystXRefData, nodesFromMystXRefData } from './crossReferences.js'; import { fileFromSourceFolder, getSourceFolder } from './links.js'; function mutateEmbedNode(node, targetNode, opts) { const { url, dataUrl, targetFile, sourceFile } = opts ?? {}; if (targetNode && node['remove-output']) { targetNode = filter(targetNode, (n) => { // This supports nodes of type 'output' or any other node with metadata suggesting it is type output return n.type !== 'output' && n.data?.type !== 'output'; }); } if (targetNode && node['remove-input']) { targetNode = filter(targetNode, (n, index, parent) => { // Remove code nodes that are children of notebook cells and not tagged as outputs return !(n.type === 'code' && n.data?.type !== 'output' && parent?.type === 'block' && parent?.kind === NotebookCell.code); }); } selectAll('[identifier],[label],[html_id]', targetNode).forEach((idNode) => { // Non-target nodes may keep these properties if (!isTargetIdentifierNode(idNode)) return; delete idNode.identifier; delete idNode.label; delete idNode.html_id; }); selectAll('crossReference', targetNode).forEach((targetXRef) => { if (!targetXRef.remote) { targetXRef.url = url; targetXRef.dataUrl = dataUrl; targetXRef.remote = true; } }); if (targetFile && sourceFile) { // TODO: Other node types that point to a file? selectAll('image', targetNode).forEach((imageNode) => { if (imageNode.url) { const absoluteUrl = path.resolve(path.dirname(sourceFile), imageNode.url); if (fs.existsSync(absoluteUrl)) { imageNode.url = path.relative(path.dirname(targetFile), absoluteUrl); } } if (imageNode.urlSource) { const absoluteUrlSource = path.resolve(path.dirname(sourceFile), imageNode.urlSource); if (fs.existsSync(absoluteUrlSource)) { imageNode.urlSource = path.relative(path.dirname(targetFile), absoluteUrlSource); } } }); } if (!targetNode) { node.children = []; } else if (targetNode.type === 'block') { // Do not nest a single block inside an embed node.children = targetNode.children; } else { node.children = [targetNode]; } } function targetsToTarget(targets) { if (targets.length === 1) return copyNode(targets[0]); return { type: 'block', children: copyNode(targets) }; } /** * This is the {embed} directive, that embeds nodes from elsewhere in a page. */ export async function embedTransform(session, mdast, file, dependencies, state) { const references = Object.values(castSession(session).$externalReferences); const mystTransformer = new MystTransformer(references); const embedNodes = selectAll('embed', mdast); await Promise.all(embedNodes.map(async (node) => { const vfile = state.vfile; const label = node.source?.label; if (!label) { fileError(vfile, 'Embed node does not have a label', { node }); return; } if (label.startsWith('xref:') || label.startsWith('myst:')) { if (!mystTransformer.test(label)) { let note; const sphinxTransformer = new SphinxTransformer(references); 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; } const referenceLink = { type: 'link', url: label, urlSource: label, children: [], }; const transformed = mystTransformer.transform(referenceLink, vfile); const referenceXRef = referenceLink; if (transformed) { let data; let targetNodes; if (referenceXRef.identifier) { data = await fetchMystXRefData(session, referenceXRef, vfile); if (!data) return; targetNodes = nodesFromMystXRefData(data, referenceXRef.identifier, vfile, { urlSource: label, // TODO: maxNodes - settable via embed directive }); } else { data = await fetchMystLinkData(session, referenceLink, vfile); if (!data?.mdast) return; targetNodes = data.mdast.children; } if (!targetNodes?.length) return; const targetNode = targetsToTarget(targetNodes); selectAll('crossReference', targetNode).forEach((targetXRef) => { // If target xref already has remoteBaseUrl, it can be unchanged in the embedded context if (targetXRef.remoteBaseUrl) return; // Otherwise, the remoteBaseUrl is added from the embed context targetXRef.remoteBaseUrl = referenceXRef.remoteBaseUrl; }); selectAll('link', targetNode).forEach((targetLink) => { if (!targetLink.internal) return; targetLink.internal = false; targetLink.url = `${referenceXRef.remoteBaseUrl}${targetLink.url}`; if (targetLink.dataUrl) { targetLink.dataUrl = `${referenceXRef.remoteBaseUrl}${targetLink.dataUrl}`; } }); selectAll('[source]', targetNode).forEach((target) => { if (!target.source) return; target.source.remoteBaseUrl = referenceXRef.remoteBaseUrl; }); mutateEmbedNode(node, targetNode, referenceXRef); // Remote dependency, not added as local dependency const source = { url: referenceXRef.url, remoteBaseUrl: referenceXRef.remoteBaseUrl, label, }; if (data.kind) source.kind = data.kind; if (data.slug) source.slug = data.slug; if (data.location) source.location = data.location; if (data.frontmatter?.title) source.title = data.frontmatter.title; if (data.frontmatter?.short_title) source.short_title = data.frontmatter.short_title; node.source = source; } return; } let hash = label; let linkFile; if (label.includes('#')) { const sourceFileFolder = getSourceFolder(label, file, session.sourcePath()); const linkFileWithTarget = fileFromSourceFolder(label, sourceFileFolder); if (!linkFileWithTarget) return; 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; } const stateProvider = state.resolveStateProvider(identifier, linkFile); if (!stateProvider) return; const cache = castSession(session); const pageMdast = cache.$getMdast(stateProvider.filePath)?.post?.mdast; if (!pageMdast) return; let targetNodes; 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; } const target = targetsToTarget(targetNodes); if (!state.states) { // Single page case - we do not need to update urls/dependencies mutateEmbedNode(node, target); return; } const { url, dataUrl, filePath } = stateProvider; mutateEmbedNode(node, target, { url, dataUrl, targetFile: file, sourceFile: filePath }); if (!url) return; const source = { url, label }; if (filePath) { session.store.dispatch(watch.actions.addLocalDependency({ path: file, dependency: filePath, })); const { kind, slug, frontmatter, location } = selectFile(session, filePath) ?? {}; if (kind) source.kind = kind; if (slug) source.slug = slug; if (location) source.location = location; if (frontmatter?.title) source.title = frontmatter.title; if (frontmatter?.short_title) source.short_title = frontmatter.short_title; } node.source = source; if (!dependencies.map((dep) => dep.url).includes(url)) dependencies.push(source); })); // If a figure contains a single embed node, move the source info to the figure and lift // the embed children, eliminating the embed node. const containerNodes = selectAll('container', mdast); containerNodes.forEach((node) => { const containerEmbeds = node.children?.filter((child) => child.type === 'embed'); if (containerEmbeds?.length === 1) { node.source = { ...containerEmbeds[0].source }; containerEmbeds[0].type = '_lift'; // If the figure's embedded content is _another_ figure, just lift out the children except caption/legend if (containerEmbeds[0].children?.length === 1 && containerEmbeds[0].children[0].type === 'container') { containerEmbeds[0].children[0].type = '_lift'; // It would be nice to keep these if there is not another caption defined on 'node' but that leads to // issues with the current figure enumeration resolution, since embedding happens after referencing... remove(containerEmbeds[0].children[0], 'caption'); remove(containerEmbeds[0].children[0], 'legend'); } } }); // If embed node contains a single figure, copy the source info to the figure const remainingEmbedNodes = selectAll('embed', mdast); remainingEmbedNodes.forEach((node) => { if (node.children?.length === 1 && node.children[0].type === 'container') { node.children[0].source = { ...node.source }; node.type = '_lift'; } }); liftChildren(mdast, '_lift'); }