import { RenderResultPluginAsset } from '@joplin/renderer/types'; import { join, dirname } from 'path'; type PluginAssetRecord = { element: HTMLElement; }; const pluginAssetsAdded_: Record = {}; const assetUrlMap_: Map Promise> = new Map(); // Some resources (e.g. CSS) reference other resources with relative paths. On web, due to sandboxing // and how plugin assets are stored, these links need to be rewritten. const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content: string) => { if (asset.mime === 'text/css') { const urlRegex = /(url\()([^)]+)(\))/g; // Converting resource paths to URLs is async. To handle this, we do two passes. // In the first, the original URLs are collected. In the second, the URLs are replaced. const replacements: [string, string][] = []; let replacementIndex = 0; content = content.replace(urlRegex, (match, _group1, url, _group3) => { const target = join(dirname(asset.path), url); if (!assetUrlMap_.has(target)) return match; const replaceString = `<>`; replacements.push([replaceString, target]); return `url(${replaceString})`; }); for (const [replacement, path] of replacements) { const url = await assetUrlMap_.get(path)(); content = content.replace(replacement, url); } return content; } else { return content; } }; interface Options { inlineAssets: boolean; readAssetBlob?(path: string): Promise; } // Note that this function keeps track of what's been added so as not to // add the same CSS files multiple times. const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => { if (!assets) return; const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer'); const prepareAssetBlobUrls = () => { for (const asset of assets) { const path = asset.path; if (!assetUrlMap_.has(path)) { // Fetching assets can be expensive -- avoid refetching assets where possible. let url: string|null = null; assetUrlMap_.set(path, async () => { if (url !== null) return url; const blob = await options.readAssetBlob(path); if (!blob) { url = ''; } else { url = URL.createObjectURL(blob); } return url; }); } } }; if (options.inlineAssets) { prepareAssetBlobUrls(); } const processedAssetIds = []; for (let i = 0; i < assets.length; i++) { const asset = assets[i]; // # and ? can be used in valid paths and shouldn't be treated as the start of a query or fragment const encodedPath = asset.path .replace(/#/g, '%23') .replace(/\?/g, '%3F'); const assetId = asset.name ? asset.name : encodedPath; processedAssetIds.push(assetId); if (pluginAssetsAdded_[assetId]) continue; let element = null; if (options.inlineAssets) { if (asset.mime === 'application/javascript') { element = document.createElement('script'); } else if (asset.mime === 'text/css') { element = document.createElement('style'); } if (element) { const blob = await options.readAssetBlob(asset.path); if (blob) { const assetContent = await blob.text(); element.appendChild( document.createTextNode(await rewriteInternalAssetLinks(asset, assetContent)), ); } } } else { if (asset.mime === 'application/javascript') { element = document.createElement('script'); element.src = encodedPath; } else if (asset.mime === 'text/css') { element = document.createElement('link'); element.rel = 'stylesheet'; element.href = encodedPath; } } if (element) { pluginAssetsContainer.appendChild(element); } pluginAssetsAdded_[assetId] = { element, }; } // Once we have added the relevant assets, we also remove those that // are no longer needed. It's necessary in particular for the CSS // generated by noteStyle - if we don't remove it, we might end up // with two or more stylesheet and that will create conflicts. // // It was happening for example when automatically switching from // light to dark theme, and then back to light theme - in that case // the viewer would remain dark because it would use the dark // stylesheet that would still be in the DOM. for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) { if (!processedAssetIds.includes(assetId)) { try { asset.element.remove(); } catch (error) { // We don't throw an exception but we log it since // it shouldn't happen console.warn('Tried to remove an asset but got an error', error); } pluginAssetsAdded_[assetId] = null; } } }; export default addPluginAssets;