diff options
Diffstat (limited to 'ext/js/templates/anki-template-renderer.js')
-rw-r--r-- | ext/js/templates/anki-template-renderer.js | 827 |
1 files changed, 827 insertions, 0 deletions
diff --git a/ext/js/templates/anki-template-renderer.js b/ext/js/templates/anki-template-renderer.js new file mode 100644 index 00000000..4bb56a4b --- /dev/null +++ b/ext/js/templates/anki-template-renderer.js @@ -0,0 +1,827 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-2022 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +import {Handlebars} from '../../lib/handlebars.js'; +import {createAnkiNoteData} from '../data/anki-note-data-creator.js'; +import {getPronunciationsOfType, isNonNounVerbOrAdjective} from '../dictionary/dictionary-data-util.js'; +import {createPronunciationDownstepPosition, createPronunciationGraph, createPronunciationText} from '../display/pronunciation-generator.js'; +import {StructuredContentGenerator} from '../display/structured-content-generator.js'; +import {CssStyleApplier} from '../dom/css-style-applier.js'; +import {convertHiraganaToKatakana, convertKatakanaToHiragana, distributeFurigana, getKanaMorae, getPitchCategory, isMoraPitchHigh} from '../language/ja/japanese.js'; +import {AnkiTemplateRendererContentManager} from './anki-template-renderer-content-manager.js'; +import {TemplateRendererMediaProvider} from './template-renderer-media-provider.js'; +import {TemplateRenderer} from './template-renderer.js'; + +/** + * This class contains all Anki-specific template rendering functionality. It is built on + * the generic TemplateRenderer class and various other Anki-related classes. + */ +export class AnkiTemplateRenderer { + /** + * Creates a new instance of the class. + */ + constructor() { + /** @type {CssStyleApplier} */ + this._structuredContentStyleApplier = new CssStyleApplier('/data/structured-content-style.json'); + /** @type {CssStyleApplier} */ + this._pronunciationStyleApplier = new CssStyleApplier('/data/pronunciation-style.json'); + /** @type {RegExp} */ + this._structuredContentDatasetKeyIgnorePattern = /^sc([^a-z]|$)/; + /** @type {TemplateRenderer} */ + this._templateRenderer = new TemplateRenderer(); + /** @type {TemplateRendererMediaProvider} */ + this._mediaProvider = new TemplateRendererMediaProvider(); + /** @type {?(Map<string, unknown>[])} */ + this._stateStack = null; + /** @type {?import('anki-note-builder').Requirement[]} */ + this._requirements = null; + /** @type {(() => void)[]} */ + this._cleanupCallbacks = []; + /** @type {?HTMLElement} */ + this._temporaryElement = null; + } + + /** + * Gets the generic TemplateRenderer instance. + * @type {TemplateRenderer} + */ + get templateRenderer() { + return this._templateRenderer; + } + + /** + * Prepares the data that is necessary before the template renderer can be safely used. + */ + async prepare() { + /* eslint-disable @stylistic/no-multi-spaces */ + this._templateRenderer.registerHelpers([ + ['dumpObject', this._dumpObject.bind(this)], + ['furigana', this._furigana.bind(this)], + ['furiganaPlain', this._furiganaPlain.bind(this)], + ['multiLine', this._multiLine.bind(this)], + ['regexReplace', this._regexReplace.bind(this)], + ['regexMatch', this._regexMatch.bind(this)], + ['mergeTags', this._mergeTags.bind(this)], + ['eachUpTo', this._eachUpTo.bind(this)], + ['spread', this._spread.bind(this)], + ['op', this._op.bind(this)], + ['get', this._get.bind(this)], + ['set', this._set.bind(this)], + ['scope', this._scope.bind(this)], + ['property', this._property.bind(this)], + ['noop', this._noop.bind(this)], + ['isMoraPitchHigh', this._isMoraPitchHigh.bind(this)], + ['getKanaMorae', this._getKanaMorae.bind(this)], + ['typeof', this._getTypeof.bind(this)], + ['join', this._join.bind(this)], + ['concat', this._concat.bind(this)], + ['pitchCategories', this._pitchCategories.bind(this)], + ['formatGlossary', this._formatGlossary.bind(this)], + ['hasMedia', this._hasMedia.bind(this)], + ['getMedia', this._getMedia.bind(this)], + ['pronunciation', this._pronunciation.bind(this)], + ['hiragana', this._hiragana.bind(this)], + ['katakana', this._katakana.bind(this)] + ]); + /* eslint-enable @stylistic/no-multi-spaces */ + this._templateRenderer.registerDataType('ankiNote', { + modifier: ({marker, commonData}) => createAnkiNoteData(marker, commonData), + composeData: ({marker}, commonData) => ({marker, commonData}) + }); + this._templateRenderer.setRenderCallbacks( + this._onRenderSetup.bind(this), + this._onRenderCleanup.bind(this) + ); + await Promise.all([ + this._structuredContentStyleApplier.prepare(), + this._pronunciationStyleApplier.prepare() + ]); + } + + // Private + + /** + * @returns {{requirements: import('anki-note-builder').Requirement[]}} + */ + _onRenderSetup() { + /** @type {import('anki-note-builder').Requirement[]} */ + const requirements = []; + this._stateStack = [new Map()]; + this._requirements = requirements; + this._mediaProvider.requirements = requirements; + return {requirements}; + } + + /** + * @returns {void} + */ + _onRenderCleanup() { + for (const callback of this._cleanupCallbacks) { callback(); } + this._stateStack = null; + this._requirements = null; + this._mediaProvider.requirements = null; + this._cleanupCallbacks.length = 0; + } + + /** + * @param {string} text + * @returns {string} + */ + _safeString(text) { + return new Handlebars.SafeString(text); + } + + // Template helpers + + /** @type {import('template-renderer').HelperFunction<string>} */ + _dumpObject(object) { + return JSON.stringify(object, null, 4); + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _furigana(args, context, options) { + const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); + const segments = distributeFurigana(expression, reading); + + let result = ''; + for (const {text, reading: reading2} of segments) { + result += ( + reading2.length > 0 ? + `<ruby>${text}<rt>${reading2}</rt></ruby>` : + text + ); + } + + return this._safeString(result); + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _furiganaPlain(args, context, options) { + const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); + const segments = distributeFurigana(expression, reading); + + let result = ''; + for (const {text, reading: reading2} of segments) { + if (reading2.length > 0) { + if (result.length > 0) { result += ' '; } + result += `${text}[${reading2}]`; + } else { + result += text; + } + } + + return result; + } + + /** + * @type {import('template-renderer').HelperFunction<{expression: string, reading: string}>} + */ + _getFuriganaExpressionAndReading(args) { + let expression; + let reading; + if (args.length >= 2) { + [expression, reading] = /** @type {[expression?: string, reading?: string]} */ (args); + } else { + ({expression, reading} = /** @type {import('core').SerializableObject} */ (args[0])); + } + return { + expression: typeof expression === 'string' ? expression : '', + reading: typeof reading === 'string' ? reading : '' + }; + } + + /** + * @param {string} string + * @returns {string} + */ + _stringToMultiLineHtml(string) { + return string.split('\n').join('<br>'); + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _multiLine(_args, context, options) { + return this._stringToMultiLineHtml(this._computeValueString(options, context)); + } + + /** + * Usage: + * ```{{#regexReplace regex string [flags] [content]...}}content{{/regexReplace}}``` + * - regex: regular expression string + * - string: string to replace + * - flags: optional flags for regular expression. + * e.g. "i" for case-insensitive, "g" for replace all + * @type {import('template-renderer').HelperFunction<string>} + */ + _regexReplace(args, context, options) { + const argCount = args.length; + let value = this._computeValueString(options, context); + if (argCount > 3) { + value = `${args.slice(3).join('')}${value}`; + } + if (argCount > 1) { + try { + const [pattern, replacement, flags] = args; + if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } + if (typeof replacement !== 'string') { throw new Error('Invalid replacement'); } + const regex = new RegExp(pattern, typeof flags === 'string' ? flags : 'g'); + value = value.replace(regex, replacement); + } catch (e) { + return `${e}`; + } + } + return value; + } + + /** + * Usage: + * {{#regexMatch regex [flags] [content]...}}content{{/regexMatch}} + * - regex: regular expression string + * - flags: optional flags for regular expression + * e.g. "i" for case-insensitive, "g" for match all + * @type {import('template-renderer').HelperFunction<string>} + */ + _regexMatch(args, context, options) { + const argCount = args.length; + let value = this._computeValueString(options, context); + if (argCount > 2) { + value = `${args.slice(2).join('')}${value}`; + } + if (argCount > 0) { + try { + const [pattern, flags] = args; + if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } + const regex = new RegExp(pattern, typeof flags === 'string' ? flags : ''); + /** @type {string[]} */ + const parts = []; + value.replace(regex, (g0) => { + parts.push(g0); + return g0; + }); + value = parts.join(''); + } catch (e) { + return `${e}`; + } + } + return value; + } + + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _mergeTags(args) { + const [object, isGroupMode, isMergeMode] = /** @type {[object: import('anki-templates').TermDictionaryEntry, isGroupMode: boolean, isMergeMode: boolean]} */ (args); + const tagSources = []; + if (isGroupMode || isMergeMode) { + const {definitions} = object; + if (Array.isArray(definitions)) { + for (const definition of definitions) { + tagSources.push(definition.definitionTags); + } + } + } else { + tagSources.push(object.definitionTags); + } + + const tags = new Set(); + for (const tagSource of tagSources) { + if (!Array.isArray(tagSource)) { continue; } + for (const tag of tagSource) { + tags.add(tag.name); + } + } + + return [...tags].join(', '); + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _eachUpTo(args, context, options) { + const [iterable, maxCount] = /** @type {[iterable: Iterable<unknown>, maxCount: number]} */ (args); + if (iterable) { + const results = []; + let any = false; + for (const entry of iterable) { + any = true; + if (results.length >= maxCount) { break; } + const processedEntry = this._computeValue(options, entry); + results.push(processedEntry); + } + if (any) { + return results.join(''); + } + } + return this._computeInverseString(options, context); + } + + /** @type {import('template-renderer').HelperFunction<unknown[]>} */ + _spread(args) { + const result = []; + for (const array of /** @type {Iterable<unknown>[]} */ (args)) { + try { + result.push(...array); + } catch (e) { + // NOP + } + } + return result; + } + + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _op(args) { + const [operator] = /** @type {[operator: string, operand1: import('core').SafeAny, operand2?: import('core').SafeAny, operand3?: import('core').SafeAny]} */ (args); + switch (args.length) { + case 2: return this._evaluateUnaryExpression(operator, args[1]); + case 3: return this._evaluateBinaryExpression(operator, args[1], args[2]); + case 4: return this._evaluateTernaryExpression(operator, args[1], args[2], args[3]); + default: return void 0; + } + } + + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @returns {unknown} + */ + _evaluateUnaryExpression(operator, operand1) { + switch (operator) { + case '+': return +operand1; + case '-': return -operand1; + case '~': return ~operand1; + case '!': return !operand1; + default: return void 0; + } + } + + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @param {import('core').SafeAny} operand2 + * @returns {unknown} + */ + _evaluateBinaryExpression(operator, operand1, operand2) { + switch (operator) { + case '+': return operand1 + operand2; + case '-': return operand1 - operand2; + case '/': return operand1 / operand2; + case '*': return operand1 * operand2; + case '%': return operand1 % operand2; + case '**': return operand1 ** operand2; + case '==': return operand1 == operand2; // eslint-disable-line eqeqeq + case '!=': return operand1 != operand2; // eslint-disable-line eqeqeq + case '===': return operand1 === operand2; + case '!==': return operand1 !== operand2; + case '<': return operand1 < operand2; + case '<=': return operand1 <= operand2; + case '>': return operand1 > operand2; + case '>=': return operand1 >= operand2; + case '<<': return operand1 << operand2; + case '>>': return operand1 >> operand2; + case '>>>': return operand1 >>> operand2; + case '&': return operand1 & operand2; + case '|': return operand1 | operand2; + case '^': return operand1 ^ operand2; + case '&&': return operand1 && operand2; + case '||': return operand1 || operand2; + default: return void 0; + } + } + + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @param {import('core').SafeAny} operand2 + * @param {import('core').SafeAny} operand3 + * @returns {unknown} + */ + _evaluateTernaryExpression(operator, operand1, operand2, operand3) { + switch (operator) { + case '?:': return operand1 ? operand2 : operand3; + default: return void 0; + } + } + + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _get(args) { + const [key] = /** @type {[key: string]} */ (args); + const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } + for (let i = stateStack.length; --i >= 0;) { + const map = stateStack[i]; + if (map.has(key)) { + return map.get(key); + } + } + return void 0; + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _set(args, context, options) { + const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } + switch (args.length) { + case 1: + { + const [key] = /** @type {[key: string]} */ (args); + const value = this._computeValue(options, context); + stateStack[stateStack.length - 1].set(key, value); + } + break; + case 2: + { + const [key, value] = /** @type {[key: string, value: unknown]} */ (args); + stateStack[stateStack.length - 1].set(key, value); + } + break; + } + return ''; + } + + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _scope(_args, context, options) { + const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } + try { + stateStack.push(new Map()); + return this._computeValue(options, context); + } finally { + if (stateStack.length > 1) { + stateStack.pop(); + } + } + } + + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _property(args) { + const ii = args.length; + if (ii <= 0) { return void 0; } + + try { + let value = args[0]; + for (let i = 1; i < ii; ++i) { + if (typeof value !== 'object' || value === null) { throw new Error('Invalid object'); } + const key = args[i]; + switch (typeof key) { + case 'number': + case 'string': + case 'symbol': + break; + default: + throw new Error('Invalid key'); + } + value = /** @type {import('core').UnknownObject} */ (value)[key]; + } + return value; + } catch (e) { + return void 0; + } + } + + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _noop(_args, context, options) { + return this._computeValue(options, context); + } + + /** @type {import('template-renderer').HelperFunction<boolean>} */ + _isMoraPitchHigh(args) { + const [index, position] = /** @type {[index: number, position: number]} */ (args); + return isMoraPitchHigh(index, position); + } + + /** @type {import('template-renderer').HelperFunction<string[]>} */ + _getKanaMorae(args) { + const [text] = /** @type {[text: string]} */ (args); + return getKanaMorae(`${text}`); + } + + /** @type {import('template-renderer').HelperFunction<import('core').TypeofResult>} */ + _getTypeof(args, context, options) { + const ii = args.length; + const value = (ii > 0 ? args[0] : this._computeValue(options, context)); + return typeof value; + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _join(args) { + return args.length > 0 ? args.slice(1, args.length).flat().join(/** @type {string} */ (args[0])) : ''; + } + + /** @type {import('template-renderer').HelperFunction<string>} */ + _concat(args) { + let result = ''; + for (let i = 0, ii = args.length; i < ii; ++i) { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + result += args[i]; + } + return result; + } + + /** @type {import('template-renderer').HelperFunction<string[]>} */ + _pitchCategories(args) { + const [data] = /** @type {[data: import('anki-templates').NoteData]} */ (args); + const {dictionaryEntry} = data; + if (dictionaryEntry.type !== 'term') { return []; } + const {pronunciations: termPronunciations, headwords} = dictionaryEntry; + /** @type {Set<string>} */ + const categories = new Set(); + for (const {headwordIndex, pronunciations} of termPronunciations) { + const {reading, wordClasses} = headwords[headwordIndex]; + const isVerbOrAdjective = isNonNounVerbOrAdjective(wordClasses); + const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); + for (const {position} of pitches) { + const category = getPitchCategory(reading, position, isVerbOrAdjective); + if (category !== null) { + categories.add(category); + } + } + } + return [...categories]; + } + + /** + * @returns {HTMLElement} + */ + _getTemporaryElement() { + let element = this._temporaryElement; + if (element === null) { + element = document.createElement('div'); + this._temporaryElement = element; + } + return element; + } + + /** + * @param {Element} node + * @returns {string} + */ + _getStructuredContentHtml(node) { + return this._getHtml(node, this._structuredContentStyleApplier, this._structuredContentDatasetKeyIgnorePattern); + } + + /** + * @param {Element} node + * @returns {string} + */ + _getPronunciationHtml(node) { + return this._getHtml(node, this._pronunciationStyleApplier, null); + } + + /** + * @param {Element} node + * @param {CssStyleApplier} styleApplier + * @param {?RegExp} datasetKeyIgnorePattern + * @returns {string} + */ + _getHtml(node, styleApplier, datasetKeyIgnorePattern) { + const container = this._getTemporaryElement(); + container.appendChild(node); + this._normalizeHtml(container, styleApplier, datasetKeyIgnorePattern); + const result = container.innerHTML; + container.textContent = ''; + return this._safeString(result); + } + + /** + * @param {Element} root + * @param {CssStyleApplier} styleApplier + * @param {?RegExp} datasetKeyIgnorePattern + */ + _normalizeHtml(root, styleApplier, datasetKeyIgnorePattern) { + const {ELEMENT_NODE, TEXT_NODE} = Node; + const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + /** @type {HTMLElement[]} */ + const elements = []; + /** @type {Text[]} */ + const textNodes = []; + while (true) { + const node = treeWalker.nextNode(); + if (node === null) { break; } + switch (node.nodeType) { + case ELEMENT_NODE: + elements.push(/** @type {HTMLElement} */ (node)); + break; + case TEXT_NODE: + textNodes.push(/** @type {Text} */ (node)); + break; + } + } + styleApplier.applyClassStyles(elements); + for (const element of elements) { + const {dataset} = element; + for (const key of Object.keys(dataset)) { + if (datasetKeyIgnorePattern !== null && datasetKeyIgnorePattern.test(key)) { continue; } + delete dataset[key]; + } + } + for (const textNode of textNodes) { + this._replaceNewlines(textNode); + } + } + + /** + * @param {Text} textNode + */ + _replaceNewlines(textNode) { + const parts = /** @type {string} */ (textNode.nodeValue).split('\n'); + if (parts.length <= 1) { return; } + const {parentNode} = textNode; + if (parentNode === null) { return; } + const fragment = document.createDocumentFragment(); + for (let i = 0, ii = parts.length; i < ii; ++i) { + if (i > 0) { fragment.appendChild(document.createElement('br')); } + fragment.appendChild(document.createTextNode(parts[i])); + } + parentNode.replaceChild(fragment, textNode); + } + + /** + * @param {import('anki-templates').NoteData} data + * @returns {StructuredContentGenerator} + */ + _createStructuredContentGenerator(data) { + const contentManager = new AnkiTemplateRendererContentManager(this._mediaProvider, data); + const instance = new StructuredContentGenerator(contentManager, document); + this._cleanupCallbacks.push(() => contentManager.unloadAll()); + return instance; + } + + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _formatGlossary(args, _context, options) { + const [dictionary, content] = /** @type {[dictionary: string, content: import('dictionary-data').TermGlossaryContent]} */ (args); + /** @type {import('anki-templates').NoteData} */ + const data = options.data.root; + if (typeof content === 'string') { return this._safeString(this._stringToMultiLineHtml(content)); } + if (!(typeof content === 'object' && content !== null)) { return ''; } + switch (content.type) { + case 'image': return this._formatGlossaryImage(content, dictionary, data); + case 'structured-content': return this._formatStructuredContent(content, dictionary, data); + case 'text': return this._safeString(this._stringToMultiLineHtml(content.text)); + } + return ''; + } + + /** + * @param {import('dictionary-data').TermGlossaryImage} content + * @param {string} dictionary + * @param {import('anki-templates').NoteData} data + * @returns {string} + */ + _formatGlossaryImage(content, dictionary, data) { + const structuredContentGenerator = this._createStructuredContentGenerator(data); + const node = structuredContentGenerator.createDefinitionImage(content, dictionary); + return this._getStructuredContentHtml(node); + } + + /** + * @param {import('dictionary-data').TermGlossaryStructuredContent} content + * @param {string} dictionary + * @param {import('anki-templates').NoteData} data + * @returns {string} + */ + _formatStructuredContent(content, dictionary, data) { + const structuredContentGenerator = this._createStructuredContentGenerator(data); + const node = structuredContentGenerator.createStructuredContent(content.content, dictionary); + return node !== null ? this._getStructuredContentHtml(node) : ''; + } + + /** + * @type {import('template-renderer').HelperFunction<boolean>} + */ + _hasMedia(args, _context, options) { + /** @type {import('anki-templates').NoteData} */ + const data = options.data.root; + return this._mediaProvider.hasMedia(data, args, options.hash); + } + + /** + * @type {import('template-renderer').HelperFunction<?string>} + */ + _getMedia(args, _context, options) { + /** @type {import('anki-templates').NoteData} */ + const data = options.data.root; + return this._mediaProvider.getMedia(data, args, options.hash); + } + + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _pronunciation(_args, _context, options) { + const {format, reading, downstepPosition} = options.hash; + + if ( + typeof reading !== 'string' || + reading.length === 0 || + typeof downstepPosition !== 'number' + ) { + return ''; + } + const morae = getKanaMorae(reading); + + switch (format) { + case 'text': + { + const nasalPositions = this._getValidNumberArray(options.hash.nasalPositions); + const devoicePositions = this._getValidNumberArray(options.hash.devoicePositions); + return this._getPronunciationHtml(createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions)); + } + case 'graph': + return this._getPronunciationHtml(createPronunciationGraph(morae, downstepPosition)); + case 'position': + return this._getPronunciationHtml(createPronunciationDownstepPosition(downstepPosition)); + default: + return ''; + } + } + + /** + * @param {unknown} value + * @returns {number[]} + */ + _getValidNumberArray(value) { + const result = []; + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'number') { result.push(item); } + } + } + return result; + } + + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _hiragana(args, context, options) { + const ii = args.length; + const {keepProlongedSoundMarks} = options.hash; + const value = (ii > 0 ? args[0] : this._computeValue(options, context)); + return typeof value === 'string' ? convertKatakanaToHiragana(value, keepProlongedSoundMarks === true) : ''; + } + + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _katakana(args, context, options) { + const ii = args.length; + const value = (ii > 0 ? args[0] : this._computeValue(options, context)); + return typeof value === 'string' ? convertHiraganaToKatakana(value) : ''; + } + + /** + * @param {unknown} value + * @returns {string} + */ + _asString(value) { + return typeof value === 'string' ? value : `${value}`; + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {unknown} + */ + _computeValue(options, context) { + return typeof options.fn === 'function' ? options.fn(context) : ''; + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {string} + */ + _computeValueString(options, context) { + return this._asString(this._computeValue(options, context)); + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {unknown} + */ + _computeInverse(options, context) { + return typeof options.inverse === 'function' ? options.inverse(context) : ''; + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {string} + */ + _computeInverseString(options, context) { + return this._asString(this._computeInverse(options, context)); + } +} |