1
0
mirror of synced 2025-12-19 09:57:42 -05:00
Files
docs/src/languages/lib/render-with-fallback.ts
2025-10-21 15:00:13 +00:00

192 lines
7.2 KiB
TypeScript

import { renderContent } from '@/content-render/index'
import Page from '@/frame/lib/page'
import { TitleFromAutotitleError } from '@/content-render/unified/rewrite-local-links'
import type { Context } from '@/types'
export class EmptyTitleError extends Error {}
interface LiquidToken {
file?: string
getPosition?: () => [number, number]
}
interface LiquidError extends Error {
token?: LiquidToken
originalError?: Error
}
interface RenderOptions {
throwIfEmpty?: boolean
textOnly?: boolean
cache?: boolean | ((template: string, context: any) => string)
[key: string]: any
}
const LIQUID_ERROR_NAMES = new Set(['RenderError', 'ParseError', 'TokenizationError'])
export const isLiquidError = (error: unknown): error is LiquidError =>
error instanceof Error && error.name !== undefined && LIQUID_ERROR_NAMES.has(error.name)
const isAutotitleError = (error: unknown): error is TitleFromAutotitleError =>
error instanceof TitleFromAutotitleError
const isEmptyTitleError = (error: unknown): error is EmptyTitleError =>
error instanceof EmptyTitleError
const isFallbackableError = (error: unknown): boolean =>
isLiquidError(error) || isAutotitleError(error) || isEmptyTitleError(error)
/**
* Creates an HTML comment with translation fallback error information
* Includes detailed debugging information for translators
*/
export function createTranslationFallbackComment(error: Error, property: string): string {
const errorType = error.name || 'UnknownError'
const errorDetails: string[] = []
// Add basic error information
errorDetails.push(`TRANSLATION_FALLBACK`)
errorDetails.push(`prop=${property}`)
errorDetails.push(`type=${errorType}`)
// Extract detailed error information based on error type
if (isLiquidError(error)) {
// For Liquid errors, we can extract rich debugging information
if (error.token) {
if (error.token.file) {
errorDetails.push(`file=${error.token.file}`)
}
if (error.token.getPosition) {
const [line, col] = error.token.getPosition()
errorDetails.push(`line=${line}`)
errorDetails.push(`col=${col}`)
}
}
// Include the original error message if available
const originalMessage = error.originalError?.message || error.message
if (originalMessage) {
// Clean up the message but keep useful information
let cleanMessage = originalMessage.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()
// Limit message length to keep comment manageable
if (cleanMessage.length > 200) {
cleanMessage = cleanMessage.substring(0, 200) + '...'
}
errorDetails.push(`msg="${cleanMessage.replace(/"/g, "'")}"`)
}
} else if (isAutotitleError(error)) {
// For AUTOTITLE errors, include the error message
if (error.message) {
let cleanMessage = error.message
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 200)
errorDetails.push(`msg="${cleanMessage.replace(/"/g, "'")}"`)
}
} else if (isEmptyTitleError(error)) {
// For empty title errors, include the property info
errorDetails.push(`msg="Content became empty after rendering"`)
}
return `<!-- ${errorDetails.join(' ')} -->`
}
// Returns a string by wrapping `renderContent()`. The input string to
// `renderContent` is one that contains Liquid and Markdown. The output
// is HTML.
// But what the wrapper does is that it watches out for possible Liquid
// related rendering errors AND if the context has been prepared with a
// sync callable that can yield the English equivalent.
// So it's up to how the `context` is prepared if it has a `getEnglishPage`
// function. This means, we can know, in the middleware (which is a
// higher level than `lib/`) how to use the URL to figure out the
// equivalent English page instance.
export async function renderContentWithFallback(
// Using `any` type for page because the actual Page class from @/frame/lib/page
// has more properties than the Page interface defined in @/types, causing type conflicts
page: any,
property: string,
context: Context,
options?: RenderOptions,
): Promise<string> {
if (!(page instanceof Page)) {
throw new Error(`The first argument has to be Page instance (not ${typeof page})`)
}
if (typeof property !== 'string') {
throw new Error(`The second argument has to be a string (not ${typeof property})`)
}
const template = (page as any)[property] as string
try {
const output = await renderContent(template, context, options)
if (options && options.throwIfEmpty && !output.trim()) {
throw new EmptyTitleError(`output for property '${property}' became empty`)
}
return output
} catch (error) {
// Only bother trying to fallback if it was an error we *can* fall back
// on English for.
if (isFallbackableError(error) && context.getEnglishPage) {
const enPage = context.getEnglishPage(context)
const englishTemplate = (enPage as any)[property] as string
// If you don't change the context, it'll confuse the liquid plugins
// like `data.ts` that uses `environment.scope.currentLanguage`
const enContext = Object.assign({}, context, { currentLanguage: 'en' })
// Render the English fallback content
const fallbackContent = await renderContent(englishTemplate, enContext, options)
// Add HTML comment with error details for non-English languages
// Skip for textOnly rendering to avoid breaking plain text output
if (context.currentLanguage !== 'en' && !options?.textOnly) {
const errorComment = createTranslationFallbackComment(error as Error, property)
return errorComment + '\n' + fallbackContent
}
return fallbackContent
}
throw error
}
}
// Returns the result of executing the first function, but if it fails
// return the result of executing the second function.
// In particular, "fails" means if it's deemed an error thrown that we
// can fall back for.
// When it executes the fallback function, it creates a shallow copy of
// the original `context` but with the `currentLanguage:'en'` set on it.
//
// You can use this function to do things like this:
//
// const title = await executeWithFallback(
// context,
// () => renderContent(track.title, context, renderOpts),
// (enContext) => renderContent(enTrack.title, enContext, renderOpts)
// )
//
export async function executeWithFallback<T>(
context: Context,
callable: (context: Context) => T | Promise<T>,
fallback: (enContext: Context) => T | Promise<T>,
): Promise<T> {
try {
return await Promise.resolve(callable(context))
} catch (error) {
if (isFallbackableError(error) && context.currentLanguage !== 'en') {
const enContext = Object.assign({}, context, { currentLanguage: 'en' })
const fallbackContent = await Promise.resolve(fallback(enContext))
// Add HTML comment with error details for non-English languages
// Only for HTML content (detected by presence of HTML tags)
if (typeof fallbackContent === 'string' && /<[^>]+>/.test(fallbackContent)) {
const errorComment = createTranslationFallbackComment(error as Error, 'content')
return (errorComment + '\n' + fallbackContent) as T
}
return fallbackContent
}
throw error
}
}