Browse Source

add preview pane to advanced editor

imwald
Silberengel 2 weeks ago
parent
commit
1d8be220b1
  1. 126
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 55
      src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx
  3. 7
      src/components/ContentPreview/Content.tsx
  4. 16
      src/components/Emoji/index.tsx
  5. 67
      src/components/Image/index.tsx
  6. 20
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  7. 6
      src/components/Note/ReactionEmojiDisplay.tsx
  8. 10
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  9. 4
      src/components/NoteStats/LikeButton.tsx
  10. 4
      src/components/NoteStats/Likes.tsx
  11. 1
      src/components/PostEditor/PostContent.tsx
  12. 2
      src/i18n/locales/de.ts
  13. 2
      src/i18n/locales/en.ts
  14. 249
      src/lib/advanced-lab-markup-protect.test.ts
  15. 995
      src/lib/advanced-lab-markup-protect.ts
  16. 15
      src/lib/languagetool-cm-linter.ts
  17. 21
      src/lib/languagetool-language-order.test.ts
  18. 28
      src/lib/languagetool-language-order.ts

126
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -17,12 +17,15 @@ import { @@ -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 @@ -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 = { @@ -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({ @@ -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({ @@ -138,11 +150,45 @@ export default function AdvancedEventLabDialog({
const sliceRef = useRef<AdvancedEventLabSlice | null>(null)
const draftPersistenceKeyRef = useRef<string | null>(null)
const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previewDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, string[]>()
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -606,18 +681,33 @@ export default function AdvancedEventLabDialog({
<AdvancedEventLabMarkupToolbar markupMode={markupMode} viewRef={markupView} sliceRef={sliceRef} />
<div className="flex-1 min-h-0 flex flex-col gap-1 px-4 py-2 overflow-hidden">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t(
markupMode === 'asciidoc'
? 'Advanced lab markup label asciidoc'
: 'Advanced lab markup label markdown'
)}
</span>
<div
ref={markupHost}
className="flex-1 min-h-[min(50dvh,36rem)] border rounded-md overflow-hidden bg-muted/20"
/>
<div className="flex-1 min-h-0 flex flex-col gap-3 px-4 py-2 overflow-hidden lg:flex-row lg:gap-0">
<div className="flex flex-1 min-h-0 min-w-0 flex-col gap-1 lg:pr-3">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t(
markupMode === 'asciidoc'
? 'Advanced lab markup label asciidoc'
: 'Advanced lab markup label markdown'
)}
</span>
<div
ref={markupHost}
className="flex-1 min-h-[min(28dvh,14rem)] lg:min-h-[min(42dvh,24rem)] border rounded-md overflow-hidden bg-muted/20"
/>
</div>
<div className="flex flex-1 min-h-0 min-w-0 flex-col gap-1 border-t border-border pt-3 lg:flex-[0_1_42%] lg:max-w-[min(50%,40rem)] lg:border-l lg:border-t-0 lg:pl-3 lg:pt-0">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t('Advanced lab preview')}
</span>
<div className="flex-1 min-h-[min(24dvh,12rem)] lg:min-h-0 overflow-y-auto rounded-md border bg-muted/10 px-2 py-2">
<AdvancedEventLabPreviewPane
markupMode={markupMode}
source={previewDoc}
previewAuthorPubkey={previewAuthorPubkey}
previewEmojiTags={mergedLabPreviewEmojiTags}
/>
</div>
</div>
</div>
{formatToolbar ? (

55
src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx

@ -0,0 +1,55 @@ @@ -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 (
<p className="text-sm text-muted-foreground px-1 py-2">{t('Advanced lab preview empty')}</p>
)
}
return (
<Card className="border-0 bg-transparent p-0 shadow-none">
<div className="select-text max-w-none text-sm">
{markupMode === 'asciidoc' ? (
<AsciidocArticle event={fakeEvent} hideImagesAndInfo={false} />
) : (
<MarkdownArticle event={fakeEvent} hideMetadata lazyMedia={false} />
)}
</div>
</Card>
)
})

7
src/components/ContentPreview/Content.tsx

@ -7,7 +7,7 @@ import { useMemo } from 'react' @@ -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({ @@ -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 <Emoji key={index} emoji={emoji} classNames={{ img: 'size-4' }} />
if (emoji) return <Emoji key={index} emoji={emoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) return <Emoji key={index} emoji={native.emoji} classNames={{ img: 'size-4' }} />
if (native?.emoji)
return <Emoji key={index} emoji={native.emoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
return node.data
}
return node.data

16
src/components/Emoji/index.tsx

@ -3,6 +3,11 @@ import { TEmoji } from '@/types' @@ -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({ @@ -20,11 +25,15 @@ export default function Emoji({
if (typeof emoji === 'string') {
if (emoji === '+') {
return <Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} />
return <Heart className={cn(EMOJI_IMG_DEFAULT_CLASS, 'text-red-400 fill-red-400', classNames?.img)} />
}
if (emoji === '-') {
return (
<ThumbsDown className={cn('size-5 text-muted-foreground', classNames?.img)} strokeWidth={2} aria-hidden />
<ThumbsDown
className={cn(EMOJI_IMG_DEFAULT_CLASS, 'text-muted-foreground', classNames?.img)}
strokeWidth={2}
aria-hidden
/>
)
}
return <span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
@ -42,7 +51,8 @@ export default function 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
)}

67
src/components/Image/index.tsx

@ -21,16 +21,25 @@ import { useTranslation } from 'react-i18next' @@ -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 `<img>` 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({ @@ -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 `<img>`. */
tooltipTitle,
...props
@ -62,6 +73,10 @@ export default function Image({ @@ -62,6 +73,10 @@ export default function Image({
alt?: string
/** Shown as the `<img title>` 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 `<img>` (e.g. profile banner vs avatar load order). */
@ -100,7 +115,17 @@ export default function Image({ @@ -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({ @@ -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({ @@ -224,19 +253,20 @@ export default function Image({
onClick?.(e)
}
const titled = tooltipTitle != null && String(tooltipTitle).trim() !== ''
const hasHoverTip = Boolean(imgTitle)
return (
<span
className={cn(
'relative overflow-hidden block w-full',
classNames.wrapper,
titled && 'cursor-help rounded-lg ring-1 ring-inset ring-dotted ring-muted-foreground/45'
)}
style={mergedWrapperStyle}
onClick={handleWrapperClick}
{...props}
>
<span className={cn('block w-full not-prose', classNames.wrapper)}>
<span
className={cn(
'relative overflow-hidden block w-full rounded-lg bg-background',
hasHoverTip && 'cursor-help ring-1 ring-inset ring-dotted ring-muted-foreground/45'
)}
style={mergedWrapperStyle}
title={imgTitle}
onClick={handleWrapperClick}
{...props}
>
{displaySkeleton && !showErrorState && (
<span className="absolute inset-0 z-10 block rounded-lg bg-muted/40">
{effectiveBlurHash ? (
@ -272,7 +302,6 @@ export default function Image({ @@ -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({ @@ -319,6 +348,10 @@ export default function Image({
) : null}
</span>
)}
</span>
{captionLine ? (
<span className="mt-1 block px-1 text-center text-xs leading-snug text-muted-foreground">{captionLine}</span>
) : null}
</span>
)
}

20
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -25,7 +25,7 @@ import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' @@ -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( @@ -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( @@ -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( @@ -4913,7 +4915,7 @@ function parseInlineMarkdownLegacy(
<Emoji
key={`${keyPrefix}-emoji-${i}`}
emoji={custom}
classNames={{ img: 'size-4 inline-block' }}
classNames={{ img: `${EMOJI_IMG_INLINE_CLASS} inline-block` }}
onImageClick={
typeof lbIdx === 'number' && emojiLightbox
? () => emojiLightbox.openLightbox(lbIdx)
@ -4924,7 +4926,9 @@ function parseInlineMarkdownLegacy( @@ -4924,7 +4926,9 @@ function parseInlineMarkdownLegacy(
} else {
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) {
parts.push(<Emoji key={`${keyPrefix}-emoji-${i}`} emoji={native.emoji} classNames={{ img: 'size-4' }} />)
parts.push(
<Emoji key={`${keyPrefix}-emoji-${i}`} emoji={native.emoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
)
} else {
parts.push(<span key={`${keyPrefix}-emoji-${i}`}>{`:${shortcode}:`}</span>)
}
@ -5244,7 +5248,15 @@ export default function MarkdownArticle({ @@ -5244,7 +5248,15 @@ export default function MarkdownArticle({
}
}
const shortcodesInBody = new Set<string>()
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({ @@ -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)),

6
src/components/Note/ReactionEmojiDisplay.tsx

@ -68,10 +68,10 @@ export default function ReactionEmojiDisplay({ @@ -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'

10
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -378,6 +378,14 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
@ -601,6 +609,8 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -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
}

4
src/components/NoteStats/LikeButton.tsx

@ -32,7 +32,7 @@ import { Event } from 'nostr-tools' @@ -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; @@ -234,7 +234,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji ? (
<>
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: 'size-4' }} />
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
{!hideCount && statsLoaded && (
<div className="text-sm tabular-nums">
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}

4
src/components/NoteStats/Likes.tsx

@ -12,7 +12,7 @@ import storage from '@/services/local-storage.service' @@ -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 }) { @@ -179,7 +179,7 @@ export default function Likes({ event }: { event: Event }) {
animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined
}}
>
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
<Emoji emoji={emoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
</div>
)}
<div className="text-sm">{pubkeys.size}</div>

1
src/components/PostEditor/PostContent.tsx

@ -3595,6 +3595,7 @@ export default function PostContent({ @@ -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={

2
src/i18n/locales/de.ts

@ -959,6 +959,8 @@ export default { @@ -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)',

2
src/i18n/locales/en.ts

@ -960,6 +960,8 @@ export default { @@ -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)',

249
src/lib/advanced-lab-markup-protect.test.ts

@ -0,0 +1,249 @@ @@ -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('# <Title>')
})
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')
})
})

995
src/lib/advanced-lab-markup-protect.ts

@ -0,0 +1,995 @@ @@ -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('')
}

15
src/lib/languagetool-cm-linter.ts

@ -1,6 +1,11 @@ @@ -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 @@ -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: @@ -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: @@ -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)
}

21
src/lib/languagetool-language-order.test.ts

@ -1,5 +1,24 @@ @@ -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', () => {

28
src/lib/languagetool-language-order.ts

@ -1,3 +1,5 @@ @@ -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 { @@ -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[] = []

Loading…
Cancel
Save