diff options
Diffstat (limited to 'src/content/dependencies/transformContent.js')
-rw-r--r-- | src/content/dependencies/transformContent.js | 246 |
1 files changed, 200 insertions, 46 deletions
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index 84fbe361..1bbd45e2 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -2,10 +2,26 @@ import {bindFind} from '#find'; import {replacerSpec, parseInput} from '#replacer'; import {Marked} from 'marked'; +import striptags from 'striptags'; const commonMarkedOptions = { headerIds: false, mangle: false, + + tokenizer: { + url(src) { + // Don't link emails + const cap = this.rules.inline.url.exec(src); + if (cap?.[2] === '@') return; + + // Use normal tokenizer url behavior otherwise + // Note that super.url doesn't work here because marked is binding or + // applying this function on the tokenizer instance - super.prop would + // just read the prototype of the containing object literal, not the + // rebound tokenizer. (Thanks MDN.) + return Object.getPrototypeOf(this).url.call(this, src); + }, + }, }; const multilineMarked = new Marked({ @@ -45,7 +61,7 @@ export default { sprawl(wikiData, content) { const find = bindFind(wikiData); - const parsedNodes = parseInput(content); + const parsedNodes = parseInput(content ?? ''); return { nodes: parsedNodes @@ -169,6 +185,8 @@ export default { link: relation(name, arg), label: node.data.label, hash: node.data.hash, + name: arg?.name, + shortName: arg?.shortName ?? arg?.nameShort, } : getPlaceholder(node, content)); @@ -221,6 +239,16 @@ export default { default: true, }, + absorbPunctuationFollowingExternalLinks: { + type: 'boolean', + default: true, + }, + + textOnly: { + type: 'boolean', + default: false, + }, + thumb: { validate: v => v.is('small', 'medium', 'large'), default: 'large', @@ -232,11 +260,34 @@ export default { let internalLinkIndex = 0; let externalLinkIndex = 0; + let offsetTextNode = 0; + const contentFromNodes = - data.nodes.map(node => { + data.nodes.map((node, index) => { + const nextNode = data.nodes[index + 1]; + + const absorbFollowingPunctuation = template => { + if (nextNode?.type !== 'text') { + return; + } + + const text = nextNode.data; + const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i); + const suffix = match?.[0]; + if (suffix) { + template.setSlot('suffixNormalContent', suffix); + offsetTextNode = suffix.length; + } + }; + switch (node.type) { - case 'text': - return {type: 'text', data: node.data}; + case 'text': { + const text = node.data.slice(offsetTextNode); + + offsetTextNode = 0; + + return {type: 'text', data: text}; + } case 'image': { const src = @@ -279,15 +330,16 @@ export default { {class: 'align-center'}, {title: - language.$('misc.external.opensInNewTab', { - link: - language.formatExternalLink(link, { - style: 'platform', - }), + language.encapsulate('misc.external.opensInNewTab', capsule => + language.$(capsule, { + link: + language.formatExternalLink(link, { + style: 'platform', + }), - annotation: - language.$('misc.external.opensInNewTab.annotation'), - }).toString()}, + annotation: + language.$(capsule, 'annotation'), + }).toString())}, content); } @@ -332,13 +384,93 @@ export default { }; } + case 'video': { + const src = + (node.src.startsWith('media/') + ? to('media.path', node.src.slice('media/'.length)) + : node.src); + + const {width, height, align, pixelate} = node; + + const content = + html.tag('div', {class: 'content-video-container'}, + align === 'center' && + {class: 'align-center'}, + + html.tag('video', + src && {src}, + width && {width}, + height && {height}, + + {controls: true}, + + pixelate && + {class: 'pixelate'})); + + return { + type: 'processed-video', + data: content, + }; + } + + case 'audio': { + const src = + (node.src.startsWith('media/') + ? to('media.path', node.src.slice('media/'.length)) + : node.src); + + const {align, inline} = node; + + const audio = + html.tag('audio', + src && {src}, + + align === 'center' && + inline && + {class: 'align-center'}, + + {controls: true}); + + const content = + (inline + ? audio + : html.tag('div', {class: 'content-audio-container'}, + align === 'center' && + {class: 'align-center'}, + + audio)); + + return { + type: 'processed-audio', + data: content, + }; + } + case 'internal-link': { const nodeFromRelations = relations.internalLinks[internalLinkIndex++]; if (nodeFromRelations.type === 'text') { return {type: 'text', data: nodeFromRelations.data}; } - const {link, label, hash} = nodeFromRelations; + // TODO: This is a bit hacky, like the stuff below, + // but since we dressed it up in a utility function + // maybe it's okay... + const link = + html.resolve( + nodeFromRelations.link, + {slots: ['content', 'hash']}); + + const {label, hash, shortName, name} = nodeFromRelations; + + if (slots.textOnly) { + if (label) { + return {type: 'text', data: label}; + } else if (slots.preferShortLinkNames) { + return {type: 'text', data: shortName ?? name}; + } else { + return {type: 'text', data: name}; + } + } // These are removed from the typical combined slots({})-style // because we don't want to override slots that were already set @@ -373,6 +505,18 @@ export default { link.setSlot('tooltipStyle', 'none'); } + let doTheAbsorbyThing = false; + + // TODO: This is just silly. + try { + const tag = html.resolve(link, {normalize: 'tag'}); + doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link'); + } catch {} + + if (doTheAbsorbyThing) { + absorbFollowingPunctuation(link); + } + return {type: 'processed-internal-link', data: link}; } @@ -380,11 +524,19 @@ export default { const {label} = node.data; const externalLink = relations.externalLinks[externalLinkIndex++]; + if (slots.textOnly) { + return {type: 'text', data: label}; + } + externalLink.setSlots({ content: label, fromContent: true, }); + if (slots.absorbPunctuationFollowingExternalLinks) { + absorbFollowingPunctuation(externalLink); + } + if (slots.indicateExternalLinks) { externalLink.setSlots({ indicateExternal: true, @@ -412,12 +564,19 @@ export default { ? valueFn(replacerValue) : replacerValue); - const contents = + const content = (htmlFn ? htmlFn(value, {html, language}) : value); - return {type: 'text', data: contents.toString()}; + const contentText = + html.resolve(content, {normalize: 'string'}); + + if (slots.textOnly) { + return {type: 'text', data: striptags(contentText)}; + } else { + return {type: 'text', data: contentText}; + } } default: @@ -493,15 +652,19 @@ export default { const attributes = html.parseAttributes(match[1]); - // Images that were all on their own line need to be removed from - // the surrounding <p> tag that marked generates. The HTML parser - // treats a <div> that starts inside a <p> as a Crocker-class - // misgiving, and will treat you very badly if you feed it that. - if (attributes.get('data-type') === 'processed-image') { - if (!attributes.get('data-inline')) { - tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, ''); - deleteParagraph = true; - } + // Images (or videos) that were all on their own line need to be + // removed from the surrounding <p> tag that marked generates. + // The HTML parser treats a <div> that starts inside a <p> as a + // Crocker-class misgiving, and will treat you very badly if you + // feed it that. + if ( + (attributes.get('data-type') === 'processed-image' && + !attributes.get('data-inline')) || + attributes.get('data-type') === 'processed-video' || + attributes.get('data-type') === 'processed-audio' + ) { + tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, ''); + deleteParagraph = true; } const nonTextNodeIndex = match[2]; @@ -512,7 +675,11 @@ export default { addText(markedOutput.slice(parseFrom)); } - return html.tags(tags, {[html.joinChildren]: ''}); + return ( + html.tags(tags, { + [html.joinChildren]: '', + [html.onlyIfContent]: true, + })); }; if (slots.mode === 'inline') { @@ -537,9 +704,9 @@ export default { // Expand line breaks which don't follow a list, quote, // or <br> / " ", and which don't precede or follow // indented text (by at least two spaces). - .replace(/(?<!^ *(?:-|\d\.).*|^>.*|^ .*\n*| $|<br>$)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + .replace(/(?<!^ *(?:-|\d+\.).*|^>.*|^ .*\n*| $|<br>$)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ // Expand line breaks which are at the end of a list. - .replace(/(?<=^ *(?:-|\d\.).*)\n+(?!^ *(?:-|\d\.))/gm, '\n\n') + .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n') // Expand line breaks which are at the end of a quote. .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); @@ -571,25 +738,12 @@ export default { const markedInput = extractNonTextNodes({ - getTextNodeContents(node, index) { - // First, replace line breaks that follow text content with - // <br> tags. - let content = node.data.replace(/(?!^)\n/gm, '<br>\n'); - - // Scrap line breaks that are at the end of a verse. - content = content.replace(/<br>$(?=\n\n)/gm, ''); - - // If the node started with a line break, and it's not the - // very first node, then whatever came before it was inline. - // (This is an assumption based on text links being basically - // the only tag that shows up in lyrics.) Since this text is - // following content that was already inline, restore that - // initial line break. - if (node.data[0] === '\n' && index !== 0) { - content = '<br>' + content; - } - - return content; + getTextNodeContents(node) { + // Just insert <br> before every line break. The resulting + // text will appear all in one paragraph - this is expected + // for lyrics, and allows for multiple lines of proportional + // space between stanzas. + return node.data.replace(/\n/g, '<br>\n'); }, }); |