GitHub Copilot helps you code faster and with confidence.
+``` + +### AUTOTITLE Error +```html + +For more information, see Getting started.
+``` + +### Missing File Reference +```html + +Welcome to GitHub documentation.
+``` + +## Troubleshooting + +### Common Issues + +**Q: I don't see any TRANSLATION_FALLBACK comments** +A: This means your translations are working correctly! Comments only appear when errors occur. + +**Q: Comments appear in the wrong language** +A: Comments are only added for non-English pages. Check that you're viewing a translated version of the page. + +**Q: The error message is truncated** +A: Messages are limited to 200 characters for readability. The truncation ensures comments don't become too large. + +**Q: File paths show as relative paths** +A: File paths are shown as they exist in the repository structure for easy navigation. + +### Getting Help + +If you need assistance interpreting error messages or fixing translation issues: + +1. Note the error type and message from the comment +2. Check the file and line number mentioned +3. Compare with the working English version +4. Reach out to the docs engineering team with specific error details + +## Technical Notes + +### Browser Compatibility +HTML comments are supported by all browsers and are invisible to end users. + +### Security Considerations +- No sensitive file paths or internal data exposed +- Error messages are sanitized and length-limited +- Only translation-related debugging information included + +### Monitoring +Translation fallback frequency can be monitored by tracking comment generation in logs or analytics. + +This feature helps translation teams identify and fix issues more efficiently while maintaining the reliability of the docs site for all users. \ No newline at end of file diff --git a/src/languages/lib/render-with-fallback.js b/src/languages/lib/render-with-fallback.js index 382f1cba40ad..0dbaf13d2ea3 100644 --- a/src/languages/lib/render-with-fallback.js +++ b/src/languages/lib/render-with-fallback.js @@ -2,7 +2,7 @@ import { renderContent } from '#src/content-render/index.js' import Page from '#src/frame/lib/page.js' import { TitleFromAutotitleError } from '#src/content-render/unified/rewrite-local-links.js' -class EmptyTitleError extends Error {} +export class EmptyTitleError extends Error {} const LIQUID_ERROR_NAMES = new Set(['RenderError', 'ParseError', 'TokenizationError']) export const isLiquidError = (error) => @@ -14,6 +14,64 @@ const isEmptyTitleError = (error) => error instanceof EmptyTitleError const isFallbackableError = (error) => 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, property) { + const errorType = error.name || 'UnknownError' + let errorDetails = [] + + // 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 `` +} + // Returns a string by wrapping `renderContent()`. The input string to // `renderContent` is one that contains Liquid and Markdown. The output // is HTML. @@ -47,8 +105,18 @@ export async function renderContentWithFallback(page, property, context, options // If you don't change the context, it'll confuse the liquid plugins // like `data.js` that uses `environment.scope.currentLanguage` const enContext = Object.assign({}, context, { currentLanguage: 'en' }) - // Try again! - return await renderContent(englishTemplate, enContext, options) + + // 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, property) + return errorComment + '\n' + fallbackContent + } + + return fallbackContent } throw error } @@ -75,7 +143,16 @@ export async function executeWithFallback(context, callable, fallback) { } catch (error) { if (isFallbackableError(error) && context.currentLanguage !== 'en') { const enContext = Object.assign({}, context, { currentLanguage: 'en' }) - return await fallback(enContext) + const fallbackContent = await 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, 'content') + return errorComment + '\n' + fallbackContent + } + + return fallbackContent } throw error } diff --git a/src/languages/tests/translation-error-comments.js b/src/languages/tests/translation-error-comments.js new file mode 100644 index 000000000000..1217e656e267 --- /dev/null +++ b/src/languages/tests/translation-error-comments.js @@ -0,0 +1,425 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' +import { + createTranslationFallbackComment, + EmptyTitleError, + renderContentWithFallback, + executeWithFallback, +} from '../lib/render-with-fallback.js' +import { TitleFromAutotitleError } from '#src/content-render/unified/rewrite-local-links.js' +import Page from '#src/frame/lib/page.js' + +describe('Translation Error Comments', () => { + // Mock renderContent for integration tests + let mockRenderContent + + beforeEach(() => { + mockRenderContent = vi.fn() + vi.stubGlobal('renderContent', mockRenderContent) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('createTranslationFallbackComment', () => { + describe('Liquid ParseError', () => { + test('includes all fields when token information is available', () => { + const error = new Error("Unknown tag 'badtag', line:1, col:3") + error.name = 'ParseError' + error.token = { + file: '/content/test/article.md', + getPosition: () => [1, 3], + } + + const result = createTranslationFallbackComment(error, 'rawTitle') + + expect(result).toContain('')).toBe(true) + }) + }) + + describe('Liquid RenderError', () => { + test('includes original error message when available', () => { + const error = new Error("Unknown variable 'variables.nonexistent.value'") + error.name = 'RenderError' + error.token = { + file: '/content/test/intro.md', + getPosition: () => [3, 15], + } + error.originalError = new Error('Variable not found: variables.nonexistent.value') + + const result = createTranslationFallbackComment(error, 'rawIntro') + + expect(result).toContain('prop=rawIntro') + expect(result).toContain('type=RenderError') + expect(result).toContain('file=/content/test/intro.md') + expect(result).toContain('line=3') + expect(result).toContain('col=15') + expect(result).toContain('msg="Variable not found: variables.nonexistent.value"') + }) + + test('falls back to main error message when no originalError', () => { + const error = new Error('Main error message') + error.name = 'RenderError' + error.token = { + file: '/content/test.md', + getPosition: () => [1, 1], + } + + const result = createTranslationFallbackComment(error, 'rawTitle') + + expect(result).toContain('msg="Main error message"') + }) + }) + + describe('Liquid TokenizationError', () => { + test('includes tokenization error details', () => { + const error = new Error('Unexpected token, line:1, col:10') + error.name = 'TokenizationError' + error.token = { + file: '/content/test/page.md', + getPosition: () => [1, 10], + } + + const result = createTranslationFallbackComment(error, 'markdown') + + expect(result).toContain('prop=markdown') + expect(result).toContain('type=TokenizationError') + expect(result).toContain('file=/content/test/page.md') + expect(result).toContain('line=1') + expect(result).toContain('col=10') + expect(result).toContain('msg="Unexpected token, line:1, col:10"') + }) + }) + + describe('TitleFromAutotitleError', () => { + test('includes AUTOTITLE error message', () => { + const error = new TitleFromAutotitleError( + 'Could not find target page for [AUTOTITLE] link to invalid-link', + ) + error.name = 'TitleFromAutotitleError' + + const result = createTranslationFallbackComment(error, 'rawTitle') + + expect(result).toContain('prop=rawTitle') + expect(result).toContain('type=TitleFromAutotitleError') + expect(result).toContain( + 'msg="Could not find target page for [AUTOTITLE] link to invalid-link"', + ) + // Should not contain file/line/col since AUTOTITLE errors don't have tokens + expect(result).not.toContain('file=') + expect(result).not.toContain('line=') + expect(result).not.toContain('col=') + }) + }) + + describe('EmptyTitleError', () => { + test('includes empty content message', () => { + const error = new EmptyTitleError("output for property 'rawTitle' became empty") + error.name = 'EmptyTitleError' + + const result = createTranslationFallbackComment(error, 'rawTitle') + + expect(result).toContain('prop=rawTitle') + expect(result).toContain('type=EmptyTitleError') + expect(result).toContain('msg="Content became empty after rendering"') + }) + }) + + describe('Error handling edge cases', () => { + test('handles error with no token information gracefully', () => { + const error = new Error('Generic liquid error without token info') + error.name = 'RenderError' + // No token property + + const result = createTranslationFallbackComment(error, 'rawIntro') + + expect(result).toContain('prop=rawIntro') + expect(result).toContain('type=RenderError') + expect(result).toContain('msg="Generic liquid error without token info"') + // Should not contain file/line/col since no token + expect(result).not.toContain('file=') + expect(result).not.toContain('line=') + expect(result).not.toContain('col=') + }) + + test('handles error with token but no file', () => { + const error = new Error('Error message') + error.name = 'ParseError' + error.token = { + // No file property + getPosition: () => [5, 10], + } + + const result = createTranslationFallbackComment(error, 'markdown') + + expect(result).toContain('line=5') + expect(result).toContain('col=10') + expect(result).not.toContain('file=') + }) + + test('handles error with token but no getPosition method', () => { + const error = new Error('Error message') + error.name = 'ParseError' + error.token = { + file: '/content/test.md', + // No getPosition method + } + + const result = createTranslationFallbackComment(error, 'title') + + expect(result).toContain('file=/content/test.md') + expect(result).not.toContain('line=') + expect(result).not.toContain('col=') + }) + + test('truncates very long error messages', () => { + const longMessage = 'A'.repeat(300) // Very long error message + const error = new Error(longMessage) + error.name = 'ParseError' + + const result = createTranslationFallbackComment(error, 'rawTitle') + + expect(result).toContain('msg="') + expect(result).toContain('...') + + // Extract the message part to verify truncation + const msgMatch = result.match(/msg="([^"]*)"/) + expect(msgMatch).toBeTruthy() + expect(msgMatch[1].length).toBeLessThanOrEqual(203) // 200 + '...' + }) + + test('properly escapes quotes in error messages', () => { + const error = new Error('Error with "double quotes" and more') + error.name = 'RenderError' + + const result = createTranslationFallbackComment(error, 'rawTitle') + + expect(result).toContain('msg="Error with \'double quotes\' and more"') + expect(result).not.toContain('msg="Error with "double quotes"') + }) + + test('handles error with unknown type', () => { + const error = new Error('Some error') + // No name property (will default to 'Error') + + const result = createTranslationFallbackComment(error, 'content') + + expect(result).toContain('type=Error') + expect(result).toContain('prop=content') + // Non-liquid errors without specific handling don't get messages + expect(result).not.toContain('msg=') + }) + + test('handles error with no message', () => { + const error = new Error() + error.name = 'ParseError' + // Message will be empty string by default + + const result = createTranslationFallbackComment(error, 'title') + + expect(result).toContain('type=ParseError') + expect(result).toContain('prop=title') + // Should handle gracefully, might not have msg or have empty msg + }) + + test('cleans up multiline messages', () => { + const error = new Error('Line 1\nLine 2\n Line 3 \n\nLine 5') + error.name = 'RenderError' + + const result = createTranslationFallbackComment(error, 'content') + + expect(result).toContain('msg="Line 1 Line 2 Line 3 Line 5"') + expect(result).not.toContain('\n') + }) + }) + + describe('Comment format validation', () => { + test('comment format is valid HTML', () => { + const error = new Error('Test error') + error.name = 'ParseError' + error.token = { + file: '/content/test.md', + getPosition: () => [1, 1], + } + + const result = createTranslationFallbackComment(error, 'rawTitle') + + // Should be a proper HTML comment + expect(result.startsWith('')).toBe(true) + + // Should be on a single line + expect(result).not.toContain('\n') + }) + + test('contains all required fields when available', () => { + const error = new Error('Detailed error message') + error.name = 'RenderError' + error.token = { + file: '/content/detailed-test.md', + getPosition: () => [42, 15], + } + + const result = createTranslationFallbackComment(error, 'rawIntro') + + expect(result).toContain('TRANSLATION_FALLBACK') + expect(result).toContain('prop=rawIntro') + expect(result).toContain('type=RenderError') + expect(result).toContain('file=/content/detailed-test.md') + expect(result).toContain('line=42') + expect(result).toContain('col=15') + expect(result).toContain('msg="Detailed error message"') + }) + + test('maintains consistent field order', () => { + const error = new Error('Test message') + error.name = 'ParseError' + error.token = { + file: '/content/test.md', + getPosition: () => [1, 1], + } + + const result = createTranslationFallbackComment(error, 'title') + + // Should follow the expected structure with all required fields + expect(result.startsWith('')).toBe(true) + }) + }) + }) + + describe('Integration Tests', () => { + describe('renderContentWithFallback', () => { + test('adds HTML comment when translation fails and fallback succeeds', async () => { + // Mock a simple page object that satisfies instanceof Page check + const mockPage = Object.create(Page.prototype) + mockPage.rawTitle = '{% badtag %}' + + const context = { + currentLanguage: 'ja', + getEnglishPage: () => { + const enPage = Object.create(Page.prototype) + enPage.rawTitle = 'English Title' + return enPage + }, + } + + // Mock renderContent to simulate error for Japanese, success for English + mockRenderContent.mockImplementation((template, context) => { + if (context.currentLanguage !== 'en' && template.includes('badtag')) { + const error = new Error("Unknown tag 'badtag'") + error.name = 'ParseError' + error.token = { + file: '/content/test.md', + getPosition: () => [1, 5], + } + throw error + } + return context.currentLanguage === 'en' ? 'English Title' : template + }) + + const result = await renderContentWithFallback(mockPage, 'rawTitle', context) + + expect(result).toContain('