Phase 21: Petal learns to be an English+Portuguese pair
The plan said "Hunspell pt-PT vendored like en-US". Measuring that first is what saved it: nspell expands affixes eagerly on construction, and European Portuguese's 1,340 rules over 44,257 stems want over a gigabyte of browser heap — ~340 MB for the first 12,000 entries, and no return at all after three minutes on the whole file. So the expansion runs once at build time instead: 1,039,058 forms, 2.66 MB gzipped, read by the same nspell in 842 ms. The obvious npm package would also have shipped the wrong language. Both dictionary-pt and dictionary-pt-br carry VERO, the Brazilian word list, so vendoring by name puts pt-BR spellings behind a pt-PT label — the drift SUGGESTIONS §3 warns about, arriving through the packaging where no reviewer can see it. The source is Projecto Natura's, and the build script now asserts the fault lines (receção in, recepção out) before writing anything. Spellcheck consults both dictionaries and flags only what both reject, which is the no-detector answer to a pair with no script boundary. The word card does the same in the other direction: "data" is a word in both languages, so Petal shows both readings rather than guessing which she meant. Writing the tests caught the one real bug — extendedAlphabet was a snapshot while correct/suggest read live, and her dictionary arrives after English, so every lookup would have resolved "cora" while the underlines were already right. Not done, and not claimed: the pack has not been read by a pt-PT speaker, and the Piper voice is deferred with the deploy. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
European Portuguese spelling dictionary
|
||||
=======================================
|
||||
|
||||
The word list in `pt-PT.dic.gz` and the suggestion directives in `pt-PT.aff` are
|
||||
derived from the LibreOffice/Projecto Natura Hunspell dictionary for European
|
||||
Portuguese (`pt_PT.aff` / `pt_PT.dic`), as packaged by Debian/Ubuntu in
|
||||
`hunspell-pt-pt`.
|
||||
|
||||
Copyright (C) 2006-2012 José João de Almeida <jj@di.uminho.pt>
|
||||
Rui Vilela <ruivilela@di.uminho.pt>
|
||||
Alberto Simões <ambs@di.uminho.pt>
|
||||
Universidade do Minho — Projecto Natura
|
||||
|
||||
License: GPL-2 or LGPL-2.1 or MPL-1.1
|
||||
(Petal redistributes it under the MPL-1.1 option.)
|
||||
|
||||
Upstream: https://natura.di.uminho.pt/ — via
|
||||
https://git.libreoffice.org/dictionaries/+/refs/heads/master/pt_PT
|
||||
|
||||
What Petal changed
|
||||
------------------
|
||||
|
||||
Nothing about which words are correct. `scripts/build_ptpt_dictionary.py`
|
||||
applies the upstream affix rules ahead of time — Hunspell's PFX/SFX expansion
|
||||
run once at build time instead of once per browser — and writes the resulting
|
||||
1,039,058 surface forms as a flat word list. The shipped `.aff` keeps only
|
||||
upstream's TRY/KEY/REP/MAP/WORDCHARS lines, which shape *corrections* rather
|
||||
than membership. See that script's header for why the dictionary could not be
|
||||
vendored in its original form.
|
||||
|
||||
Note that npm's `dictionary-pt` is *not* this dictionary: both it and
|
||||
`dictionary-pt-br` package the Brazilian VERO word list.
|
||||
@@ -0,0 +1,42 @@
|
||||
SET UTF-8
|
||||
TRY aerisontcdmlupvgbfzáhçqjíxãóéêâúõACMPSBTELGRIFVDkHJONôywUKXZWQÁYÍÉàÓèÂÚ
|
||||
KEY qwertyuiop|asdfghjkl|zxcvbnm
|
||||
WORDCHARS -
|
||||
REP 25
|
||||
REP por pro
|
||||
REP pre per
|
||||
REP damente mente
|
||||
REP mente damente
|
||||
REP iz íz
|
||||
REP cao ção
|
||||
REP ç ss
|
||||
REP ss ç
|
||||
REP c ss
|
||||
REP ss c
|
||||
REP ch x
|
||||
REP x ch
|
||||
REP cç x
|
||||
REP x cç
|
||||
REP k qu
|
||||
REP íti ití
|
||||
REP ití íti
|
||||
REP issí íssi
|
||||
REP ilí íli
|
||||
REP íli ilí
|
||||
REP ífi ifí
|
||||
REP ifí ífi
|
||||
REP nume mune
|
||||
REP coen quen
|
||||
REP concerteza com_certeza
|
||||
MAP 11
|
||||
MAP aá
|
||||
MAP aã
|
||||
MAP aâ
|
||||
MAP eé
|
||||
MAP eê
|
||||
MAP ií
|
||||
MAP cç
|
||||
MAP oó
|
||||
MAP oô
|
||||
MAP oõ
|
||||
MAP uú
|
||||
Binary file not shown.
@@ -78,12 +78,27 @@ export interface WordInfo {
|
||||
frequency: number
|
||||
difficulty: number
|
||||
etymology: string // free-form, already trimmed to a line by the server; '' when absent
|
||||
// The same token read as a word of the writer's own language, when it is one.
|
||||
// Absent for a zh-pair writer and for almost every word in a Latin pair — see
|
||||
// lexicon.Reverse for why Petal asks both directions instead of guessing.
|
||||
reverse?: WordReverse
|
||||
}
|
||||
|
||||
// A word looked up in the other direction: the writer's language -> English.
|
||||
export interface WordReverse {
|
||||
lang: string
|
||||
gloss: string
|
||||
definitions?: WordMeaning[]
|
||||
phonetic?: string
|
||||
}
|
||||
|
||||
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
||||
export interface Gloss {
|
||||
word: string
|
||||
gloss: string
|
||||
// The English meaning of the token read as a word of her own language.
|
||||
// Present only on a collision (Portuguese *sale*, French *chat*).
|
||||
reverse?: string
|
||||
}
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
|
||||
|
||||
@@ -32,6 +32,7 @@ import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
||||
import { speak, speechSupported } from '../../audio/speech'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
export interface EditorChange {
|
||||
content: string // Tiptap JSON, stringified
|
||||
@@ -176,6 +177,8 @@ interface HoverState {
|
||||
interface GlossState {
|
||||
word: string
|
||||
gloss: string
|
||||
// The other reading, when the token is a word in her language too.
|
||||
reverse?: string
|
||||
from: number
|
||||
to: number
|
||||
top: number
|
||||
@@ -222,6 +225,9 @@ export function EditorCore({
|
||||
spellChecker,
|
||||
onAddWord,
|
||||
}: Props) {
|
||||
// Her pair's copy — the hover tip labels the second reading with the language's
|
||||
// own name, so it says "português" rather than "pt-PT".
|
||||
const pack = usePack()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<HoverState | null>(null)
|
||||
// The open spelling popover (click a red-underlined word), or null.
|
||||
@@ -368,6 +374,13 @@ export function EditorCore({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, editor])
|
||||
|
||||
// Which letters count as part of a word. The spell checker owns the answer,
|
||||
// because it depends on which dictionaries this writer's pair loaded — see
|
||||
// SpellCheck's wordRe. Every surface that resolves "the word under here"
|
||||
// (lookup, gloss, right-click) has to agree with the underline, or the
|
||||
// popover would offer a definition of "cora".
|
||||
const wordAlphabet = spellChecker?.extendedAlphabet ?? false
|
||||
|
||||
// Push the spell checker into its decoration plugin once the dictionary loads
|
||||
// (and again whenever the personal dictionary changes its identity).
|
||||
useEffect(() => {
|
||||
@@ -590,7 +603,7 @@ export function EditorCore({
|
||||
const openMisspellAt = useCallback(
|
||||
(pos: number): boolean => {
|
||||
if (!editor || !spellChecker) return false
|
||||
const range = wordAt(editor.state.doc, pos)
|
||||
const range = wordAt(editor.state.doc, pos, spellChecker.extendedAlphabet)
|
||||
if (!range || spellChecker.correct(range.word)) return false
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return false
|
||||
@@ -679,7 +692,7 @@ export function EditorCore({
|
||||
const openWordLookup = useCallback(
|
||||
(pos: number) => {
|
||||
if (!editor) return
|
||||
const range = wordAt(editor.state.doc, pos)
|
||||
const range = wordAt(editor.state.doc, pos, wordAlphabet)
|
||||
if (!range) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
@@ -784,13 +797,13 @@ export function EditorCore({
|
||||
if (!editor) return
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) return
|
||||
if (!wordAt(editor.state.doc, coords.pos)) return
|
||||
if (!wordAt(editor.state.doc, coords.pos, wordAlphabet)) return
|
||||
e.preventDefault()
|
||||
// A misspelled word offers corrections first; otherwise look it up.
|
||||
if (openMisspellAt(coords.pos)) return
|
||||
openWordLookup(coords.pos)
|
||||
},
|
||||
[editor, openMisspellAt, openWordLookup],
|
||||
[editor, wordAlphabet, openMisspellAt, openWordLookup],
|
||||
)
|
||||
|
||||
// Touch has no hover or right-click, so a long-press (~500ms without moving)
|
||||
@@ -844,7 +857,7 @@ export function EditorCore({
|
||||
clear()
|
||||
return
|
||||
}
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
const range = wordAt(editor.state.doc, coords.pos, wordAlphabet)
|
||||
if (!range) {
|
||||
clear()
|
||||
return
|
||||
@@ -859,7 +872,9 @@ export function EditorCore({
|
||||
.then((g) => {
|
||||
if (token !== glossReqRef.current) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!g.gloss || !wrapper) {
|
||||
// A token can have only the reverse reading — a Portuguese word she
|
||||
// hovers in her own sentence — and that is still worth a tooltip.
|
||||
if ((!g.gloss && !g.reverse) || !wrapper) {
|
||||
setGloss(null)
|
||||
return
|
||||
}
|
||||
@@ -868,14 +883,14 @@ export function EditorCore({
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
|
||||
setGloss({ word: range.word, gloss: g.gloss, reverse: g.reverse, from: range.from, to: range.to, top, left })
|
||||
})
|
||||
.catch(() => {
|
||||
if (token === glossReqRef.current) setGloss(null)
|
||||
})
|
||||
}, 350)
|
||||
},
|
||||
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
||||
[editor, wordAlphabet, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
||||
)
|
||||
|
||||
// Leaving the editor surface drops any pending/shown gloss.
|
||||
@@ -1081,7 +1096,14 @@ export function EditorCore({
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
|
||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
||||
{gloss && (
|
||||
<GlossTip
|
||||
gloss={gloss.gloss}
|
||||
reverse={gloss.reverse}
|
||||
reverseLang={pack.nativeName}
|
||||
style={{ top: gloss.top, left: gloss.left }}
|
||||
/>
|
||||
)}
|
||||
{selection && !rewrite && !dragging && (
|
||||
<SelectionBubble
|
||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
|
||||
interface Props {
|
||||
gloss: string
|
||||
// The English meaning of the same token read as a word of the writer's own
|
||||
// language, when it is one. On a Latin-script pair "sale" is both, and the
|
||||
// bubble shows the two readings stacked rather than picking one — the same
|
||||
// both-directions rule the word card follows, in one line less space.
|
||||
reverse?: string
|
||||
// How the writer's language names itself, to label the second line.
|
||||
reverseLang?: string
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export function GlossTip({ gloss, style }: Props) {
|
||||
export function GlossTip({ gloss, reverse, reverseLang, style }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
|
||||
@@ -27,6 +34,12 @@ export function GlossTip({ gloss, style }: Props) {
|
||||
}}
|
||||
>
|
||||
{gloss}
|
||||
{reverse && (
|
||||
<span className="mt-0.5 block" style={{ opacity: 0.72, fontSize: '0.85em' }}>
|
||||
{reverseLang ? `${reverseLang}: ` : ''}
|
||||
{reverse}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
|
||||
import { wordAt } from './SpellCheck'
|
||||
|
||||
// Same minimal schema the suggestion-anchoring tests use: enough of a document
|
||||
// to walk textblocks, none of the editor.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||||
text: { group: 'inline' },
|
||||
},
|
||||
})
|
||||
|
||||
const para = (text: string): PMNode =>
|
||||
schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||||
|
||||
// posOf turns a plain-text offset into a ProseMirror position inside the single
|
||||
// paragraph (+1 for the paragraph's opening token).
|
||||
const posOf = (offset: number) => offset + 1
|
||||
|
||||
describe('wordAt and the pair alphabet', () => {
|
||||
it('keeps English-only tokenizing when no accented dictionary is loaded', () => {
|
||||
const doc = para('the river runs')
|
||||
expect(wordAt(doc, posOf(5))?.word).toBe('river')
|
||||
})
|
||||
|
||||
it('cuts an accented word into fragments on the narrow alphabet', () => {
|
||||
// Not a hypothetical: this is what every surface did before the pt-PT pair,
|
||||
// and it is why the alphabet had to become a property of the checker rather
|
||||
// than a constant. "coração" tokenized as A-Z yields "cora" — a definition
|
||||
// of which would be worse than no popover.
|
||||
const doc = para('o coração dela')
|
||||
expect(wordAt(doc, posOf(3))?.word).toBe('cora')
|
||||
})
|
||||
|
||||
it('resolves the whole word once the pair widens the alphabet', () => {
|
||||
const doc = para('o coração dela')
|
||||
expect(wordAt(doc, posOf(3), true)?.word).toBe('coração')
|
||||
// …from either side of the accented letters, not just before them.
|
||||
expect(wordAt(doc, posOf(9), true)?.word).toBe('coração')
|
||||
})
|
||||
|
||||
it('never tokenizes CJK, whichever alphabet is in force', () => {
|
||||
// The zh pair's guarantee, and it must survive a change made for another
|
||||
// pair entirely: Chinese is the source language, not something to spellcheck.
|
||||
const doc = para('我在写作 today')
|
||||
expect(wordAt(doc, posOf(1))).toBeNull()
|
||||
expect(wordAt(doc, posOf(1), true)).toBeNull()
|
||||
expect(wordAt(doc, posOf(6), true)?.word).toBe('today')
|
||||
})
|
||||
|
||||
it('stops the wide alphabet at the maths symbols hiding in Latin-1', () => {
|
||||
// × (U+00D7) and ÷ (U+00F7) sit inside the accented-letter block. A range
|
||||
// written À-ÿ would swallow them and glue "3×4" into one token.
|
||||
const doc = para('3×4 é isso')
|
||||
expect(wordAt(doc, posOf(4), true)?.word).toBe('é')
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,25 @@ interface PluginState {
|
||||
|
||||
// A word is a run of Latin letters with optional internal/edge apostrophes
|
||||
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
|
||||
//
|
||||
// Two alphabets, because the writer's pair decides which is right. English needs
|
||||
// only A-Z. European Portuguese needs ç and the accented vowels, and tokenizing
|
||||
// "ação" without them yields "a" and "o" — two fragments short enough that
|
||||
// isCheckable throws them away, so the word is silently never checked at all.
|
||||
//
|
||||
// The narrow alphabet stays the default rather than always widening: for a
|
||||
// writer with no Latin second language, adding accented letters can only find
|
||||
// new words to underline (the "café" and "naïve" she borrows), and finds no
|
||||
// mistakes she has actually made.
|
||||
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
||||
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ']*/g
|
||||
|
||||
// wordRe returns a fresh matcher for the alphabet in force. Fresh because these
|
||||
// are /g regexes carrying lastIndex, and two scans sharing one would interleave.
|
||||
function wordRe(extended: boolean): RegExp {
|
||||
const src = extended ? WORD_RE_LATIN : WORD_RE
|
||||
return new RegExp(src.source, 'g')
|
||||
}
|
||||
|
||||
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
|
||||
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
|
||||
@@ -47,12 +65,13 @@ function eachMisspelling(
|
||||
checker: SpellChecker,
|
||||
visit: (from: number, to: number, word: string) => void,
|
||||
) {
|
||||
const re = wordRe(checker.extendedAlphabet)
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isTextblock) return true
|
||||
const text = node.textContent
|
||||
WORD_RE.lastIndex = 0
|
||||
re.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = WORD_RE.exec(text)) !== null) {
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const { core, lead } = coreOf(m[0])
|
||||
if (!isCheckable(core) || checker.correct(core)) continue
|
||||
const from = mapOffset(node, pos, m.index + lead)
|
||||
@@ -78,16 +97,25 @@ function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): D
|
||||
// click), returning its range + text so the card can offer corrections and the
|
||||
// replacement can target the exact span — robust to duplicate words anywhere
|
||||
// else in the document. Returns null if the position isn't inside a Latin word.
|
||||
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
|
||||
//
|
||||
// `extended` widens the alphabet the same way the decoration pass does, so that
|
||||
// right-clicking "coração" looks up the whole word rather than "cora". Callers
|
||||
// pass the live checker's flag; the default keeps English-only behaviour.
|
||||
export function wordAt(
|
||||
doc: PMNode,
|
||||
pos: number,
|
||||
extended = false,
|
||||
): { from: number; to: number; word: string } | null {
|
||||
let found: { from: number; to: number; word: string } | null = null
|
||||
const re = wordRe(extended)
|
||||
doc.descendants((node, nodePos) => {
|
||||
if (found) return false
|
||||
if (!node.isTextblock) return true
|
||||
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
|
||||
const text = node.textContent
|
||||
WORD_RE.lastIndex = 0
|
||||
re.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = WORD_RE.exec(text)) !== null) {
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const { core, lead } = coreOf(m[0])
|
||||
if (!core) continue
|
||||
const from = mapOffset(node, nodePos, m.index + lead)
|
||||
|
||||
@@ -28,9 +28,11 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
const gloss = info?.gloss ?? ''
|
||||
const phonetic = info?.phonetic ?? ''
|
||||
const etymology = info?.etymology ?? ''
|
||||
// Present only when the token is also a word in her own language.
|
||||
const reverse = info?.reverse ?? null
|
||||
// Null whenever the dictionary has no opinion — the chip then doesn't render.
|
||||
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
|
||||
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||
const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -184,6 +186,37 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The same word read as one of hers. Only a Latin-script pair ever sees
|
||||
this — "sale" is English and Portuguese, "chat" is English and French —
|
||||
and Petal shows both readings rather than deciding which she meant. A
|
||||
detector would be right most of the time and wrong about her writing
|
||||
the rest; two lines are right always, and for a learner the collision
|
||||
is the interesting part. */}
|
||||
{reverse && (
|
||||
<div
|
||||
className="mt-3 rounded-xl px-2.5 py-2"
|
||||
style={{ background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
<p className="mb-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.editor.alsoIn}
|
||||
</p>
|
||||
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
{reverse.gloss || word}
|
||||
{reverse.phonetic && (
|
||||
<span className="ml-1.5 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
/{reverse.phonetic}/
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{(reverse.definitions ?? []).map((m, i) => (
|
||||
<p key={i} className="mt-1 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
{m.part_of_speech && <span className="mr-1 italic">{m.part_of_speech}</span>}
|
||||
{m.definition}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Where the word came from. Last, and in small muted type, because it is
|
||||
the one thing here that is interesting rather than useful — and for a
|
||||
writer whose own language shares Latin roots with English, "efémero"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { combine, interleave, type Loaded } from './useSpellChecker'
|
||||
|
||||
// The both-dictionaries rule (SUGGESTIONS.md §3a), separated from the fetching
|
||||
// so it can be checked without a 15 MB word list. What matters here is not
|
||||
// "does nspell work" but which way the combination is allowed to be wrong.
|
||||
|
||||
// A dictionary that accepts exactly the words it was given.
|
||||
function dict(lang: string, words: string[], corrections: string[] = [], extendedAlphabet = false): Loaded {
|
||||
const set = new Set(words)
|
||||
return {
|
||||
lang,
|
||||
extendedAlphabet,
|
||||
spell: {
|
||||
correct: (w: string) => set.has(w),
|
||||
suggest: () => corrections,
|
||||
add: (w: string) => set.add(w),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const en = dict('en', ['sale', 'the', 'river'], ['sailed', 'salt'])
|
||||
const pt = dict('pt-PT', ['sale', 'coração', 'jardim'], ['salte', 'sala'], true)
|
||||
|
||||
describe('the both-dictionaries rule', () => {
|
||||
it('accepts a word either dictionary knows', () => {
|
||||
const c = combine(() => [en, pt])
|
||||
expect(c.correct('river')).toBe(true) // English only
|
||||
expect(c.correct('jardim')).toBe(true) // Portuguese only
|
||||
expect(c.correct('sale')).toBe(true) // both — the collision case
|
||||
})
|
||||
|
||||
it('flags only what every dictionary rejects', () => {
|
||||
expect(combine(() => [en, pt]).correct('qqzzx')).toBe(false)
|
||||
})
|
||||
|
||||
it('never flags a Portuguese word just because English has not heard of it', () => {
|
||||
// The property the whole design exists for. With English alone, "coração"
|
||||
// is a misspelling; with her own dictionary loaded it is a word she wrote.
|
||||
expect(combine(() => [en]).correct('coração')).toBe(false)
|
||||
expect(combine(() => [en, pt]).correct('coração')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts everything when no dictionary loaded', () => {
|
||||
// A failed fetch must not underline every word in the document. Silence is
|
||||
// the safe failure; a page of red is not.
|
||||
expect(combine(() => []).correct('qqzzx')).toBe(true)
|
||||
})
|
||||
|
||||
it('sees a dictionary that arrives after the checker was built', () => {
|
||||
// English loads immediately; hers lands a moment later, once /api/me has
|
||||
// named her pair. The checker reads through a getter for exactly this.
|
||||
let loaded: Loaded[] = [en]
|
||||
const c = combine(() => loaded)
|
||||
expect(c.correct('jardim')).toBe(false)
|
||||
expect(c.extendedAlphabet).toBe(false)
|
||||
loaded = [en, pt]
|
||||
expect(c.correct('jardim')).toBe(true)
|
||||
expect(c.extendedAlphabet).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('correction pills', () => {
|
||||
it('interleaves the two dictionaries rather than letting one fill the list', () => {
|
||||
// Five pills fit. Concatenating would spend all of them on English and
|
||||
// leave a misspelt Portuguese word with no Portuguese correction — the one
|
||||
// case the second dictionary was loaded for.
|
||||
expect(combine(() => [en, pt]).suggest('salle')).toEqual(['sailed', 'salte', 'salt', 'sala'])
|
||||
})
|
||||
|
||||
it('drops duplicates, keeping the first dictionary to offer one', () => {
|
||||
expect(interleave([['a', 'b'], ['a', 'c']])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('keeps going when one dictionary runs out of ideas', () => {
|
||||
expect(interleave([['a'], ['x', 'y', 'z']])).toEqual(['a', 'x', 'y', 'z'])
|
||||
expect(interleave([[], []])).toEqual([])
|
||||
expect(interleave([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,37 +1,85 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import nspell, { type NSpell } from 'nspell'
|
||||
import nspell from 'nspell'
|
||||
import { api } from '../api/client'
|
||||
import { usePack } from '../i18n'
|
||||
import type { PairLang } from '../i18n'
|
||||
|
||||
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
|
||||
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
|
||||
// in-browser nspell instance — zero backend round-trips, per spec. The
|
||||
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
|
||||
// bundle) once per app session, not per document.
|
||||
// useSpellChecker builds the in-browser spell checker — zero backend round-trips
|
||||
// per keystroke, per spec.
|
||||
//
|
||||
// English is always loaded, because English is always the target half of the
|
||||
// pair. The writer's own language is loaded too when Petal ships a dictionary
|
||||
// for it, and then the two are consulted together: **a token is flagged only if
|
||||
// both dictionaries reject it.**
|
||||
//
|
||||
// That rule is the answer to the one genuinely new problem a Latin-script pair
|
||||
// creates (SUGGESTIONS.md §3a). The zh pair never had to decide which language a
|
||||
// word was in — the script answers it, and CJK is simply never tokenized. In an
|
||||
// English+Portuguese document both halves are Latin letters, and there is no
|
||||
// honest way to look at "sale" and know which language it is. Asking both
|
||||
// dictionaries needs no detector and no guess. It can miss a misspelling that
|
||||
// happens to be a real word in the other language; it can never squiggle
|
||||
// correct writing. That is the gentle direction to be wrong in.
|
||||
//
|
||||
// The personal word list — the words she's told Petal to stop flagging — lives
|
||||
// on the server, keyed by her account and by the dictionary's language. It used
|
||||
// to be one localStorage key, which meant two people sharing a device shared a
|
||||
// word list built from one person's private writing, and one person on a laptop
|
||||
// and a tablet had two lists that never met. It is replayed into nspell on load;
|
||||
// adding a word bumps a `version` so consumers re-run their decorations and the
|
||||
// word stops flagging.
|
||||
// on the server, keyed by her account and by dictionary language. It is replayed
|
||||
// into each nspell instance on load; adding a word bumps a `version` so consumers
|
||||
// re-run their decorations and the word stops flagging.
|
||||
|
||||
// SpellChecker is the minimal surface the editor decoration layer consumes.
|
||||
export interface SpellChecker {
|
||||
correct(word: string): boolean
|
||||
suggest(word: string): string[]
|
||||
// Whether the loaded dictionaries need letters beyond A-Z. Portuguese words
|
||||
// carry ç and five accented vowels; tokenizing without them would cut "ação"
|
||||
// into fragments. It is a property of the checker rather than a constant
|
||||
// because widening the alphabet for an English-only writer would only earn
|
||||
// her new squiggles under the French and Portuguese words she borrows.
|
||||
extendedAlphabet: boolean
|
||||
}
|
||||
|
||||
// The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the
|
||||
// first Latin pair, and its personal words are a separate list by design — an
|
||||
// English exception must not silence a Portuguese flag.
|
||||
const DICT_LANG = 'en'
|
||||
// A dictionary Petal can load into the browser.
|
||||
interface DictSpec {
|
||||
// The key the personal word list is stored under. It is the dictionary's
|
||||
// language, not the writer's.
|
||||
lang: string
|
||||
aff: string
|
||||
dic: string
|
||||
// Whether `dic` is gzipped. pt-PT's word list is 15 MB of text, so it ships
|
||||
// compressed and is inflated here; en's 550 KB does not need it.
|
||||
gzipped?: boolean
|
||||
extendedAlphabet?: boolean
|
||||
}
|
||||
|
||||
const EN: DictSpec = {
|
||||
lang: 'en',
|
||||
aff: '/dictionaries/en/en.aff',
|
||||
dic: '/dictionaries/en/en.dic',
|
||||
}
|
||||
|
||||
// The writer's-language dictionary, by pair. zh has no Hunspell dictionary and
|
||||
// needs none: Chinese is not tokenized, so it is never flagged.
|
||||
//
|
||||
// pt-PT's word list is pre-expanded (see scripts/build_ptpt_dictionary.py) —
|
||||
// nspell expands affixes eagerly on load, and doing that to European
|
||||
// Portuguese's 1,340 rules in a browser wants over a gigabyte of heap. The
|
||||
// forms are computed at build time instead, so this is the same nspell reading
|
||||
// a bigger, simpler file.
|
||||
const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
|
||||
'pt-PT': {
|
||||
lang: 'pt-PT',
|
||||
aff: '/dictionaries/pt-PT/pt-PT.aff',
|
||||
dic: '/dictionaries/pt-PT/pt-PT.dic.gz',
|
||||
gzipped: true,
|
||||
extendedAlphabet: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Where the list lived before it had an owner (Phase 7). Read once, handed to
|
||||
// the account, and then removed — see takeLegacyWords.
|
||||
// the account, and then removed — see readLegacyWords.
|
||||
const LEGACY_KEY = 'petal.spell.personal'
|
||||
|
||||
// takeLegacyWords reads the pre-account list without deleting it: the words are
|
||||
// readLegacyWords reads the pre-account list without deleting it: the words are
|
||||
// only dropped from the browser once the server has actually accepted them, so
|
||||
// a failed request costs nothing.
|
||||
function readLegacyWords(): string[] {
|
||||
@@ -52,72 +100,197 @@ function clearLegacyWords() {
|
||||
}
|
||||
}
|
||||
|
||||
export function useSpellChecker() {
|
||||
const spellRef = useRef<NSpell | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
// Bumped whenever the personal dictionary changes, to force re-decoration.
|
||||
const [version, setVersion] = useState(0)
|
||||
// fetchText retrieves a dictionary file, inflating it when it ships gzipped.
|
||||
//
|
||||
// DecompressionStream is used rather than a bundled inflate because it costs no
|
||||
// bytes and has been in every browser since 2023. A browser without it gets an
|
||||
// exception, which the caller treats as "this dictionary didn't load" — the same
|
||||
// outcome as a failed fetch.
|
||||
async function fetchText(url: string, gzipped: boolean | undefined): Promise<string> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|
||||
if (!gzipped) return res.text()
|
||||
if (!res.body) throw new Error(`${url}: no body to inflate`)
|
||||
const stream = res.body.pipeThrough(new DecompressionStream('gzip'))
|
||||
return new Response(stream).text()
|
||||
}
|
||||
|
||||
// Dictionary is the three methods Petal asks of nspell. Naming them rather than
|
||||
// referring to NSpell is what lets the decision logic below be exercised without
|
||||
// a 15 MB word list behind it; a real nspell instance satisfies this as-is.
|
||||
export interface Dictionary {
|
||||
correct(word: string): boolean
|
||||
suggest(word: string): string[]
|
||||
add(word: string): unknown
|
||||
}
|
||||
|
||||
// A dictionary that finished loading, with the language its personal words
|
||||
// belong to.
|
||||
export interface Loaded {
|
||||
lang: string
|
||||
spell: Dictionary
|
||||
extendedAlphabet: boolean
|
||||
}
|
||||
|
||||
// load builds one nspell instance and replays this writer's personal words into
|
||||
// it. `adoptLegacy` is passed only for English, and only on first load.
|
||||
async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
|
||||
const [aff, dic] = await Promise.all([
|
||||
fetchText(spec.aff, false),
|
||||
fetchText(spec.dic, spec.gzipped),
|
||||
])
|
||||
const spell = nspell(aff, dic)
|
||||
|
||||
// Her own words come from her account. A browser holding a list from before
|
||||
// accounts existed hands it over on the way — but only lets go of it once the
|
||||
// server has taken it.
|
||||
const legacy = adoptLegacy ? readLegacyWords() : []
|
||||
const stored = legacy.length
|
||||
? await api.addPersonalWords(spec.lang, legacy).then((res) => {
|
||||
clearLegacyWords()
|
||||
return res
|
||||
})
|
||||
: await api.listPersonalWords(spec.lang)
|
||||
for (const w of stored.words) spell.add(w)
|
||||
|
||||
return { lang: spec.lang, spell, extendedAlphabet: spec.extendedAlphabet ?? false }
|
||||
}
|
||||
|
||||
// interleave merges each dictionary's corrections round-robin. Concatenating
|
||||
// would let English fill all five pills and leave a misspelt Portuguese word
|
||||
// with no Portuguese suggestion, which is the case the second dictionary exists
|
||||
// for.
|
||||
export function interleave(lists: string[][]): string[] {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < Math.max(...lists.map((l) => l.length), 0); i++) {
|
||||
for (const list of lists) {
|
||||
const word = list[i]
|
||||
if (word && !seen.has(word)) {
|
||||
seen.add(word)
|
||||
out.push(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// combine is the both-dictionaries rule itself, separated from the loading so it
|
||||
// can be reasoned about (and tested) on its own. `dicts` is a getter because the
|
||||
// writer's dictionary lands after English and the checker must see it when it
|
||||
// does, without being rebuilt around a stale array.
|
||||
export function combine(dicts: () => Loaded[]): SpellChecker {
|
||||
return {
|
||||
// Flag only what *every* loaded dictionary rejects. With one dictionary this
|
||||
// is exactly the pre-Phase-21 behaviour; with two it is the rule from
|
||||
// SUGGESTIONS.md §3a. No dictionary at all accepts everything — an editor
|
||||
// that underlines every word because a fetch failed is worse than one that
|
||||
// underlines nothing.
|
||||
correct: (w) => {
|
||||
const loaded = dicts()
|
||||
if (loaded.length === 0) return true
|
||||
return loaded.some((d) => d.spell.correct(w))
|
||||
},
|
||||
suggest: (w) => interleave(dicts().map((d) => d.spell.suggest(w))),
|
||||
// A getter, not a snapshot. The alphabet is read by every surface that
|
||||
// resolves a word, and if it were captured when the checker was built it
|
||||
// would still say A-Z after her dictionary arrived — so a pt-PT writer's
|
||||
// first lookups would silently be of "cora" while the underlines were
|
||||
// already right.
|
||||
get extendedAlphabet() {
|
||||
return dicts().some((d) => d.extendedAlphabet)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useSpellChecker() {
|
||||
const pack = usePack()
|
||||
const pairSpec = PAIR_DICTS[pack.code]
|
||||
|
||||
const loadedRef = useRef<Loaded[]>([])
|
||||
// Bumped whenever a dictionary arrives or the personal list changes, to force
|
||||
// the editor to re-decorate.
|
||||
const [version, setVersion] = useState(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
// English first, once per session — it is never reloaded, whatever the pair.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
// Served from web/dist root (and embedded in the Go binary), same as /api.
|
||||
const [aff, dic] = await Promise.all([
|
||||
fetch('/dictionaries/en/en.aff').then((r) => r.text()),
|
||||
fetch('/dictionaries/en/en.dic').then((r) => r.text()),
|
||||
])
|
||||
load(EN, true)
|
||||
.then((loaded) => {
|
||||
if (cancelled) return
|
||||
const sp = nspell(aff, dic)
|
||||
spellRef.current = sp
|
||||
loadedRef.current = [...loadedRef.current.filter((l) => l.lang !== EN.lang), loaded]
|
||||
setReady(true)
|
||||
|
||||
// Her own words come from her account. A browser holding a list from
|
||||
// before accounts existed hands it over on the way — but only lets go of
|
||||
// it once the server has taken it.
|
||||
const legacy = readLegacyWords()
|
||||
const stored = legacy.length
|
||||
? await api.addPersonalWords(DICT_LANG, legacy).then((res) => {
|
||||
clearLegacyWords()
|
||||
return res
|
||||
})
|
||||
: await api.listPersonalWords(DICT_LANG)
|
||||
if (cancelled) return
|
||||
for (const w of stored.words) sp.add(w)
|
||||
setVersion((v) => v + 1)
|
||||
} catch (err) {
|
||||
// A failure here costs correct words being flagged, not writing. The
|
||||
// checker itself stays usable if only the word list failed to arrive.
|
||||
})
|
||||
.catch((err) => {
|
||||
// A failure here costs correct words being flagged, not writing.
|
||||
console.error('spell checker failed to load', err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Recreate the checker's identity on load and on every personal-dict change so
|
||||
// the editor's effect re-pushes it and rebuilds decorations.
|
||||
// The writer's own dictionary, once her pair is known. `pack.code` is 'zh'
|
||||
// until /api/me answers, so a pt-PT writer loads this a moment after the
|
||||
// editor is already usable — which is the right order: the English half works
|
||||
// immediately and hers fills in.
|
||||
useEffect(() => {
|
||||
if (!pairSpec) {
|
||||
// Switching away from a pair (only tests do this today) must not leave the
|
||||
// old language's words still being accepted.
|
||||
loadedRef.current = loadedRef.current.filter((l) => l.lang === EN.lang)
|
||||
setVersion((v) => v + 1)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
load(pairSpec, false)
|
||||
.then((loaded) => {
|
||||
if (cancelled) return
|
||||
loadedRef.current = [...loadedRef.current.filter((l) => l.lang !== loaded.lang), loaded]
|
||||
setVersion((v) => v + 1)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Only her half failed. English still checks, and the consequence is
|
||||
// that correct Portuguese gets underlined — worth a console line, not
|
||||
// worth blocking the editor.
|
||||
console.error(`spell checker failed to load ${pairSpec.lang}`, err)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pairSpec])
|
||||
|
||||
// The checker's identity changes on every load and every added word, so the
|
||||
// editor's effect re-pushes it and rebuilds decorations.
|
||||
const checker = useMemo<SpellChecker | null>(() => {
|
||||
if (!ready) return null
|
||||
return {
|
||||
correct: (w) => spellRef.current?.correct(w) ?? true,
|
||||
suggest: (w) => spellRef.current?.suggest(w) ?? [],
|
||||
}
|
||||
// Read through the ref rather than closing over a snapshot: the pair
|
||||
// dictionary arrives after English, and `version` is what re-runs this.
|
||||
return combine(() => loadedRef.current)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, version])
|
||||
|
||||
// Adding a word takes effect in the editor immediately and is persisted in the
|
||||
// background: the word stops being underlined the instant she asks, whatever
|
||||
// the network is doing.
|
||||
//
|
||||
// It is written to every loaded dictionary's list. Under the both-dictionaries
|
||||
// rule a word is only ever flagged when *all* of them rejected it, so
|
||||
// accepting it is a statement about her pair rather than about one language —
|
||||
// and recording it against only one would silently unaccept it if the other
|
||||
// dictionary is the one still loaded next time.
|
||||
const addWord = useCallback((word: string) => {
|
||||
const sp = spellRef.current
|
||||
if (!sp) return
|
||||
sp.add(word)
|
||||
const dicts = loadedRef.current
|
||||
if (dicts.length === 0) return
|
||||
for (const d of dicts) {
|
||||
d.spell.add(word)
|
||||
api.addPersonalWords(d.lang, [word]).catch((err) => {
|
||||
console.error('could not save personal word', err)
|
||||
})
|
||||
}
|
||||
setVersion((v) => v + 1)
|
||||
api.addPersonalWords(DICT_LANG, [word]).catch((err) => {
|
||||
console.error('could not save personal word', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { checker, ready, addWord }
|
||||
|
||||
+68
-15
@@ -2,6 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
|
||||
import { zh } from './packs/zh'
|
||||
import { ptPT } from './packs/pt-PT'
|
||||
import type { Pack } from './types'
|
||||
|
||||
// Every pack that ships. Shape assertions run over all of them, because the
|
||||
// point of Phase 19 was that a language is data — and data that only the first
|
||||
// author's pack satisfies isn't a shape, it's a coincidence.
|
||||
const PACKS: Pack[] = [zh, ptPT]
|
||||
|
||||
beforeEach(() => {
|
||||
resetPackForTests()
|
||||
@@ -15,10 +22,19 @@ describe('pack selection', () => {
|
||||
expect(pack().code).toBe('zh')
|
||||
})
|
||||
|
||||
it('switches to a shipped pack when the session names one', () => {
|
||||
setPackLang('pt-PT')
|
||||
expect(pack()).toBe(ptPT)
|
||||
expect(pack().code).toBe('pt-PT')
|
||||
// And back — a writer moving pairs must not strand the app on the old copy.
|
||||
setPackLang('zh')
|
||||
expect(pack()).toBe(zh)
|
||||
})
|
||||
|
||||
it('falls back rather than blanking on a pair with no pack yet', () => {
|
||||
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
||||
// of its translation. She should still get a working editor.
|
||||
setPackLang('pt-PT')
|
||||
setPackLang('fr')
|
||||
expect(pack()).toBe(zh)
|
||||
setPackLang('klingon')
|
||||
expect(pack()).toBe(zh)
|
||||
@@ -30,20 +46,19 @@ describe('pack selection', () => {
|
||||
expect(pack()).toBe(zh)
|
||||
})
|
||||
|
||||
// Only one pack ships today, so a *real* switch can't be exercised yet; what
|
||||
// can be is the other half of that contract — that a no-op never wakes every
|
||||
// reader in the app. The switching path gets its test when pt-PT lands.
|
||||
it('never notifies readers when nothing actually changed', () => {
|
||||
it('notifies readers on a real switch, and only on a real one', () => {
|
||||
const seen = vi.fn()
|
||||
onPackChange(seen)
|
||||
|
||||
setPackLang('zh') // already the current pack — nothing changed
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
// Standing in for a second pack until one ships: switching to something
|
||||
// unshipped resolves back to zh, which is also not a change.
|
||||
// An unshipped pair resolves back to zh, which is also not a change.
|
||||
setPackLang('fr')
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
setPackLang('pt-PT')
|
||||
expect(seen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
@@ -87,7 +102,7 @@ describe('the zh pack', () => {
|
||||
// A pack with a hole in it renders an empty label rather than failing, which
|
||||
// is exactly the kind of thing that reaches production. Types catch a missing
|
||||
// *key*; only this catches an empty *value*.
|
||||
it('has no empty strings anywhere', () => {
|
||||
it.each(PACKS)('has no empty strings anywhere ($code)', (p) => {
|
||||
const empties: string[] = []
|
||||
const walk = (node: unknown, path: string) => {
|
||||
if (typeof node === 'string') {
|
||||
@@ -99,28 +114,28 @@ describe('the zh pack', () => {
|
||||
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
|
||||
}
|
||||
}
|
||||
walk(zh, '')
|
||||
walk(p, '')
|
||||
expect(empties).toEqual([])
|
||||
})
|
||||
|
||||
it('labels every companion in the roster and every tone the editor offers', async () => {
|
||||
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||
for (const c of COMPANIONS) {
|
||||
expect(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
||||
expect(p.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
||||
}
|
||||
|
||||
const { TONES } = await import('../components/Editor/ToneSelect')
|
||||
for (const tone of TONES) {
|
||||
expect(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
||||
expect(p.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
||||
}
|
||||
|
||||
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
|
||||
for (const style of REWRITE_STYLES) {
|
||||
expect(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
||||
expect(p.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('labels every word band the popover can show', async () => {
|
||||
it.each(PACKS)('labels every word band the popover can show ($code)', async (p) => {
|
||||
// wordBand returns a band name, never a label — an unlabelled band would
|
||||
// render as an empty chip, which reads as a bug rather than as no data.
|
||||
const { wordBand } = await import('../components/Editor/wordband')
|
||||
@@ -136,7 +151,45 @@ describe('the zh pack', () => {
|
||||
)
|
||||
expect(bands.size).toBe(3)
|
||||
for (const band of bands) {
|
||||
expect(zh.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
|
||||
expect(p.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('the pt-PT pack', () => {
|
||||
// The pack is European Portuguese or it is nothing: a Brazilian form in the
|
||||
// chrome is exactly the drift SUGGESTIONS.md §3 says to guard against, and it
|
||||
// is invisible to anyone who doesn't read Portuguese — including whoever
|
||||
// reviews this diff.
|
||||
it('is European Portuguese, not Brazilian', () => {
|
||||
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
|
||||
|
||||
// Brazilian spellings and vocabulary that would give the pack away.
|
||||
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
|
||||
expect(text, `pt-BR form "${bad}" in the pt-PT pack`).not.toContain(bad)
|
||||
}
|
||||
|
||||
// And the European forms that should be there instead.
|
||||
expect(ptPT.editor.synonyms).toContain('Sinónimos')
|
||||
expect(ptPT.styles.academic.native).toBe('Académico')
|
||||
expect(ptPT.auth.signIn).toContain('Iniciar sessão')
|
||||
})
|
||||
|
||||
it('renders its interpolated lines with the value in place', () => {
|
||||
expect(ptPT.app.duplicateTitle('Primavera')).toBe('Primavera (cópia)')
|
||||
expect(ptPT.companion.milestone(300).native).toContain('300 palavras')
|
||||
// Portuguese agreement is the pack's business, the same way English
|
||||
// pluralisation is — the call site only ever passes a number.
|
||||
expect(ptPT.garden.reviewDue(1)).toContain('1 palavra ·')
|
||||
expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·')
|
||||
expect(ptPT.garden.growing(1)).toContain('1 flor no jardim')
|
||||
expect(ptPT.garden.growing(3)).toContain('3 flores no jardim')
|
||||
})
|
||||
|
||||
it('says the collision line the zh pair never needed', () => {
|
||||
// "sale", "comum" and "tarde" are words on both sides of this pair, so the
|
||||
// word card's second reading is reachable copy here — unlike in zh.
|
||||
expect(ptPT.editor.alsoIn).toBeTruthy()
|
||||
expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,12 +16,13 @@ import { useSyncExternalStore } from 'react'
|
||||
|
||||
import type { Pack, PairLang } from './types'
|
||||
import { zh } from './packs/zh'
|
||||
import { ptPT } from './packs/pt-PT'
|
||||
|
||||
export type { Pack, PairLang, Line } from './types'
|
||||
|
||||
// Every pack Petal ships. pt-PT, fr and es land here in Phase 21 — adding one
|
||||
// is this line plus the file, and TypeScript then names every string it owes.
|
||||
const PACKS: Partial<Record<PairLang, Pack>> = { zh }
|
||||
// Every pack Petal ships. fr and es are the same two lines apiece when their
|
||||
// copy is written — TypeScript names every string a new pack still owes.
|
||||
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
|
||||
|
||||
const DEFAULT_LANG: PairLang = 'zh'
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// The European Portuguese pack — the first pair that is not Chinese, and the
|
||||
// one that proves a language really is data.
|
||||
//
|
||||
// ⚠️ WRITTEN BUT NOT YET REVIEWED BY A NATIVE SPEAKER.
|
||||
// SUGGESTIONS.md §3 sets the bar: "the pt-PT pack should be reviewed by a pt-PT
|
||||
// speaker before it's trusted — same standard the zh copy got by being written
|
||||
// for a real reader." That review has not happened. Until it does, treat every
|
||||
// line here as a good-faith draft rather than as shipped copy, and expect a
|
||||
// speaker to change the register long before they change the vocabulary.
|
||||
//
|
||||
// European Portuguese, not Brazilian. That is the single most likely way for
|
||||
// this file to go quietly wrong, so the choices are deliberate throughout:
|
||||
//
|
||||
// * Post-Acordo spellings (ação, direção, ótimo), but the pt-PT lexicon —
|
||||
// ficheiro not arquivo, ecrã not tela, guardar not salvar, eliminar not
|
||||
// deletar, telemóvel not celular, sinónimo not sinônimo, académico not
|
||||
// acadêmico.
|
||||
// * "Estás a escrever", not "está escrevendo". The progressive with *a* +
|
||||
// infinitive is the European construction, and the gerund is the single
|
||||
// clearest tell of a Brazilian text.
|
||||
// * Second person singular *tu*, because Petal is a companion in someone's
|
||||
// private notebook and *você* would put a desk between them.
|
||||
// * "Iniciar sessão" / "Terminar sessão", not "fazer login" / "sair".
|
||||
//
|
||||
// Portuguese first, English underneath — same shape as the zh pack, for the
|
||||
// same reason: she reads her own language faster, and the English half is what
|
||||
// she is here to learn.
|
||||
|
||||
import type { Pack } from '../types'
|
||||
|
||||
export const ptPT: Pack = {
|
||||
code: 'pt-PT',
|
||||
nativeName: 'Português',
|
||||
|
||||
app: {
|
||||
duplicateTitle: (title) => `${title} (cópia)`,
|
||||
garden: 'Jardim de palavras',
|
||||
history: 'Histórico',
|
||||
},
|
||||
|
||||
auth: {
|
||||
title: 'Inicia sessão outra vez',
|
||||
titleEn: 'Please sign in again',
|
||||
bodyWithDraft:
|
||||
'O que acabaste de escrever está guardado neste dispositivo — volta a entrar e guarda-se sozinho.',
|
||||
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
|
||||
bodyPlain: 'A tua sessão expirou. Tudo o que escreveste já está guardado.',
|
||||
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
|
||||
signIn: 'Iniciar sessão · Sign in',
|
||||
},
|
||||
|
||||
companion: {
|
||||
choose: 'Escolhe um companheiro · Choose a companion',
|
||||
|
||||
encouragements: [
|
||||
{ native: 'Que bom! Esta frase ficou bem mais fluida 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||
{ native: 'Estás a escrever cada vez melhor ✨', en: "You're getting better and better." },
|
||||
{ native: 'Gosto muito desta mudança 💕', en: 'I really like that change.' },
|
||||
{ native: 'Continua assim — tu consegues!', en: 'Keep going — you’ve got this!' },
|
||||
{ native: 'Pois é, assim ficou muito mais claro 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ native: 'Que boa escolha de palavra 🌷', en: 'That’s such a good word choice.' },
|
||||
{ native: 'Ui, este parágrafo lê-se tão bem ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||
{ native: 'Adoro ver-te escrever com mais confiança 💛', en: 'I love watching you write with more confidence.' },
|
||||
{ native: 'Cada bocadinho de progresso conta 🌱', en: 'Every little bit of progress counts.' },
|
||||
{ native: 'Hoje as tuas palavras estão a brilhar ✨', en: 'Your words are sparkling today.' },
|
||||
],
|
||||
|
||||
tips: [
|
||||
{ native: 'Dica: em inglês, frases mais curtas leem-se melhor.', en: 'Tip: shorter English sentences often read clearer.' },
|
||||
{ native: 'Não te esqueças dos artigos “the” e “a”.', en: "Don't forget articles like “the” and “a”." },
|
||||
{ native: 'Para o passado, usa o past tense: go → went.', en: 'For the past, use past tense: go → went.' },
|
||||
{ native: 'Ler em voz alta ajuda a apanhar o que soa estranho.', en: 'Reading aloud helps you catch awkward spots.' },
|
||||
{ native: 'Uma ideia por parágrafo e fica tudo arrumado.', en: 'One idea per paragraph keeps it tidy.' },
|
||||
{ native: 'Se tiveres dúvidas, pergunta-me ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||
{ native: 'O plural leva “s”: two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||
// One tip the zh pack has no use for: the false friends between
|
||||
// Portuguese and English are a daily hazard for this writer and a
|
||||
// non-problem for the last one.
|
||||
{ native: 'Atenção aos falsos amigos: “pretender” não é *to pretend*.', en: 'Careful with false friends — “pretender” means to intend.' },
|
||||
],
|
||||
|
||||
breaks: [
|
||||
{ native: 'Já escreves há um bom bocado — levanta-te e descansa os olhos 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||
{ native: 'Bebe um copo de água e para cinco minutos?', en: 'Sip some water and take five?' },
|
||||
{ native: 'Olha ao longe um momento, dá uma folga aos olhos 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||
],
|
||||
|
||||
// Late-night nudges. The English wit is the user's own and is kept word for
|
||||
// word across every pack; the Portuguese line leads gently into it, exactly
|
||||
// as the Mandarin one does.
|
||||
bedtime: [
|
||||
{ native: 'A tua cama deve estar com saudades 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ native: 'Cansada não se escreve bem — vai descansar 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ native: 'Dorme sobre o assunto, que as ideias vêm sozinhas ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ native: 'Ouves? Pois não… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// Portuguese proverbs on sleep and haste, in place of the Chinese ones —
|
||||
// a pack is not a translation of another pack.
|
||||
{ native: 'Deitar cedo e cedo erguer dá saúde e faz crescer.', en: 'Early to bed and early to rise makes you healthy and helps you grow.' },
|
||||
{ native: 'Quem dorme sobre o problema, acorda com a solução.', en: 'Sleep on the problem and you wake with the answer.' },
|
||||
{ native: 'Depressa e bem, há pouco quem.', en: 'Fast and good together — few manage that.' },
|
||||
],
|
||||
|
||||
greeting: { native: 'Olá! Estou aqui a fazer-te companhia 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
|
||||
welcomeBack: { native: 'Bem-vinda de volta ✨ vamos continuar!', en: 'Welcome back ✨ let’s keep going!' },
|
||||
|
||||
errors: [
|
||||
{ native: 'Ups — houve um percalço, mas as tuas palavras estão a salvo.', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||
{ native: 'Bolas, encravei por um segundo — já volto.', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||
{ native: 'Não te preocupes, tentamos outra vez daqui a pouco 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||
],
|
||||
|
||||
milestone: (words: number) => ({
|
||||
native: `Uau! Já vais em ${words} palavras 🎉`,
|
||||
en: `Wow — ${words} words already! Amazing. 🎉`,
|
||||
}),
|
||||
|
||||
names: {
|
||||
cat: 'Gato dorminhoco',
|
||||
dog: 'Cão contente',
|
||||
'wiggle-dog': 'Cão abanão',
|
||||
butterfly: 'Borboleta',
|
||||
parrot: 'Papagaio',
|
||||
},
|
||||
},
|
||||
|
||||
prose: {
|
||||
longSentence: 'Esta frase está um bocadinho longa — dividi-la em duas ou três fica mais claro 🌸',
|
||||
commaSplice: 'Aqui há duas frases ligadas só por uma vírgula. Podes usar um ponto final, ou juntar “and / but”.',
|
||||
vagueThis: (word) => `Não se percebe bem a que “${word}” se refere — vale a pena dizê-lo (por exemplo, “${word} idea / change…”).`,
|
||||
oxfordComma: 'Numa lista de três ou mais, uma vírgula antes de “and / or” também ajuda a ler (a vírgula de Oxford).',
|
||||
transitionComma: (word) => `Depois de uma palavra de ligação no início, põe uma vírgula: “${word}, …”.`,
|
||||
capitalizeSentence: 'Começa cada frase com letra maiúscula.',
|
||||
repeatedWord: (word) => `“${word}” parece estar escrito duas vezes — dá uma vista de olhos.`,
|
||||
capitalizeI: 'Em inglês, o “I” (eu) escreve-se sempre com maiúscula.',
|
||||
spaceBeforePunct: 'Em inglês não se põe espaço antes da pontuação: a vírgula e o ponto vêm colados à palavra.',
|
||||
spaceAfterPunct: 'Depois da vírgula ou do ponto, deixa um espaço antes da palavra seguinte.',
|
||||
articleAn: (word) => `Antes de som de vogal usa-se “an”: “an ${word}”.`,
|
||||
articleA: (word) => `Antes de som de consoante usa-se “a”: “a ${word}”.`,
|
||||
uncountable: (word, singular) => `“${word}” é incontável em inglês — não leva s, basta “${singular}”.`,
|
||||
capitalizeProper: (fixed) => `Em inglês, línguas, nacionalidades, dias da semana e meses levam maiúscula: “${fixed}”.`,
|
||||
thirdPersonS: (subject, verb) => `Com he/she/it, o verbo leva -s: “${subject} ${verb}”.`,
|
||||
pluralAfter: (determiner, noun) => `Depois de “${determiner}” o nome vai no plural: “${determiner} ${noun}s”.`,
|
||||
doubleDeterminer: (first, second) => `“${first} ${second}” tem dois determinantes — fica só com um (por exemplo, tira “${first}”).`,
|
||||
thereArePlural: (noun) => `Com plural usa-se “there are”: “there are ${noun}…”.`,
|
||||
itsOwn: '“it’s” = “it is”. Para dizer “o seu / dele”, é “its” — portanto “its own”.',
|
||||
itsIs: (rest) => `Aqui é “it’s ${rest}” (it is); “its” é o possessivo.`,
|
||||
thanNotThen: (word) => `Nas comparações usa-se “than”, não “then”: “${word} than”.`,
|
||||
},
|
||||
|
||||
docs: {
|
||||
sortRecent: 'Recentes · Recent',
|
||||
sortTitle: 'Título · Title',
|
||||
sortLongest: 'Mais longos · Longest',
|
||||
backUpAll: 'Cópia de segurança · Back up all:',
|
||||
signOut: 'Terminar sessão · Sign out',
|
||||
duplicate: 'Duplicar · Duplicate',
|
||||
searchPlaceholder: 'Procurar · Search',
|
||||
searching: 'A procurar… · Searching…',
|
||||
noMatches: 'Sem resultados · No matches',
|
||||
tags: 'Etiquetas · Tags',
|
||||
newTagPlaceholder: 'Nova etiqueta · New tag',
|
||||
},
|
||||
|
||||
editor: {
|
||||
askPlaceholder: 'Ask why… / Pergunta porquê…',
|
||||
findPlaceholder: 'Localizar · Find',
|
||||
findNone: 'Nada · 0',
|
||||
matchCase: 'Match case · Maiúsculas/minúsculas',
|
||||
close: 'Close · Fechar',
|
||||
replacePlaceholder: 'Substituir por · Replace',
|
||||
replace: 'Substituir',
|
||||
replaceAll: 'Tudo',
|
||||
spelling: 'Ortografia · Spelling',
|
||||
noSuggestions: 'Sem sugestões · No suggestions',
|
||||
addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
|
||||
readSelection: 'Ler a seleção em voz alta · Read selection aloud',
|
||||
rewrite: 'Reescrever · Rewrite',
|
||||
rewriting: 'A reescrever… · Rewriting…',
|
||||
rewriteFailed: 'Não deu para reescrever — tenta outra vez · Couldn’t rewrite',
|
||||
cancel: 'Cancelar · Cancel',
|
||||
retry: 'Tentar de novo · Retry',
|
||||
useThis: 'Usar esta · Use this',
|
||||
word: 'Palavra · Word',
|
||||
inGarden: 'Já está no jardim · In your garden (tap to remove)',
|
||||
saveToGarden: 'Guardar no jardim · Save to garden',
|
||||
readAloud: 'Ler em voz alta · Read aloud',
|
||||
lookingUp: 'A procurar… · Looking up…',
|
||||
definition: 'Definição · Definition',
|
||||
synonyms: 'Sinónimos · Synonyms',
|
||||
tapToSwap: 'toca para trocar · tap to swap',
|
||||
nothingFound: 'Não encontrei esta palavra · Nothing found for this word',
|
||||
origin: 'Origem · Origin',
|
||||
// This one the pt-PT pair actually sees: sale, comum, tarde, ali, data and
|
||||
// dozens more are words on both sides of the pair.
|
||||
alsoIn: 'Também é palavra em português · Also a word in Portuguese',
|
||||
wordBands: {
|
||||
simple: { native: 'Do dia a dia', en: 'Everyday word' },
|
||||
standard: { native: 'Normal', en: 'Standard' },
|
||||
advanced: { native: 'Avançada', en: 'Advanced' },
|
||||
},
|
||||
},
|
||||
|
||||
styles: {
|
||||
natural: { native: 'Mais natural', en: 'Natural' },
|
||||
academic: { native: 'Académico', en: 'Academic' },
|
||||
professional: { native: 'Profissional', en: 'Professional' },
|
||||
casual: { native: 'Descontraído', en: 'Casual' },
|
||||
humorous: { native: 'Bem-humorado', en: 'Humorous' },
|
||||
creative: { native: 'Criativo', en: 'Creative' },
|
||||
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
|
||||
},
|
||||
|
||||
tones: {
|
||||
general: { native: 'Geral', en: 'General' },
|
||||
academic: { native: 'Académico', en: 'Academic' },
|
||||
professional: { native: 'Profissional', en: 'Professional' },
|
||||
casual: { native: 'Descontraído', en: 'Casual' },
|
||||
humorous: { native: 'Bem-humorado', en: 'Humorous' },
|
||||
creative: { native: 'Criativo', en: 'Creative' },
|
||||
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
|
||||
},
|
||||
|
||||
exports: {
|
||||
label: 'Exportar',
|
||||
print: 'Imprimir / PDF',
|
||||
formats: {
|
||||
md: { native: 'Markdown', en: 'Markdown (.md)' },
|
||||
docx: { native: 'Documento Word', en: 'Word (.docx)' },
|
||||
html: { native: 'Página web', en: 'Web page (.html)' },
|
||||
txt: { native: 'Texto simples', en: 'Plain text (.txt)' },
|
||||
},
|
||||
},
|
||||
|
||||
garden: {
|
||||
title: 'Jardim de palavras · Vocabulary Garden',
|
||||
titleWithFlower: '🌷 Jardim de palavras · Vocabulary Garden',
|
||||
reviewing: 'A rever · Reviewing — recall, then grade yourself',
|
||||
subtitle: 'Words you looked up, blooming as you learn them',
|
||||
reviewDue: (n) => `Rever ${n} palavra${n === 1 ? '' : 's'} · Review ${n} due 🌸`,
|
||||
emptyLead: 'O teu jardim ainda está vazio.',
|
||||
emptyHint: 'Clica com o botão direito numa palavra inglesa para a procurar — e ela germina aqui.',
|
||||
due: 'a rever · due',
|
||||
seen: (reps, intervalDays) => `${reps}× revista · seen ${reps}× · intervalo ${intervalDays}d`,
|
||||
readAloud: '🔊 Ler',
|
||||
source: '📄 Origem · Source',
|
||||
remove: '🗑 Remover',
|
||||
growing: (n) => `🐱💤 ${n} flor${n === 1 ? '' : 'es'} no jardim · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||
end: 'Terminar · End',
|
||||
promptProduction: 'Qual é a palavra inglesa? · Which English word?',
|
||||
promptRecognition: 'O que significa? · What does this mean?',
|
||||
showAnswer: 'Ver a resposta · Show answer',
|
||||
gradeAgain: { native: 'Outra vez', en: 'Again' },
|
||||
gradeGood: { native: 'Lembro-me', en: 'Good' },
|
||||
gradeEasy: { native: 'Fácil', en: 'Easy' },
|
||||
},
|
||||
|
||||
history: {
|
||||
title: 'Histórico · History',
|
||||
kinds: {
|
||||
manual: { native: 'Ponto guardado', en: 'Saved point' },
|
||||
auto: { native: 'Automático', en: 'Auto' },
|
||||
pre_restore: { native: 'Antes de restaurar', en: 'Before restore' },
|
||||
},
|
||||
justNow: 'just now · agora mesmo',
|
||||
minutesAgo: (n) => `${n} min ago · há ${n} min`,
|
||||
hoursAgo: (n) => `${n} hr ago · há ${n} h`,
|
||||
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · há ${n} dia${n > 1 ? 's' : ''}`,
|
||||
preview: 'Pré-visualizar · Preview',
|
||||
restoring: 'Restoring…',
|
||||
restoreThis: 'Restaurar esta versão · Restore this version',
|
||||
passport: '📜 Certificado de escrita · Writing passport',
|
||||
keepFullHistory: 'Guardar o histórico completo · Keep full history',
|
||||
},
|
||||
|
||||
status: {
|
||||
savedLocally: 'Guardado neste dispositivo · Kept on this device',
|
||||
helperRestingNative: 'O ajudante está a descansar',
|
||||
helperRestingEn: "· Petal's helper is resting · o teu texto está guardado",
|
||||
soundsOn: 'Som ligado · Sounds on',
|
||||
soundsOff: 'Som desligado · Sounds off',
|
||||
petalsOn: 'Pétalas ligadas · Petals on',
|
||||
petalsOff: 'Pétalas desligadas · Petals off',
|
||||
statsTitle: 'Estatísticas · Writing stats',
|
||||
stats: {
|
||||
words: { native: 'Palavras', en: 'Words' },
|
||||
characters: { native: 'Caracteres', en: 'Characters' },
|
||||
sentences: { native: 'Frases', en: 'Sentences' },
|
||||
paragraphs: { native: 'Parágrafos', en: 'Paragraphs' },
|
||||
pages: { native: 'Páginas', en: 'Pages' },
|
||||
readingTime: { native: 'Tempo de leitura', en: 'Reading time' },
|
||||
avgWordLength: { native: 'Comprimento médio', en: 'Avg word length' },
|
||||
variety: { native: 'Variedade vocabular', en: 'Word variety' },
|
||||
readability: { native: 'Nível de leitura', en: 'Reading level' },
|
||||
},
|
||||
readability: {
|
||||
easy: { native: 'Fácil', en: 'Easy' },
|
||||
standard: { native: 'Normal', en: 'Standard' },
|
||||
fairlyHard: { native: 'Algo difícil', en: 'Fairly hard' },
|
||||
advanced: { native: 'Avançado', en: 'Advanced' },
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
untitledHeading: '(sem título)',
|
||||
outline: 'Estrutura · Outline',
|
||||
outlineHint: 'Usa H1/H2/H3 para criar títulos e a navegação aparece aqui.',
|
||||
},
|
||||
|
||||
update: {
|
||||
available: 'Há uma versão nova',
|
||||
refresh: 'Atualizar · Refresh',
|
||||
dismiss: 'Mais tarde · Dismiss',
|
||||
},
|
||||
}
|
||||
@@ -175,6 +175,10 @@ export const zh: Pack = {
|
||||
tapToSwap: '点击替换 · tap to swap',
|
||||
nothingFound: '没有找到这个词 · Nothing found for this word',
|
||||
origin: '词源 · Origin',
|
||||
// Never rendered for this pair — English and Chinese share no spellings, so
|
||||
// a word is never both. Written out anyway because the type demands it and
|
||||
// because "unreachable" is a claim about today's data, not a guarantee.
|
||||
alsoIn: '这个词在中文里也有 · Also a word in Chinese',
|
||||
wordBands: {
|
||||
simple: { native: '常用词', en: 'Everyday word' },
|
||||
standard: { native: '一般难度', en: 'Standard' },
|
||||
|
||||
@@ -137,6 +137,13 @@ export interface Pack {
|
||||
// Where the word came from — a real hook for a writer whose own language
|
||||
// shares roots with English.
|
||||
origin: string
|
||||
// Heading for the other reading of a word that exists in both languages —
|
||||
// Portuguese *sale*, French *chat*. Petal shows both rather than picking
|
||||
// one, so this labels the half that is in her language. The pack names its
|
||||
// own language here rather than being handed a code: only it knows whether
|
||||
// that reads as "em português" or as "葡萄牙语". A pack whose pair has no
|
||||
// such collisions (zh) never sees this rendered.
|
||||
alsoIn: string
|
||||
// How hard the word is, keyed by the band wordBand() returns.
|
||||
wordBands: Record<string, Line>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user