diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index 1eb69f99..beaefaff 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -17,12 +17,15 @@ import { import logger from '@/lib/logger' import { isLanguageToolConfigured } from '@/lib/languagetool-client' import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter' -import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' +import { + buildLanguageToolPreferenceList, + pickLanguageToolCodeForTranslateTarget +} from '@/lib/languagetool-language-order' import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' +import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { fetchTranslateLanguages, isTranslateConfigured, - translatePlainText, type TranslateLanguageOption } from '@/lib/translate-client' import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' @@ -43,10 +46,13 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AdvancedEventLabMarkupToolbar } from './AdvancedEventLabMarkupToolbar' +import { AdvancedEventLabPreviewPane } from './AdvancedEventLabPreviewPane' import customEmojiService from '@/services/custom-emoji.service' import postEditorCache from '@/services/post-editor-cache.service' import type { TEmoji } from '@/types' +const PREVIEW_DEBOUNCE_MS = 200 + /** Subset of {@link TPostTextareaHandle} so media upload + toolbar can target the lab surface. */ export type AdvancedLabBodyHandle = { getText: () => string @@ -99,6 +105,10 @@ export type AdvancedEventLabDialogProps = { * clears this draft so the next open is seeded from TipTap again. */ draftPersistenceKey?: string | null + /** Lab preview: resolve custom `:shortcode:` from this author's NIP-30 inventory when tags do not define them. */ + previewAuthorPubkey?: string | null + /** Lab preview: `emoji` tags on the fake event (e.g. copied from the event being edited). */ + previewEmojiTags?: string[][] } function useDarkModeFlag(): boolean { @@ -129,7 +139,9 @@ export default function AdvancedEventLabDialog({ onApply, bodyApiRef, formatToolbar, - draftPersistenceKey = null + draftPersistenceKey = null, + previewAuthorPubkey = null, + previewEmojiTags }: AdvancedEventLabDialogProps) { const { t, i18n } = useTranslation() const dark = useDarkModeFlag() @@ -138,11 +150,45 @@ export default function AdvancedEventLabDialog({ const sliceRef = useRef(null) const draftPersistenceKeyRef = useRef(null) const labPersistTimerRef = useRef | null>(null) + const previewDebounceTimerRef = useRef | null>(null) + const schedulePreviewUpdateRef = useRef<(text: string) => void>(() => {}) /** When true, closing is from Apply (draft already cleared); skip discard cleanup. */ const skipClearLabDraftOnCloseRef = useRef(false) /** Debounce writes to the draft map; pagehide/beforeunload flush immediately to disk. */ const LAB_DRAFT_DEBOUNCE_MS = 500 + const [previewDoc, setPreviewDoc] = useState('') + + const mergedLabPreviewEmojiTags = useMemo(() => { + if (!open || !initial) return [] + const fromInitial = initial.tags.filter(([n]) => n === 'emoji').map((r) => [...r]) + const fromProp = previewEmojiTags ?? [] + const m = new Map() + for (const row of fromInitial) { + const sc = row[1]?.trim() + if (sc) m.set(sc.toLowerCase(), row) + } + for (const row of fromProp) { + const sc = row[1]?.trim() + if (sc) m.set(sc.toLowerCase(), row) + } + return [...m.values()] + }, [open, initial, previewEmojiTags]) + + const schedulePreviewUpdate = useCallback((text: string) => { + if (previewDebounceTimerRef.current) { + clearTimeout(previewDebounceTimerRef.current) + } + previewDebounceTimerRef.current = setTimeout(() => { + previewDebounceTimerRef.current = null + setPreviewDoc(text) + }, PREVIEW_DEBOUNCE_MS) + }, []) + + useEffect(() => { + schedulePreviewUpdateRef.current = schedulePreviewUpdate + }, [schedulePreviewUpdate]) + draftPersistenceKeyRef.current = draftPersistenceKey ?? null const flushLabDraftNow = useCallback((key: string) => { @@ -180,6 +226,16 @@ export default function AdvancedEventLabDialog({ } }, [open, draftPersistenceKey, flushLabDraftNow]) + useEffect(() => { + if (!open) { + if (previewDebounceTimerRef.current) { + clearTimeout(previewDebounceTimerRef.current) + previewDebounceTimerRef.current = null + } + setPreviewDoc('') + } + }, [open]) + const handleDialogOpenChange = useCallback( (next: boolean) => { if (!next) { @@ -311,6 +367,7 @@ export default function AdvancedEventLabDialog({ tags: initial.tags.map((row) => [...row]) } sliceRef.current = baseSlice + setPreviewDoc(baseSlice.content) const markupLang: Extension = markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown() @@ -331,7 +388,7 @@ export default function AdvancedEventLabDialog({ '&': { maxHeight: '100%' }, '.cm-scroller': { overflow: 'auto' }, '.cm-content': { - minHeight: 'min(50dvh, 42rem)', + minHeight: 'min(22dvh, 11rem)', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } }), @@ -341,11 +398,14 @@ export default function AdvancedEventLabDialog({ const s = sliceRef.current if (!s) return s.content = content + schedulePreviewUpdateRef.current(content) scheduleLabDraftPersist() }) ] if (isLanguageToolConfigured()) { - mkExtensions.push(languageToolLintExtension(() => ltLangRef.current, 650)) + mkExtensions.push( + languageToolLintExtension(() => ltLangRef.current, 650, () => markupMode) + ) } if (dark) mkExtensions.push(oneDark) @@ -402,6 +462,10 @@ export default function AdvancedEventLabDialog({ return () => { cancelled = true cancelAnimationFrame(rafId) + if (previewDebounceTimerRef.current) { + clearTimeout(previewDebounceTimerRef.current) + previewDebounceTimerRef.current = null + } if (labPersistTimerRef.current) { clearTimeout(labPersistTimerRef.current) labPersistTimerRef.current = null @@ -463,13 +527,24 @@ export default function AdvancedEventLabDialog({ inputChars: text.length }) try { - const out = await translatePlainText(text, translateTarget, translateSource) + const out = await translateAdvancedLabMarkup(text, translateTarget, translateSource, markupMode) if (!markupView.current) return markupView.current.dispatch({ changes: { from: 0, to: markupView.current.state.doc.length, insert: out } }) const s = sliceRef.current if (s) s.content = out + if (isLanguageToolConfigured()) { + const nextLt = pickLanguageToolCodeForTranslateTarget(translateTarget, ltList) + if (nextLt !== ltLang) { + logger.info('[AdvancedLab] grammar language synced after translate', { + from: ltLang, + to: nextLt, + translateTarget + }) + setLtLang(nextLt) + } + } toast.success(t('Advanced lab translate done')) } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) @@ -606,18 +681,33 @@ export default function AdvancedEventLabDialog({ -
- - {t( - markupMode === 'asciidoc' - ? 'Advanced lab markup label asciidoc' - : 'Advanced lab markup label markdown' - )} - -
+
+
+ + {t( + markupMode === 'asciidoc' + ? 'Advanced lab markup label asciidoc' + : 'Advanced lab markup label markdown' + )} + +
+
+
+ + {t('Advanced lab preview')} + +
+ +
+
{formatToolbar ? ( diff --git a/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx b/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx new file mode 100644 index 00000000..4fc19e04 --- /dev/null +++ b/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx @@ -0,0 +1,55 @@ +import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' +import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' +import { Card } from '@/components/ui/card' +import { ExtendedKind } from '@/constants' +import { createFakeEvent } from '@/lib/event' +import { kinds } from 'nostr-tools' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +export const AdvancedEventLabPreviewPane = memo(function AdvancedEventLabPreviewPane({ + markupMode, + source, + previewAuthorPubkey = null, + previewEmojiTags +}: { + markupMode: 'markdown' | 'asciidoc' + source: string + /** When set (hex pubkey), Markdown preview resolves custom `:shortcode:` from this author (NIP-30). */ + previewAuthorPubkey?: string | null + /** `emoji` tags on the preview fake event (e.g. from the note being edited). */ + previewEmojiTags?: string[][] +}) { + const { t } = useTranslation() + + const fakeEvent = useMemo(() => { + const kind = + markupMode === 'asciidoc' ? ExtendedKind.WIKI_ARTICLE : kinds.LongFormArticle + const pk = (previewAuthorPubkey ?? '').trim().toLowerCase() + const tags = (previewEmojiTags ?? []).map((row) => [...row]) + return createFakeEvent({ + content: source, + kind, + tags, + pubkey: pk + }) + }, [markupMode, source, previewAuthorPubkey, previewEmojiTags]) + + if (!source.trim()) { + return ( +

{t('Advanced lab preview empty')}

+ ) + } + + return ( + +
+ {markupMode === 'asciidoc' ? ( + + ) : ( + + )} +
+
+ ) +}) diff --git a/src/components/ContentPreview/Content.tsx b/src/components/ContentPreview/Content.tsx index 8c0adef7..2e6c24e5 100644 --- a/src/components/ContentPreview/Content.tsx +++ b/src/components/ContentPreview/Content.tsx @@ -7,7 +7,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import PaytoLink from '../PaytoLink' import { EmbeddedMentionText } from '../Embedded' -import Emoji from '../Emoji' +import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji' export default function Content({ content, @@ -52,9 +52,10 @@ export default function Content({ if (node.type === 'emoji') { const shortcode = node.data.slice(1, -1).trim() const emoji = emojiInfos?.find((e) => e.shortcode === shortcode) - if (emoji) return + if (emoji) return const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis) - if (native?.emoji) return + if (native?.emoji) + return return node.data } return node.data diff --git a/src/components/Emoji/index.tsx b/src/components/Emoji/index.tsx index 150c1a0c..6da1aac0 100644 --- a/src/components/Emoji/index.tsx +++ b/src/components/Emoji/index.tsx @@ -3,6 +3,11 @@ import { TEmoji } from '@/types' import { Heart, ThumbsDown } from 'lucide-react' import { HTMLAttributes, useState } from 'react' +/** ~4/3 of legacy `size-5` for custom images when no `classNames.img` override. */ +export const EMOJI_IMG_DEFAULT_CLASS = 'size-[calc(1.25rem*4/3)]' as const +/** ~4/3 of legacy `size-4` for dense inline contexts (markdown, likes row, etc.). */ +export const EMOJI_IMG_INLINE_CLASS = 'size-[calc(1rem*4/3)]' as const + export default function Emoji({ emoji, classNames, @@ -20,11 +25,15 @@ export default function Emoji({ if (typeof emoji === 'string') { if (emoji === '+') { - return + return } if (emoji === '-') { return ( - + ) } return {emoji} @@ -42,7 +51,8 @@ export default function Emoji({ alt={emoji.shortcode} draggable={false} className={cn( - 'inline-block size-5 rounded-sm', + 'inline-block rounded-sm', + EMOJI_IMG_DEFAULT_CLASS, onImageClick ? 'cursor-zoom-in' : 'pointer-events-none', classNames?.img )} diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 85fc3f34..4d9a1844 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -21,16 +21,25 @@ import { useTranslation } from 'react-i18next' /** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ const IMAGE_LOAD_TIMEOUT_MS = 10_000 -/** Without reserved height, `absolute` skeleton + `opacity-0` img collapse to 0×0 — looks like “nothing”. */ +/** + * Without reserved height, `absolute` skeleton + `opacity-0` img collapse to 0×0 — looks like “nothing”. + * The tall `minHeight` fallback is only for that placeholder phase; keeping it after load (with no `dim`) + * leaves a box taller than the `` when `height:100%` cannot resolve, which often reads as a white band + * under transparent GIFs or in dark UI. + */ function wrapperReserveStyle( dim: { width: number; height: number } | undefined, - showError: boolean + showError: boolean, + useMinHeightPlaceholder: boolean ): CSSProperties | undefined { if (showError) return undefined if (dim && dim.width > 0 && dim.height > 0) { return { aspectRatio: `${dim.width} / ${dim.height}` } } - return { minHeight: 'min(30vh, 280px)' } + if (useMinHeightPlaceholder) { + return { minHeight: 'min(30vh, 280px)' } + } + return undefined } function formatFileSize(bytes: number): string { @@ -50,6 +59,8 @@ export default function Image({ holdUntilClick = false, fetchPriority, onClick, + showAltCaption = false, + caption, /** Native tooltip on hover (e.g. Markdown `![alt](url "title")`). When set, overrides alt-as-title on ``. */ tooltipTitle, ...props @@ -62,6 +73,10 @@ export default function Image({ alt?: string /** Shown as the `` tooltip when non-empty. */ tooltipTitle?: string + /** When true, show {@link caption} or non-empty alt below the image (lightbox-style caption). */ + showAltCaption?: boolean + /** Caption below the image; defaults to resolved alt when {@link showAltCaption} is true. */ + caption?: string hideIfError?: boolean errorPlaceholder?: React.ReactNode /** Passed to the inner `` (e.g. profile banner vs avatar load order). */ @@ -100,7 +115,17 @@ export default function Image({ const imgTitle = tooltipTitle != null && String(tooltipTitle).trim() !== '' ? String(tooltipTitle).trim() - : finalAlt || undefined + : (() => { + const a = (finalAlt ?? '').trim() + // Markdown uses `alt="image"` when `![](url)` has no label — not a real caption/tooltip. + return a && a !== 'image' ? a : undefined + })() + const captionLine = (() => { + if (!showAltCaption) return '' + const c = (caption ?? finalAlt ?? '').trim() + if (c && c !== 'image') return c + return '' + })() const openLinkHref = (isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || '' @@ -207,7 +232,11 @@ export default function Image({ notifyLoaded() } - const reserveStyle = wrapperReserveStyle(dim, showErrorState) + const reserveStyle = wrapperReserveStyle( + dim, + showErrorState, + displaySkeleton && !showErrorState + ) const mergedWrapperStyle: CSSProperties | undefined = reserveStyle || wrapperStyleProp ? { ...reserveStyle, ...wrapperStyleProp } @@ -224,19 +253,20 @@ export default function Image({ onClick?.(e) } - const titled = tooltipTitle != null && String(tooltipTitle).trim() !== '' + const hasHoverTip = Boolean(imgTitle) return ( - + + {displaySkeleton && !showErrorState && ( {effectiveBlurHash ? ( @@ -272,7 +302,6 @@ export default function Image({ ref={imgRef} src={imageUrl} alt={finalAlt} - title={imgTitle} referrerPolicy="no-referrer" decoding={effectiveHoldUntilClick ? 'async' : 'sync'} // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. @@ -319,6 +348,10 @@ export default function Image({ ) : null} )} + + {captionLine ? ( + {captionLine} + ) : null} ) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index a43a66e6..a0890c52 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -25,7 +25,7 @@ import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' import { canonicalizeRssArticleUrl } from '@/lib/rss-article' import { cn } from '@/lib/utils' import { Event, kinds } from 'nostr-tools' -import Emoji from '@/components/Emoji' +import Emoji, { EMOJI_IMG_INLINE_CLASS } from '@/components/Emoji' import { ExtendedKind, SPOTIFY_OPEN_URL_REGEX, @@ -3374,6 +3374,7 @@ function parseMarkdownContentMarked( image={{ ...baseImeta, url: src }} alt={label || 'image'} tooltipTitle={imageTip} + showAltCaption={Boolean(label.trim())} className="w-full rounded-lg cursor-zoom-in" classNames={{ wrapper: 'not-prose my-2 block max-w-[400px] mx-auto rounded-lg w-full', @@ -4124,6 +4125,7 @@ function parseMarkdownContentMarked( image={imetaInfoForStandaloneImageUrl(cleaned)} alt={imageToken.text || 'image'} tooltipTitle={markdownTokenTitle(imageToken)} + showAltCaption={Boolean(String(imageToken.text ?? '').trim())} className="w-full rounded-lg cursor-zoom-in my-0" classNames={{ wrapper: 'my-2 block max-w-[400px] mx-auto' }} holdUntilClick={lazyMedia} @@ -4913,7 +4915,7 @@ function parseInlineMarkdownLegacy( emojiLightbox.openLightbox(lbIdx) @@ -4924,7 +4926,9 @@ function parseInlineMarkdownLegacy( } else { const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis) if (native?.emoji) { - parts.push() + parts.push( + + ) } else { parts.push({`:${shortcode}:`}) } @@ -5244,7 +5248,15 @@ export default function MarkdownArticle({ } } + const shortcodesInBody = new Set() + const scRe = new RegExp(EMOJI_SHORT_CODE_REGEX.source, 'g') + let scMatch: RegExpExecArray | null + while ((scMatch = scRe.exec(event.content)) !== null) { + shortcodesInBody.add(scMatch[1].trim().toLowerCase()) + } + for (const em of emojiInfos) { + if (!shortcodesInBody.has(em.shortcode.trim().toLowerCase())) continue const raw = em.url?.trim() if (!raw) continue const cleaned = cleanUrl(raw) @@ -5254,7 +5266,7 @@ export default function MarkdownArticle({ } return images - }, [extractedMedia.images, metadata.image, emojiInfos]) + }, [extractedMedia.images, metadata.image, emojiInfos, event.content]) const lightboxSlides = useMemo( () => allImages.map((img) => lightboxSlideFromImeta(img)), diff --git a/src/components/Note/ReactionEmojiDisplay.tsx b/src/components/Note/ReactionEmojiDisplay.tsx index 176678cf..fdf899d5 100644 --- a/src/components/Note/ReactionEmojiDisplay.tsx +++ b/src/components/Note/ReactionEmojiDisplay.tsx @@ -68,10 +68,10 @@ export default function ReactionEmojiDisplay({ classNames={{ img: variant === 'thread' - ? 'size-3.5 max-h-[1em] w-auto rounded-sm opacity-90' + ? 'size-[calc(0.875rem*4/3)] max-h-[1em] w-auto rounded-sm opacity-90' : variant === 'compact' - ? 'size-4 max-h-[1em] w-auto rounded-sm' - : 'size-7 max-h-[1.5em] w-auto rounded-sm', + ? 'size-[calc(1rem*4/3)] max-h-[1em] w-auto rounded-sm' + : 'size-[calc(1.75rem*4/3)] max-h-[1.5em] w-auto rounded-sm', text: variant === 'thread' ? 'text-sm leading-none' diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx index f3d65733..fbce4a97 100644 --- a/src/components/NoteOptions/EditOrCloneEventDialog.tsx +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -378,6 +378,14 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp const labKind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent?.kind ?? 0 + const labPreviewEmojiTags = useMemo( + () => + !isCreate && sourceEvent?.tags?.length + ? sourceEvent.tags.filter(([n]) => n === 'emoji').map((row) => [...row]) + : [], + [isCreate, sourceEvent] + ) + return ( <> @@ -601,6 +609,8 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp markupMode={isAsciidocMarkupKind(labKind) ? 'asciidoc' : 'markdown'} i18nLanguage={i18n.language} contextEventId={!isCreate && sourceEvent ? sourceEvent.id : null} + previewAuthorPubkey={pubkey ?? null} + previewEmojiTags={labPreviewEmojiTags} draftPersistenceKey={ advancedLabOpen && advancedLabDraftPersistenceKey ? advancedLabDraftPersistenceKey : null } diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 0bc0452e..765d721a 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -32,7 +32,7 @@ import { Event } from 'nostr-tools' import { useMemo, useState } from 'react' import logger from '@/lib/logger' import { useTranslation } from 'react-i18next' -import Emoji from '../Emoji' +import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji' import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' import { type RelayStatus, @@ -234,7 +234,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; ) : myLastEmoji ? ( <> - + {!hideCount && statsLoaded && (
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)} diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 9816d8dd..7e590385 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -12,7 +12,7 @@ import storage from '@/services/local-storage.service' import { TEmoji } from '@/types' import { Event } from 'nostr-tools' import { useMemo, useRef, useState } from 'react' -import Emoji from '../Emoji' +import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji' import Username from '../Username' import logger from '@/lib/logger' @@ -179,7 +179,7 @@ export default function Likes({ event }: { event: Event }) { animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined }} > - +
)}
{pubkeys.size}
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index a3bdffda..48709035 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -3595,6 +3595,7 @@ export default function PostContent({ markupMode={isAsciidocMarkupKind(getDeterminedKind) ? 'asciidoc' : 'markdown'} i18nLanguage={i18n.language} contextEventId={parentEvent?.id ?? null} + previewAuthorPubkey={pubkey ?? null} draftPersistenceKey={advancedLabOpen ? advancedLabPersistenceKey : null} bodyApiRef={advancedLabBodyApiRef} formatToolbar={ diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 59a4bfea..8d5b72ed 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -959,6 +959,8 @@ export default { 'Advanced lab cancel undo': 'Abbrechen und Änderungen verwerfen', 'Advanced lab markup label markdown': 'Markdown', 'Advanced lab markup label asciidoc': 'AsciiDoc', + 'Advanced lab preview': 'Vorschau', + 'Advanced lab preview empty': 'Noch nichts in der Vorschau.', 'Advanced lab markup placeholder markdown': 'Notiztext (Markdown)', 'Advanced lab markup placeholder asciidoc': 'Notiztext (AsciiDoc)', 'Advanced lab tags JSON': 'Kind, Inhalt und Tags (JSON)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b0c89c56..91f57c1a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -960,6 +960,8 @@ export default { 'Advanced lab cancel undo': 'Cancel and Undo Changes', 'Advanced lab markup label markdown': 'Markdown', 'Advanced lab markup label asciidoc': 'AsciiDoc', + 'Advanced lab preview': 'Preview', + 'Advanced lab preview empty': 'Nothing to preview yet.', 'Advanced lab markup placeholder markdown': 'Note body (Markdown)', 'Advanced lab markup placeholder asciidoc': 'Note body (AsciiDoc)', 'Advanced lab tags JSON': 'Kind, content, and tags (JSON)', diff --git a/src/lib/advanced-lab-markup-protect.test.ts b/src/lib/advanced-lab-markup-protect.test.ts new file mode 100644 index 00000000..e84d68ed --- /dev/null +++ b/src/lib/advanced-lab-markup-protect.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getMarkupProtectRanges, + rangeIntersectsMerged, + translateAdvancedLabMarkup +} from '@/lib/advanced-lab-markup-protect' + +vi.mock('@/lib/translate-client', () => ({ + translatePlainText: vi.fn(async (text: string) => `<${text}>`) +})) + +describe('getMarkupProtectRanges', () => { + it('freezes ATX heading marker and following whitespace', () => { + const merged = getMarkupProtectRanges('# Hello', 'markdown') + expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true) + }) + + it('freezes inline math including delimiters', () => { + const t = 'a $\\sqrt{x}$ b' + const merged = getMarkupProtectRanges(t, 'markdown') + const i0 = t.indexOf('$') + const i1 = t.indexOf('$', i0 + 1) + expect(i0).toBeGreaterThanOrEqual(0) + expect(i1).toBeGreaterThan(i0) + expect(rangeIntersectsMerged(i0, i1 - i0 + 1, merged)).toBe(true) + }) + + it('freezes display math', () => { + const t = 'pre $$a+b$$ post' + const merged = getMarkupProtectRanges(t, 'markdown') + const start = t.indexOf('$$') + expect(rangeIntersectsMerged(start, 2, merged)).toBe(true) + }) + + it('respects CRLF when matching line-leading markup', () => { + const t = '# A\r\nplain' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true) + }) + + it('freezes a fenced code block by line scanning', () => { + const t = '```js\nx\n```\n# H' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(merged.some(([a, b]) => a === 0 && b >= t.indexOf('```', 3) + 3)).toBe(true) + const hashLine = t.lastIndexOf('#') + expect(rangeIntersectsMerged(hashLine, 2, merged)).toBe(true) + }) + + it('markdown: freezes each pipe in a GFM-style table row', () => { + const t = '| Cell | Other |' + const merged = getMarkupProtectRanges(t, 'markdown') + const pipes = [...t.matchAll(/\|/g)].map((m) => m.index!) + for (const idx of pipes) { + expect(rangeIntersectsMerged(idx, 1, merged)).toBe(true) + } + }) + + it('markdown: freezes alignment separator line as a whole', () => { + const t = '| --- | :---: |' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(merged.some(([a, b]) => a === 0 && b === t.length)).toBe(true) + }) + + it('markdown: freezes link brackets and URL part, not label', () => { + const t = '[Label](https://a.com)' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(rangeIntersectsMerged(0, 1, merged)).toBe(true) + expect(rangeIntersectsMerged(t.indexOf(']'), 1, merged)).toBe(true) + expect(rangeIntersectsMerged(t.indexOf('L'), 5, merged)).toBe(false) + }) + + it('markdown: link title in quotes is not frozen (only quotes and URL)', () => { + const t = '[L](https://a.com "Link title")' + const merged = getMarkupProtectRanges(t, 'markdown') + const titleBodyStart = t.indexOf('Link title') + expect(rangeIntersectsMerged(titleBodyStart, 'Link title'.length, merged)).toBe(false) + expect(rangeIntersectsMerged(t.indexOf('"'), 1, merged)).toBe(true) + }) + + it('markdown: freezes footnote reference span', () => { + const t = 'See[^1]here' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(rangeIntersectsMerged(3, 4, merged)).toBe(true) + }) + + it('markdown: freezes NIP emoji shortcode span', () => { + const t = 'hi :chad_yes: bye' + const merged = getMarkupProtectRanges(t, 'markdown') + const i = t.indexOf(':chad_yes:') + expect(rangeIntersectsMerged(i, ':chad_yes:'.length, merged)).toBe(true) + }) + + it('markdown: freezes inline code span', () => { + const t = 'a `code` b' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(rangeIntersectsMerged(t.indexOf('`'), 6, merged)).toBe(true) + }) + + it('asciidoc: freezes source block including fences', () => { + const t = '[source,js]\n----\nconst x = 1\n----\n' + const merged = getMarkupProtectRanges(t, 'asciidoc') + expect(merged.some(([a, b]) => a === 0 && b === t.length)).toBe(true) + }) + + it('asciidoc: freezes stem macro span', () => { + const t = 'x stem:[\\alpha] y' + const merged = getMarkupProtectRanges(t, 'asciidoc') + const s = t.indexOf('stem:') + expect(rangeIntersectsMerged(s, 'stem:[\\alpha]'.length, merged)).toBe(true) + }) + + it('freezes wiki double-bracket spans (bookstr, citation, wikis)', () => { + const t = '[[wikis|Nostr]] [[book::genesis]] [[citation::inline::x]]' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(merged.some(([a, b]) => t.slice(a, b) === '[[wikis|Nostr]]')).toBe(true) + expect(merged.some(([a, b]) => t.slice(a, b) === '[[book::genesis]]')).toBe(true) + expect(merged.some(([a, b]) => t.slice(a, b).startsWith('[[citation::'))).toBe(true) + }) + + it('freezes nostr: and bare npub1', () => { + const npub = 'npub1' + 'q'.repeat(58) + const t = `x nostr:${npub} y ${npub} z` + const merged = getMarkupProtectRanges(t, 'markdown') + const atNostr = t.indexOf('nostr:') + expect(rangeIntersectsMerged(atNostr, `nostr:${npub}`.length, merged)).toBe(true) + expect(rangeIntersectsMerged(t.lastIndexOf(npub), npub.length, merged)).toBe(true) + }) + + it('freezes BOOKSTR_MARKER passthrough and WIKILINK marker', () => { + const book = 'BOOKSTR_MARKER:foo:BOOKSTR_END' + const wiki = 'WIKILINK:my-page[My Page]' + const merged = getMarkupProtectRanges(`${book} ${wiki}`, 'markdown') + expect(merged.some(([a, b]) => a === 0 && b === book.length)).toBe(true) + expect(merged.some(([a, b]) => b - a === wiki.length)).toBe(true) + }) + + it('freezes link: and menu: bracket macros in markdown', () => { + const t = 'link:https://x.com[Go] menu:File[Quit]' + const merged = getMarkupProtectRanges(t, 'markdown') + expect(merged.some(([a, b]) => t.slice(a, b) === 'link:https://x.com[Go]')).toBe(true) + expect(merged.some(([a, b]) => t.slice(a, b) === 'menu:File[Quit]')).toBe(true) + }) + + it('asciidoc: freezes section title prefix', () => { + const merged = getMarkupProtectRanges('= Title', 'asciidoc') + expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true) + }) + + it('asciidoc: freezes unordered and ordered list markers', () => { + const star = getMarkupProtectRanges('* one', 'asciidoc') + expect(star.some(([a, b]) => a === 0 && b === 2)).toBe(true) + const dot = getMarkupProtectRanges('.. two', 'asciidoc') + expect(dot.some(([a, b]) => a === 0 && b === 3)).toBe(true) + }) + + it('asciidoc: freezes labeled list marker', () => { + const t = 'CPU:: The brain' + const merged = getMarkupProtectRanges(t, 'asciidoc') + expect(merged.some(([a, b]) => a === 0 && t.slice(a, b) === 'CPU:: ')).toBe(true) + }) + + it('asciidoc: freezes attribute name and following spaces', () => { + const t = ':toc: Table of contents' + const merged = getMarkupProtectRanges(t, 'asciidoc') + expect(merged.some(([a, b]) => a === 0 && t.slice(a, b) === ':toc: ')).toBe(true) + }) + + it('asciidoc: freezes whole-line include macro', () => { + const t = 'include::chapter.adoc[]' + const merged = getMarkupProtectRanges(t, 'asciidoc') + expect(merged.some(([a, b]) => a === 0 && b === t.length)).toBe(true) + }) + + it('asciidoc: delimiter line is fully frozen', () => { + const t = '----' + const merged = getMarkupProtectRanges(t, 'asciidoc') + expect(merged.some(([a, b]) => a === 0 && b === t.length)).toBe(true) + }) +}) + +describe('translateAdvancedLabMarkup', () => { + it('translates heading text but not the # prefix', async () => { + const out = await translateAdvancedLabMarkup('# Title', 'de', 'en', 'markdown') + expect(out).toBe('# ') + }) + + it('does not send math delimiters or body to translate', async () => { + const t = 'x $\\sqrt{y}$ z' + const out = await translateAdvancedLabMarkup(t, 'de', 'en', 'markdown') + expect(out).toContain('$\\sqrt{y}$') + expect(out).toBe('<x >$\\sqrt{y}$< z>') + }) + + it('asciidoc: translates heading text after equals marker', async () => { + const out = await translateAdvancedLabMarkup('= Title', 'de', 'en', 'asciidoc') + expect(out).toBe('= <Title>') + }) + + it('markdown: translates table cell text but not pipes', async () => { + const out = await translateAdvancedLabMarkup('| Cell |', 'ru', 'en', 'markdown') + expect(out).toBe('|< Cell >|') + }) + + it('markdown: leaves separator row unchanged', async () => { + const t = '| --- | --- |\n| a | b |' + const out = await translateAdvancedLabMarkup(t, 'ru', 'en', 'markdown') + expect(out).toContain('| --- | --- |') + expect(out).toMatch(/\|\s*< a >\s*\|/) + }) + + it('markdown: translates link label only', async () => { + const out = await translateAdvancedLabMarkup('[Hi](https://x.com)', 'de', 'en', 'markdown') + expect(out).toBe('[<Hi>](https://x.com)') + }) + + it('markdown: translates optional link title in quotes', async () => { + const out = await translateAdvancedLabMarkup( + '[Hi](https://x.com "Link title")', + 'de', + 'en', + 'markdown' + ) + expect(out).toBe('[<Hi>](https://x.com "<Link title>")') + }) + + it('markdown: does not translate :shortcode: spans', async () => { + const out = await translateAdvancedLabMarkup('Hello :chad_yes: world', 'de', 'en', 'markdown') + expect(out).toBe('<Hello >:chad_yes:< world>') + }) + + it('preserves newlines: translate API is never called with embedded line breaks', async () => { + const { translatePlainText } = await import('@/lib/translate-client') + const spy = vi.mocked(translatePlainText) + spy.mockClear() + spy.mockImplementation(async (s: string) => `<${s}>`) + await translateAdvancedLabMarkup('Line1\nLine2', 'de', 'en', 'markdown') + for (const call of spy.mock.calls) { + expect(String(call[0])).not.toMatch(/\r|\n/) + } + expect(spy.mock.calls.map((c) => c[0])).toEqual(['Line1', 'Line2']) + }) + + it('preserves blank lines between translated lines', async () => { + const { translatePlainText } = await import('@/lib/translate-client') + vi.mocked(translatePlainText).mockImplementation(async (s: string) => (s === 'A' ? 'Aa' : 'Bb')) + const out = await translateAdvancedLabMarkup('A\n\nB', 'de', 'en', 'markdown') + expect(out).toBe('Aa\n\nBb') + }) +}) diff --git a/src/lib/advanced-lab-markup-protect.ts b/src/lib/advanced-lab-markup-protect.ts new file mode 100644 index 00000000..e648d6ad --- /dev/null +++ b/src/lib/advanced-lab-markup-protect.ts @@ -0,0 +1,995 @@ +/** + * Ranges in the lab editor source that must not be grammar-checked or machine-translated. + * Covers Advanced Event Lab toolbar constructs: headings, lists, quotes, tables, code fences, + * math ($ / $$), Markdown links/images (structure only), task items, footnotes, inline code, + * emphasis/strike delimiters, and AsciiDoc blocks, macros, stem, passthrough, xref. + * Also: wiki `[[…]]` (incl. `book::`, `citation::`), `wikilink:`, `BOOKSTR_MARKER:…:BOOKSTR_END`, + * `nostr:…` / bare NIP-19 bech32 (`npub1`…, `nprofile1`…, etc.), and `link:url[text]` macros. + * NIP-style custom/native emoji shortcodes `:shortcode:` (see {@link EMOJI_SHORT_CODE_REGEX}). + */ + +import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' +import { translatePlainText } from '@/lib/translate-client' + +export type AdvancedLabMarkupMode = 'markdown' | 'asciidoc' + +function mergeSortedRanges(ranges: [number, number][]): [number, number][] { + if (ranges.length === 0) return [] + const s = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1]) + const out: [number, number][] = [] + let [cs, ce] = s[0]! + for (let i = 1; i < s.length; i++) { + const [a, b] = s[i]! + if (a <= ce) ce = Math.max(ce, b) + else { + out.push([cs, ce]) + cs = a + ce = b + } + } + out.push([cs, ce]) + return out +} + +function posInMerged(pos: number, merged: [number, number][]): boolean { + return merged.some(([a, b]) => pos >= a && pos < b) +} + +/** Walk logical lines; `lineStart` is index of first char, `raw` has no line ending. */ +function forEachLine( + text: string, + cb: (raw: string, lineStart: number, lineIndex: number) => void +): void { + const lines = text.split(/\r?\n/) + let lineStart = 0 + for (let li = 0; li < lines.length; li++) { + cb(lines[li]!, lineStart, li) + lineStart += lines[li]!.length + if (li < lines.length - 1) { + if (text[lineStart] === '\r' && text[lineStart + 1] === '\n') lineStart += 2 + else lineStart += 1 + } + } +} + +/** Find closing `$` for inline math starting after opening `$` at index `open + 1`. */ +function findClosingInlineDollar(text: string, open: number): number { + let j = open + 1 + while (j < text.length) { + const c = text[j] + if (c === '\\' && j + 1 < text.length) { + j += 2 + continue + } + if (c === '$') return j + j++ + } + return -1 +} + +function collectLatexRanges(text: string): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i < text.length) { + if (text.startsWith('$$', i)) { + const close = text.indexOf('$$', i + 2) + if (close === -1) break + ranges.push([i, close + 2]) + i = close + 2 + continue + } + if (text[i] === '$') { + const close = findClosingInlineDollar(text, i) + if (close < 0) { + i++ + continue + } + ranges.push([i, close + 1]) + i = close + 1 + continue + } + i++ + } + return ranges +} + +function collectFencedCodeRanges(text: string): [number, number][] { + const ranges: [number, number][] = [] + const n = text.length + let i = 0 + while (i < n) { + if (!text.startsWith('```', i)) { + i++ + continue + } + if (i > 0 && text[i - 1] !== '\n' && text[i - 1] !== '\r') { + i++ + continue + } + const firstNl = text.indexOf('\n', i) + const bodyStart = firstNl === -1 ? n : firstNl + 1 + let p = bodyStart + let blockEnd = -1 + while (p <= n) { + const nl = text.indexOf('\n', p) + const lineEnd = nl === -1 ? n : nl + const line = text.slice(p, lineEnd) + if (/^[\t ]{0,3}```(?:\s|$)/.test(line)) { + blockEnd = nl === -1 ? n : nl + 1 + break + } + if (nl === -1) break + p = nl + 1 + } + if (blockEnd < 0) { + i = bodyStart + continue + } + ranges.push([i, blockEnd]) + i = blockEnd + } + return ranges +} + +/** GFM-style column alignment row: pipes, colons, dashes, spaces only, and at least one dash. */ +function looksLikePipeTableSeparatorRow(line: string): boolean { + const t = line.trim() + if (!t.includes('|')) return false + if (!/^[\s|\-:]+$/.test(t)) return false + if (!/-/.test(t)) return false + return true +} + +function collectPipeTableRanges(text: string, mergedBase: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const lines = text.split(/\r?\n/) + const rowFlags = lines.map((rawLine) => { + let pipeCount = 0 + for (let j = 0; j < rawLine.length; j++) { + if (rawLine[j] === '|') pipeCount++ + } + const isSep = looksLikePipeTableSeparatorRow(rawLine) + return isSep || pipeCount >= 2 + }) + + let lineStart = 0 + let prevWasTableRow = false + for (let li = 0; li < lines.length; li++) { + const rawLine = lines[li]! + const isTableRow = rowFlags[li]! + + if (li > 0 && prevWasTableRow && isTableRow) { + let gapStart = lineStart - 1 + if (gapStart >= 1 && text[gapStart] === '\n' && text[gapStart - 1] === '\r') gapStart -= 1 + if (gapStart < lineStart) ranges.push([gapStart, lineStart]) + } + + if (isTableRow) { + const isSep = looksLikePipeTableSeparatorRow(rawLine) + if (isSep) { + const a = lineStart + const b = lineStart + rawLine.length + if (a < b && !posInMerged(a, mergedBase)) ranges.push([a, b]) + } else { + for (let j = 0; j < rawLine.length; j++) { + if (rawLine[j] !== '|') continue + const g = lineStart + j + if (!posInMerged(g, mergedBase)) ranges.push([g, g + 1]) + } + } + } + + prevWasTableRow = isTableRow + lineStart += rawLine.length + if (li < lines.length - 1) { + if (text[lineStart] === '\r' && text[lineStart + 1] === '\n') lineStart += 2 + else lineStart += 1 + } + } + return ranges +} + +function findClosingParenMdUrl(text: string, openParen: number): number { + let i = openParen + let depth = 0 + let inStr: '"' | "'" | null = null + while (i < text.length) { + const c = text[i] + if (inStr) { + if (c === '\\' && i + 1 < text.length) { + i += 2 + continue + } + if (c === inStr) inStr = null + i++ + continue + } + if (c === '"' || c === "'") { + inStr = c + i++ + continue + } + if (c === '(') depth++ + else if (c === ')') { + depth-- + if (depth === 0) return i + 1 + } + i++ + } + return -1 +} + +/** Walk label inside `[...]` starting at `labelStart` (first char inside brackets). */ +function findLabelEndBracket(text: string, labelStart: number): number { + let j = labelStart + while (j < text.length) { + const c = text[j] + if (c === '\\' && j + 1 < text.length) { + j += 2 + continue + } + if (c === ']') return j + j++ + } + return -1 +} + +/** + * Inside `[...](` … `)`, find optional CommonMark title after the destination. + * Returns indices relative to `inner` (slice between `(` and closing `)`). + */ +function parseMdInlineLinkDestForTitle(inner: string): { + /** Freeze [0, titleQuoteIdx) — destination + whitespace before title. */ + titleQuoteIdx: number + /** Freeze [closeQuoteIdx, closeQuoteIdx + 1) — closing quote. */ + closeQuoteIdx: number +} | null { + if (!inner) return null + let hrefEnd = 0 + if (inner[0] === '<') { + let j = 1 + while (j < inner.length) { + if (inner[j] === '\\' && j + 1 < inner.length) { + j += 2 + continue + } + if (inner[j] === '>') { + hrefEnd = j + 1 + break + } + j++ + } + if (hrefEnd === 0) hrefEnd = inner.length + } else { + const m = inner.match(/^\S+/) + if (!m) return null + hrefEnd = m[0].length + } + let p = hrefEnd + while (p < inner.length && /\s/.test(inner[p]!)) p++ + if (p >= inner.length) return null + const q = inner[p] + if (q !== '"' && q !== "'") return null + const bodyStart = p + 1 + let j = bodyStart + while (j < inner.length) { + if (inner[j] === '\\' && j + 1 < inner.length) { + j += 2 + continue + } + if (inner[j] === q) return { titleQuoteIdx: p, closeQuoteIdx: j } + j++ + } + return null +} + +/** Push frozen ranges for `](` … `)`; link title body (between quotes) stays translatable. */ +function pushMarkdownLinkParenStructureRanges( + ranges: [number, number][], + text: string, + lbEnd: number, + parenEnd: number +): void { + const innerStart = lbEnd + 2 + const closeParenIdx = parenEnd - 1 + ranges.push([lbEnd, innerStart]) + const inner = text.slice(innerStart, closeParenIdx) + const titled = parseMdInlineLinkDestForTitle(inner) + if (!titled) { + ranges.push([innerStart, parenEnd]) + return + } + const { titleQuoteIdx, closeQuoteIdx } = titled + ranges.push([innerStart, innerStart + titleQuoteIdx]) + ranges.push([innerStart + titleQuoteIdx, innerStart + titleQuoteIdx + 1]) + ranges.push([innerStart + closeQuoteIdx, innerStart + closeQuoteIdx + 1]) + ranges.push([closeParenIdx, parenEnd]) +} + +/** Markdown / CommonMark `link` and `image` — freeze brackets/URL/title quotes; label + title body translate. */ +function collectMarkdownLinkImageStructureRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i < text.length - 1) { + if (posInMerged(i, merged)) { + i++ + continue + } + const isImg = text.startsWith('![', i) + const isLink = text[i] === '[' && text[i + 1] !== '^' && text[i + 1] !== '[' + if (!isImg && !isLink) { + i++ + continue + } + const labelStart = isImg ? i + 2 : i + 1 + const lbEnd = findLabelEndBracket(text, labelStart) + if (lbEnd < 0 || lbEnd + 1 >= text.length || text[lbEnd + 1] !== '(') { + i++ + continue + } + const parenEnd = findClosingParenMdUrl(text, lbEnd + 1) + if (parenEnd < 0) { + i++ + continue + } + if (isImg) ranges.push([i, i + 2]) + else ranges.push([i, i + 1]) + pushMarkdownLinkParenStructureRanges(ranges, text, lbEnd, parenEnd) + i = parenEnd + } + return ranges +} + +/** `[^id]` reference (not definition line — handled by line prefix). */ +function collectMarkdownFootnoteRefRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i < text.length - 2) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (text[i] !== '[' || text[i + 1] !== '^') { + i++ + continue + } + const j = findLabelEndBracket(text, i + 2) + if (j < 0 || j === i + 2) { + i++ + continue + } + ranges.push([i, j + 1]) + i = j + 1 + } + return ranges +} + +/** Fenced code spans with run of backticks (CommonMark). */ +function collectInlineCodeRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i < text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (text[i] !== '`') { + i++ + continue + } + let n = 0 + let p = i + while (p < text.length && text[p] === '`') { + n++ + p++ + } + let q = p + let found = -1 + while (q < text.length) { + if (text[q] === '`') { + let m = 0 + let r = q + while (r < text.length && text[r] === '`') { + m++ + r++ + } + if (m >= n) { + found = r + break + } + q = r + continue + } + q++ + } + if (found < 0) break + ranges.push([i, found]) + i = found + } + return ranges +} + +/** Freeze only `**` / `__` / `~~` delimiter pairs so inner text still translates (toolbar inline). */ +function collectMarkdownDelimiterPairEdges( + text: string, + merged: [number, number][], + token: string +): [number, number][] { + const ranges: [number, number][] = [] + const tl = token.length + if (tl < 2) return ranges + let i = 0 + while (i <= text.length - tl * 2) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (!text.startsWith(token, i)) { + i++ + continue + } + let j = i + tl + while (j <= text.length - tl) { + if (posInMerged(j, merged)) { + j++ + continue + } + if (text.startsWith(token, j)) { + ranges.push([i, i + tl], [j, j + tl]) + i = j + tl + break + } + j++ + } + if (j > text.length - tl) i++ + } + return ranges +} + +function matchAsciiDocLinePrefix(rawLine: string): RegExpMatchArray | null { + const patterns: RegExp[] = [ + /^[\t ]{0,3}\/\/[^\n]*$/, + /^[\t ]{0,3}(?:-{4,}|\.{4,}|={4,}|\*{4,}|_{4,}|\+{4,}|\/{4,})\s*$/, + /^[\t ]{0,3}\|={3,}\s*$/, + /^[\t ]{0,3}(?:---|\*\*\*|___|''')\s*$/, + /^[\t ]{0,3}--\s*$/, + /^[\t ]{0,3}\+\s*$/, + /^[\t ]{0,3}=+\s+/, + /^[\t ]{0,3}(?:ifdef|ifndef|ifeval|endif)::[^\n]*$/, + /^[\t ]{0,3}(?:include|image|video|audio|xref|link|mailto|pass|kbd|btn|menu|anchor|set|footnote)::[^\n]*$/, + /^[\t ]{0,3}\[\[[^\]]+\]\]\s*/, + /^[\t ]{0,3}(\[[^\]\n]+\]\s*)$/, + /^[\t ]{0,3}(:[!]?[A-Za-z0-9_][\w-]*:)\s*/, + /^[\t ]{0,3}(?!https?:\/\/)(.+?::\s+)/, + /^[\t ]{0,3}\*{1,9}\s+/, + /^[\t ]{0,3}-\s+/, + /^[\t ]{0,3}\.{1,9}\s+/ + ] + for (const re of patterns) { + const m = rawLine.match(re) + if (m) return m + } + return null +} + +function collectLinePrefixRanges( + text: string, + mode: AdvancedLabMarkupMode, + mergedBase: [number, number][] +): [number, number][] { + const ranges: [number, number][] = [] + forEachLine(text, (rawLine, lineStart) => { + if (posInMerged(lineStart, mergedBase)) return + let m: RegExpMatchArray | null = null + if (mode === 'markdown') { + m = + rawLine.match(/^[\t ]{0,3}(#{1,6}\s+)/) || + rawLine.match(/^[\t ]{0,3}(?:[-*+]\s+\[[ xX]\]\s+)/) || + rawLine.match(/^[\t ]{0,3}(?:[-*+]\s+)/) || + rawLine.match(/^[\t ]{0,3}(?:\d{1,9}\.\s+)/) || + rawLine.match(/^[\t ]{0,3}(>\s?)/) || + rawLine.match(/^[\t ]{0,3}(\[\^[^\]\n]+\]:)\s*/) || + rawLine.match(/^[\t ]{0,3}(?:[-*]{3,}|_{3,})\s*$/) || + rawLine.match(/^[\t ]{0,3}=+\s*$/) || + rawLine.match(/^[\t ]{0,3}(?:`{3,}\s*)\S.*$/) + } else { + m = matchAsciiDocLinePrefix(rawLine) + } + if (m && m[0].length > 0) ranges.push([lineStart, lineStart + m[0].length]) + }) + return ranges +} + +const ADOC_FENCE_LINE = /^[\t ]{0,3}(-{4,}|={4,}|\*{4,}|_{4,}|\.{4,}|\+{4,}|--)\s*$/ + +function lineMatchFence(raw: string): string | null { + const t = raw.replace(/\r$/, '') + const m = t.match(ADOC_FENCE_LINE) + return m ? m[1]! : null +} + +/** `[source,...]` or `[NOTE]` / `[TIP]` / … / `[stem]` / `[listing]` / `[example]` (toolbar blocks). */ +function isAdocBlockMetaLine(raw: string): boolean { + const t = raw.trim() + if (!/^\[[^\]]+\]$/.test(t)) return false + return /^\[\s*(source|NOTE|TIP|WARNING|IMPORTANT|CAUTION|stem|listing|example|discrete)/i.test(t) +} + +function collectAsciiDocStructuredBlocks(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const lines = text.split(/\r?\n/) + let offset = 0 + const lineStarts: number[] = [] + for (let li = 0; li < lines.length; li++) { + lineStarts.push(offset) + offset += lines[li]!.length + if (li < lines.length - 1) { + if (text[offset] === '\r' && text[offset + 1] === '\n') offset += 2 + else offset += 1 + } + } + + let li = 0 + while (li < lines.length) { + const raw = lines[li]! + const ls = lineStarts[li]! + if (!posInMerged(ls, merged)) { + const meta = isAdocBlockMetaLine(raw) + let openFenceLine = li + let fenceToken: string | null = null + if (meta) { + let j = li + 1 + while (j < lines.length && /^\s*$/.test(lines[j]!)) j++ + if (j < lines.length) { + const f = lineMatchFence(lines[j]!) + if (f && (f === '----' || f === '====' || f === '++++')) { + openFenceLine = j + fenceToken = f + } + } + } else { + const f = lineMatchFence(raw) + if ( + f && + (f === '----' || + f === '====' || + f === '++++' || + f === '****' || + f === '____' || + f === '....' || + f === '--') + ) { + openFenceLine = li + fenceToken = f + } + } + + if (fenceToken) { + let k = openFenceLine + 1 + while (k < lines.length) { + if (lineMatchFence(lines[k]!) === fenceToken) { + const endLineIdx = k + let endOff = lineStarts[endLineIdx]! + lines[endLineIdx]!.length + if (endLineIdx < lines.length - 1) { + if (text[endOff] === '\r' && text[endOff + 1] === '\n') endOff += 2 + else endOff += 1 + } + const startOff = meta ? ls : lineStarts[openFenceLine]! + ranges.push([startOff, endOff]) + li = endLineIdx + break + } + k++ + } + } + } + li++ + } + return ranges +} + +function bracketDepthClose(text: string, from: number): number { + let d = 0 + for (let p = from; p < text.length; p++) { + const c = text[p] + if (c === '\\' && p + 1 < text.length) { + p++ + continue + } + if (c === '[') d++ + else if (c === ']') { + d-- + if (d === 0) return p + 1 + } + } + return -1 +} + +function isAsciiDocMacroBoundary(text: string, i: number): boolean { + if (i === 0) return true + const prev = text[i - 1]! + return !/[a-zA-Z0-9_]/.test(prev) +} + +/** AsciiDoc `stem:[…]`, `latexmath:[…]`, `footnote:[…]`, `kbd:[]`, `btn:[]`, `image::…[]`, `video::…`, `audio::…` (`link:` / `menu:` handled in {@link collectLinkMenuColonMacros}). */ +function collectAsciiDocInlineMacroRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const needles = [ + 'stem:[', + 'latexmath:[', + 'footnote:[', + 'kbd:[', + 'btn:[', + 'image::', + 'video::', + 'audio::' + ] as const + + let i = 0 + while (i < text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + let hit: (typeof needles)[number] | null = null + for (const n of needles) { + if (text.startsWith(n, i) && isAsciiDocMacroBoundary(text, i)) { + hit = n + break + } + } + if (!hit) { + i++ + continue + } + if (hit === 'image::' || hit === 'video::' || hit === 'audio::') { + const ob = text.indexOf('[', i + hit.length) + if (ob < 0) { + const nl = text.indexOf('\n', i) + ranges.push([i, nl === -1 ? text.length : nl + 1]) + i++ + continue + } + const end = bracketDepthClose(text, ob) + if (end < 0) { + i++ + continue + } + ranges.push([i, end]) + i = end + continue + } + const openBracket = i + hit.length - 1 + if (text[openBracket] !== '[') { + i++ + continue + } + const end = bracketDepthClose(text, openBracket) + if (end < 0) { + i++ + continue + } + ranges.push([i, end]) + i = end + } + return ranges +} + +/** `+++passthrough+++` (toolbar). */ +function collectAsciiDocTriplePlusPassthrough(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i + 3 <= text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (!text.startsWith('+++', i)) { + i++ + continue + } + const close = text.indexOf('+++', i + 3) + if (close < 0) break + ranges.push([i, close + 3]) + i = close + 3 + } + return ranges +} + +/** `[[page]]`, `[[page|label]]`, `[[book::…]]`, `[[citation::…]]`, AsciiDoc `[[id]]`. */ +function collectWikiDoubleBracketRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i + 3 <= text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (!text.startsWith('[[', i)) { + i++ + continue + } + const close = text.indexOf(']]', i + 2) + if (close < 0) break + ranges.push([i, close + 2]) + i = close + 2 + } + return ranges +} + +/** Passthrough markers from article preprocessing (AsciiDoc / Markdown pipelines). */ +function collectBookstrMarkerPassthrough(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const head = 'BOOKSTR_MARKER:' + const tail = ':BOOKSTR_END' + let i = 0 + while (i < text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + const s = text.indexOf(head, i) + if (s < 0) break + const e = text.indexOf(tail, s + head.length) + if (e < 0) { + i = s + 1 + continue + } + if (!posInMerged(s, merged)) ranges.push([s, e + tail.length]) + i = e + tail.length + } + return ranges +} + +/** `wikilink:dtag[label]` (post-processed wiki / bookstr). */ +function collectWikilinkMarkerRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const re = /\bWIKILINK:([^\s[\n]+)(?:\[[^\]]*\])?/g + let m: RegExpExecArray | null + while ((m = re.exec(text))) { + const s = m.index + if (!posInMerged(s, merged)) ranges.push([s, s + m[0].length]) + } + return ranges +} + +/** AsciiDoc / Markdown `link:url[text]` and `menu:…[…]` (toolbar + jumble). */ +function collectLinkMenuColonMacros(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const prefixes = ['link:', 'menu:'] as const + let i = 0 + while (i < text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + let hit: (typeof prefixes)[number] | null = null + for (const p of prefixes) { + if (text.startsWith(p, i) && isAsciiDocMacroBoundary(text, i)) { + hit = p + break + } + } + if (!hit) { + i++ + continue + } + const ob = text.indexOf('[', i + hit.length) + if (ob < 0) { + i++ + continue + } + const end = bracketDepthClose(text, ob) + if (end < 0) { + i++ + continue + } + ranges.push([i, end]) + i = end + } + return ranges +} + +const B32 = '[ac-hj-np-z02-9]' +const RE_NOSTR_PREFIX = new RegExp( + `^nostr:(npub1${B32}{58}|nprofile1${B32}{20,}|note1${B32}{20,}|nevent1${B32}{20,}|naddr1${B32}{20,}|nrelay1${B32}{20,})(?!${B32})`, + 'i' +) +const RE_NPUB_BARE = new RegExp(`^npub1${B32}{58}(?!${B32})`, 'i') +const RE_BARE_OTHER = new RegExp( + `^((?:nprofile|note|nevent|naddr|nrelay)1${B32}{20,})(?!${B32})`, + 'i' +) + +/** `nostr:npub1…` / `nostr:nevent1…` and bare NIP-19 bech32 entities. */ +function collectNostrAndBech32Ranges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i < text.length) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (i > 0 && /[a-zA-Z0-9_]/.test(text[i - 1]!)) { + i++ + continue + } + const slice = text.slice(i) + const prefixed = slice.match(RE_NOSTR_PREFIX) + if (prefixed) { + ranges.push([i, i + prefixed[0].length]) + i += prefixed[0].length + continue + } + const npub = slice.match(RE_NPUB_BARE) + if (npub) { + ranges.push([i, i + npub[0].length]) + i += npub[0].length + continue + } + const other = slice.match(RE_BARE_OTHER) + if (other) { + ranges.push([i, i + other[0].length]) + i += other[0].length + continue + } + i++ + } + return ranges +} + +/** AsciiDoc `<<id,label>>` / `<<id>>`. */ +function collectAsciiDocXrefRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + let i = 0 + while (i < text.length - 3) { + if (posInMerged(i, merged)) { + i++ + continue + } + if (!text.startsWith('<<', i)) { + i++ + continue + } + const close = text.indexOf('>>', i + 2) + if (close < 0) break + ranges.push([i, close + 2]) + i = close + 2 + } + return ranges +} + +/** `:shortcode:` spans (custom / native emoji); skipped when already inside code, links, etc. */ +function collectEmojiShortcodeRanges(text: string, merged: [number, number][]): [number, number][] { + const ranges: [number, number][] = [] + const re = new RegExp(EMOJI_SHORT_CODE_REGEX.source, 'gu') + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + const start = m.index + if (posInMerged(start, merged)) continue + ranges.push([start, start + m[0].length]) + } + return ranges +} + +/** + * Merged half-open ranges `[start,end)` in `text` that must be left unchanged for LT / translate. + */ +export function getMarkupProtectRanges(text: string, mode: AdvancedLabMarkupMode): [number, number][] { + const latex = collectLatexRanges(text) + const fenced = collectFencedCodeRanges(text) + let merged = mergeSortedRanges([...latex, ...fenced]) + + const wiki = collectWikiDoubleBracketRanges(text, merged) + merged = mergeSortedRanges([...merged, ...wiki]) + const bookstrPass = collectBookstrMarkerPassthrough(text, merged) + const wikilinkM = collectWikilinkMarkerRanges(text, merged) + merged = mergeSortedRanges([...merged, ...bookstrPass, ...wikilinkM]) + const linkMenu = collectLinkMenuColonMacros(text, merged) + merged = mergeSortedRanges([...merged, ...linkMenu]) + const nostrBech = collectNostrAndBech32Ranges(text, merged) + merged = mergeSortedRanges([...merged, ...nostrBech]) + const triplePlus = collectAsciiDocTriplePlusPassthrough(text, merged) + merged = mergeSortedRanges([...merged, ...triplePlus]) + + if (mode === 'markdown') { + const code = collectInlineCodeRanges(text, merged) + merged = mergeSortedRanges([...merged, ...code]) + const links = collectMarkdownLinkImageStructureRanges(text, merged) + merged = mergeSortedRanges([...merged, ...links]) + const fn = collectMarkdownFootnoteRefRanges(text, merged) + merged = mergeSortedRanges([...merged, ...fn]) + const strike = collectMarkdownDelimiterPairEdges(text, merged, '~~') + const bold = collectMarkdownDelimiterPairEdges(text, mergeSortedRanges([...merged, ...strike]), '**') + const boldU = collectMarkdownDelimiterPairEdges(text, mergeSortedRanges([...merged, ...bold]), '__') + merged = mergeSortedRanges([...merged, ...strike, ...bold, ...boldU]) + } else { + const adocBlocks = collectAsciiDocStructuredBlocks(text, merged) + merged = mergeSortedRanges([...merged, ...adocBlocks]) + const code = collectInlineCodeRanges(text, merged) + merged = mergeSortedRanges([...merged, ...code]) + const macros = collectAsciiDocInlineMacroRanges(text, merged) + merged = mergeSortedRanges([...merged, ...macros]) + const xref = collectAsciiDocXrefRanges(text, merged) + merged = mergeSortedRanges([...merged, ...xref]) + } + + const prefixes = collectLinePrefixRanges(text, mode, merged) + merged = mergeSortedRanges([...merged, ...prefixes]) + const pipes = collectPipeTableRanges(text, merged) + merged = mergeSortedRanges([...merged, ...pipes]) + const emoji = collectEmojiShortcodeRanges(text, merged) + return mergeSortedRanges([...merged, ...emoji]) +} + +export function rangeIntersectsMerged(pos: number, len: number, merged: [number, number][]): boolean { + const a = pos + const b = pos + len + for (const [rs, re] of merged) { + if (a < re && b > rs) return true + } + return false +} + +type Seg = { translatable: boolean; start: number; end: number } + +function buildSegments(text: string, merged: [number, number][]): Seg[] { + const segs: Seg[] = [] + let cursor = 0 + for (const [fs, fe] of merged) { + if (cursor < fs) segs.push({ translatable: true, start: cursor, end: fs }) + if (fs < fe) segs.push({ translatable: false, start: fs, end: fe }) + cursor = Math.max(cursor, fe) + } + if (cursor < text.length) segs.push({ translatable: true, start: cursor, end: text.length }) + return segs +} + +/** + * LibreTranslate often drops or normalizes newlines in `q`, which breaks Markdown/AsciiDoc. + * Split on line breaks (keep CRLF/LF as literal pieces) and only send non-whitespace text lines + * to the API; preserve newline runs and whitespace-only spans unchanged. + */ +async function translatePreservingLineBreaks( + text: string, + targetLang: string, + sourceLang: string +): Promise<string> { + const pieces = text.split(/(\r?\n+)/) + const out: string[] = [] + for (const p of pieces) { + if (p === '') continue + if (/^\r?\n+$/.test(p)) { + out.push(p) + continue + } + if (/^\s+$/.test(p)) { + out.push(p) + continue + } + out.push(await translatePlainText(p, targetLang, sourceLang)) + } + return out.join('') +} + +export async function translateAdvancedLabMarkup( + text: string, + targetLang: string, + sourceLang: string, + mode: AdvancedLabMarkupMode +): Promise<string> { + const merged = getMarkupProtectRanges(text, mode) + if (merged.length === 0) { + return translatePreservingLineBreaks(text, targetLang, sourceLang) + } + const segs = buildSegments(text, merged) + const parts = await Promise.all( + segs.map(async (s) => { + const chunk = text.slice(s.start, s.end) + if (!s.translatable) return chunk + if (!chunk) return chunk + return translatePreservingLineBreaks(chunk, targetLang, sourceLang) + }) + ) + return parts.join('') +} diff --git a/src/lib/languagetool-cm-linter.ts b/src/lib/languagetool-cm-linter.ts index d74680fe..e735365a 100644 --- a/src/lib/languagetool-cm-linter.ts +++ b/src/lib/languagetool-cm-linter.ts @@ -1,6 +1,11 @@ import { linter, type Diagnostic } from '@codemirror/lint' import { Annotation, EditorSelection, type Extension } from '@codemirror/state' import type { EditorView } from '@codemirror/view' +import { + getMarkupProtectRanges, + rangeIntersectsMerged, + type AdvancedLabMarkupMode +} from '@/lib/advanced-lab-markup-protect' import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client' /** Local LanguageTool is slow on cold JVM; keep payloads bounded (LT has ~20–30k limits anyway). */ @@ -76,8 +81,13 @@ function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | n * Async grammar/style lint for CodeMirror using LanguageTool `/v2/check`. * Per-editor state (debounce / abort) lives in the closure so promises always settle and stale fetches are cancelled. * `getLanguage` is read on each lint pass so the UI can change language without remounting the editor. + * `getMarkupMode` selects which line-prefix syntax is treated as non-checkable markup. */ -export function languageToolLintExtension(getLanguage: () => string, debounceMs: number): Extension { +export function languageToolLintExtension( + getLanguage: () => string, + debounceMs: number, + getMarkupMode: () => AdvancedLabMarkupMode +): Extension { let requestSeq = 0 let inFlight: AbortController | null = null @@ -105,6 +115,8 @@ export function languageToolLintExtension(getLanguage: () => string, debounceMs: const ac = new AbortController() inFlight = ac + const protectMerged = getMarkupProtectRanges(toSend, getMarkupMode()) + void languageToolCheck(toSend, getLanguage(), ac.signal) .then((res) => { if (seq !== requestSeq) { @@ -114,6 +126,7 @@ export function languageToolLintExtension(getLanguage: () => string, debounceMs: const docLen = view.state.doc.length const out: Diagnostic[] = [] for (const m of res.matches ?? []) { + if (rangeIntersectsMerged(m.offset, m.length, protectMerged)) continue const d = matchToDiagnostic(docLen, m) if (d) out.push(d) } diff --git a/src/lib/languagetool-language-order.test.ts b/src/lib/languagetool-language-order.test.ts index 837dff8a..8606c87e 100644 --- a/src/lib/languagetool-language-order.test.ts +++ b/src/lib/languagetool-language-order.test.ts @@ -1,5 +1,24 @@ import { describe, expect, it } from 'vitest' -import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' +import { + buildLanguageToolPreferenceList, + pickLanguageToolCodeForTranslateTarget, + translateTargetToLanguageToolCode +} from '@/lib/languagetool-language-order' + +describe('translateTargetToLanguageToolCode', () => { + it('maps ISO codes to LT variants', () => { + expect(translateTargetToLanguageToolCode('ja')).toBe('ja-JP') + expect(translateTargetToLanguageToolCode('de')).toBe('de-DE') + expect(translateTargetToLanguageToolCode('zh-Hans')).toBe('zh-CN') + }) +}) + +describe('pickLanguageToolCodeForTranslateTarget', () => { + it('returns mapped code when it appears in ltList', () => { + const lt = buildLanguageToolPreferenceList('en') + expect(pickLanguageToolCodeForTranslateTarget('ja', lt)).toBe('ja-JP') + }) +}) describe('buildLanguageToolPreferenceList', () => { it('puts client language first then en-US then de-DE', () => { diff --git a/src/lib/languagetool-language-order.ts b/src/lib/languagetool-language-order.ts index 94691d20..c4c74f17 100644 --- a/src/lib/languagetool-language-order.ts +++ b/src/lib/languagetool-language-order.ts @@ -1,3 +1,5 @@ +import { normalizeTranslateLangCode } from '@/lib/translate-client' + /** * Build LanguageTool `language` codes with UI language first, then English, then German, then others. * @see https://api.languagetool.org/v2/languages @@ -114,6 +116,32 @@ function mapI18nToLt(i18nLanguage: string): string { return 'en-US' } +/** + * Map a LibreTranslate / lab target language code to a LanguageTool `language` value + * (after {@link normalizeTranslateLangCode}). + */ +export function translateTargetToLanguageToolCode(translateCode: string): string { + const n = normalizeTranslateLangCode(translateCode).toLowerCase().replace(/_/gu, '-') + if (LT_ALIASES[n]) return LT_ALIASES[n]! + const base = n.split(/-/u)[0] ?? n + if (LT_ALIASES[base]) return LT_ALIASES[base]! + return 'en-US' +} + +/** Prefer a code present in `ltList` (lab grammar dropdown) when possible. */ +export function pickLanguageToolCodeForTranslateTarget( + translateCode: string, + ltList: readonly string[] +): string { + const mapped = translateTargetToLanguageToolCode(translateCode) + if (ltList.includes(mapped)) return mapped + const base = mapped.split(/-/u)[0]?.toLowerCase() ?? '' + const hit = ltList.find( + (c) => c.toLowerCase() === mapped.toLowerCase() || c.toLowerCase().startsWith(`${base}-`) + ) + return hit ?? ltList[0] ?? mapped +} + export function buildLanguageToolPreferenceList(i18nLanguage: string | undefined): string[] { const primary = mapI18nToLt((i18nLanguage ?? 'en').trim() || 'en') const ordered: string[] = []