mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(reader): follow inflections so define reaches a real definition
Looking up a plural showed only "plural of candelabra", and looking up "candelabra" added a Latin declension line beneath it. Both come from the Wiktionary path, which English lookups fall through to whenever api.dictionaryapi.dev fails; it 502s often enough that readers hit it regularly. Read only the requested language section. Wiktionary keys a page by language and returns every language that spells the word the same way, so iterating Object.values() merged Latin into English. This was not a rare edge case: of 14 common words sampled, 13 carried foreign senses, and "fare" rendered 129 definitions of which 18 were English. Follow an inflection gloss to the word that carries the meaning, bounded to depth 2, three lemma fetches and a visited set for cycles. Only a pointer in the leading sense of its block is followed, which is what separates "candelabras" from the "simple past of bake" buried at sense 10 of "book". A lemma that fails to resolve is dropped rather than failing the lookup. Detection requires every word before "of" to be grammatical, so "Informal form of address used by juniors" stays a definition rather than sending the reader to "address". Drop script and style elements before reading textContent. Wiktionary ships an inline <style> with some senses and its CSS was rendering as part of the definition, visibly so on "fare". Merge blocks that share a part of speech and cap each at five senses, so a resolved chain stays readable and the popover stops keying a v-for on a duplicated part of speech. Pointer following is deliberately English-only. fr.wiktionary writes "pluriel de chat", and guessing at sixteen languages of grammar vocabulary is not worth it; non-English lookups still gain the language filter. The Free Dictionary path is also unchanged for "candelabras", where its own gloss "A single candelabrum." is circular but not a recognizable pointer. Closes #1020
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Volume2, RefreshCw } from '@lucide/vue'
|
||||
import type { DictionaryResult } from '@bookorbit/types'
|
||||
import type { DictionaryEntry, DictionaryResult } from '@bookorbit/types'
|
||||
import { useDictionary } from '../composables/useDictionary'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -30,6 +30,32 @@ const popoverStyle = ref({
|
||||
})
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
interface SenseGroup {
|
||||
word: string
|
||||
isLemma: boolean
|
||||
entries: DictionaryEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps senses borrowed from a resolved lemma under their own heading, so a
|
||||
* reader looking up "candelabras" can tell which meaning belongs to which form.
|
||||
*/
|
||||
const senseGroups = computed<SenseGroup[]>(() => {
|
||||
const res = result.value
|
||||
if (!res) return []
|
||||
|
||||
const groups: SenseGroup[] = []
|
||||
for (const entry of res.entries) {
|
||||
const current = groups[groups.length - 1]
|
||||
if (current && current.word === entry.sourceWord) {
|
||||
current.entries.push(entry)
|
||||
continue
|
||||
}
|
||||
groups.push({ word: entry.sourceWord, isLemma: entry.sourceWord !== res.word, entries: [entry] })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const FALLBACK_WIDTH = 288
|
||||
const FALLBACK_HEIGHT = 220
|
||||
@@ -158,13 +184,18 @@ watch([loading, result, notFound, hasError], async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-for="entry in result.entries" :key="entry.partOfSpeech" class="space-y-1">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-primary">{{ entry.partOfSpeech }}</p>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="(def, i) in entry.definitions" :key="i" class="text-xs text-foreground leading-relaxed">
|
||||
{{ def.definition }}
|
||||
</li>
|
||||
</ul>
|
||||
<div v-for="group in senseGroups" :key="group.word" class="space-y-1">
|
||||
<p v-if="group.isLemma" class="text-xs font-semibold text-foreground pt-2 border-t border-border">
|
||||
{{ group.word }}
|
||||
</p>
|
||||
<div v-for="entry in group.entries" :key="`${group.word}:${entry.partOfSpeech}`" class="space-y-1">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-primary">{{ entry.partOfSpeech }}</p>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="(def, i) in entry.definitions" :key="i" class="text-xs text-foreground leading-relaxed">
|
||||
{{ def.definition }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,10 +31,12 @@ function makeResult(overrides: Partial<DictionaryResult> = {}): DictionaryResult
|
||||
{ definition: 'An expression of greeting.', example: 'She said hello.' },
|
||||
{ definition: 'A call to attract attention.', example: null },
|
||||
],
|
||||
sourceWord: 'hello',
|
||||
},
|
||||
{
|
||||
partOfSpeech: 'verb',
|
||||
definitions: [{ definition: 'To greet with hello.', example: null }],
|
||||
sourceWord: 'hello',
|
||||
},
|
||||
],
|
||||
provider: 'free-dictionary',
|
||||
@@ -42,6 +44,21 @@ function makeResult(overrides: Partial<DictionaryResult> = {}): DictionaryResult
|
||||
}
|
||||
}
|
||||
|
||||
/** A result shaped like a resolved inflection chain: candelabras -> candelabra -> candelabrum. */
|
||||
function makeChainResult(): DictionaryResult {
|
||||
return {
|
||||
word: 'candelabras',
|
||||
phonetic: null,
|
||||
audioUrl: null,
|
||||
entries: [
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'plural of candelabra', example: null }], sourceWord: 'candelabras' },
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'A single candelabrum.', example: null }], sourceWord: 'candelabra' },
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'A candle holder with branches.', example: null }], sourceWord: 'candelabrum' },
|
||||
],
|
||||
provider: 'wiktionary',
|
||||
}
|
||||
}
|
||||
|
||||
function parseStylePx(style: string | undefined, prop: 'left' | 'top') {
|
||||
if (!style) return NaN
|
||||
const match = style.match(new RegExp(`${prop}:\\s*(-?\\d+(?:\\.\\d+)?)px`))
|
||||
@@ -160,6 +177,52 @@ describe('DictionaryPopover', () => {
|
||||
expect(wrapper.text()).toContain('To greet with hello.')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolved inflection chains (issue #1020)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders senses borrowed from a resolved lemma under their own heading', async () => {
|
||||
mockLookup.mockResolvedValue(makeChainResult())
|
||||
const wrapper = await mountPopover({ word: 'candelabras' })
|
||||
await flushPromises()
|
||||
|
||||
// The looked-up word is already named in the popover header, so only the
|
||||
// borrowed groups need a heading of their own.
|
||||
const headings = wrapper.findAll('p.font-semibold').map((p) => p.text())
|
||||
expect(headings).toEqual(['candelabra', 'candelabrum'])
|
||||
expect(wrapper.text()).toContain('A candle holder with branches.')
|
||||
})
|
||||
|
||||
it('does not add a lemma heading when every sense belongs to the looked-up word', async () => {
|
||||
mockLookup.mockResolvedValue(makeResult())
|
||||
const wrapper = await mountPopover()
|
||||
await flushPromises()
|
||||
|
||||
// Only the result header itself is emphasised; no per-group heading is added.
|
||||
expect(wrapper.findAll('p.font-semibold')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps consecutive parts of speech for one word under a single heading', async () => {
|
||||
mockLookup.mockResolvedValue({
|
||||
word: 'leaves',
|
||||
phonetic: null,
|
||||
audioUrl: null,
|
||||
entries: [
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'plural of leaf', example: null }], sourceWord: 'leaves' },
|
||||
{ partOfSpeech: 'Verb', definitions: [{ definition: 'third-person singular of leave', example: null }], sourceWord: 'leaves' },
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'A flattened plant organ.', example: null }], sourceWord: 'leaf' },
|
||||
],
|
||||
provider: 'wiktionary',
|
||||
})
|
||||
const wrapper = await mountPopover({ word: 'leaves' })
|
||||
await flushPromises()
|
||||
|
||||
// Both "leaves" blocks stay under the header with no repeated heading; only
|
||||
// the borrowed "leaf" block is labelled.
|
||||
expect(wrapper.findAll('p.font-semibold').map((p) => p.text())).toEqual(['leaf'])
|
||||
expect(wrapper.findAll('p.uppercase').map((p) => p.text())).toEqual(['Noun', 'Verb', 'Noun'])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Audio playback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -25,9 +25,10 @@ function makeFreeDictResponse(word = 'hello', overrides: Record<string, unknown>
|
||||
]
|
||||
}
|
||||
|
||||
function makeWiktionaryResponse(overrides: Record<string, unknown> = {}) {
|
||||
/** Wiktionary keys its response by language; tests must supply the section they ask for. */
|
||||
function makeWiktionaryResponse(lang = 'fr', overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
en: [
|
||||
[lang]: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [
|
||||
@@ -38,12 +39,16 @@ function makeWiktionaryResponse(overrides: Record<string, unknown> = {}) {
|
||||
{ definition: 'A call for attention.', examples: [] },
|
||||
],
|
||||
},
|
||||
...((overrides['extraEntries'] as unknown[]) ?? []),
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** One Wiktionary "page" of English noun senses, for chain fixtures. */
|
||||
function enNoun(...definitions: string[]) {
|
||||
return { en: [{ partOfSpeech: 'Noun', definitions: definitions.map((definition) => ({ definition, examples: [] })) }] }
|
||||
}
|
||||
|
||||
describe('useDictionary', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
|
||||
|
||||
@@ -78,6 +83,30 @@ describe('useDictionary', () => {
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
/** Serves a fixed set of Wiktionary pages by word, 404ing anything else. */
|
||||
function serveWiktionary(pages: Record<string, unknown>) {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const word = decodeURIComponent(String(input).split('/definition/')[1] ?? '')
|
||||
return word in pages ? mockOk(pages[word]) : mockStatus(404)
|
||||
})
|
||||
}
|
||||
|
||||
/** Serves a fixed set of Free Dictionary pages by word, 404ing anything else. */
|
||||
function serveFreeDictionary(pages: Record<string, unknown>) {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const word = decodeURIComponent(String(input).split('/entries/en/')[1] ?? '')
|
||||
return word in pages ? mockOk(pages[word]) : mockStatus(404)
|
||||
})
|
||||
}
|
||||
|
||||
/** Words asked of Wiktionary, ignoring the Free Dictionary probe an English lookup makes first. */
|
||||
function requestedWords() {
|
||||
return fetchMock.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.filter((url) => url.includes('wiktionary.org'))
|
||||
.map((url) => decodeURIComponent(url.split('/definition/')[1] ?? ''))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// English - Free Dictionary success path
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -178,7 +207,7 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('English: falls back to Wiktionary when Free Dictionary returns 404', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse('en')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
|
||||
@@ -190,7 +219,7 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('English: falls back to Wiktionary when Free Dictionary throws a network error', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('network error')).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockRejectedValueOnce(new Error('network error')).mockResolvedValueOnce(mockOk(makeWiktionaryResponse('en')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
|
||||
@@ -199,21 +228,21 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('English: falls back to Wiktionary when Free Dictionary returns a non-404 server error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(500)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(500)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse('en')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
expect(result!.provider).toBe('wiktionary')
|
||||
})
|
||||
|
||||
it('English: falls back to Wiktionary when Free Dictionary returns empty array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk([])).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockOk([])).mockResolvedValueOnce(mockOk(makeWiktionaryResponse('en')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
expect(result!.provider).toBe('wiktionary')
|
||||
})
|
||||
|
||||
it('English: falls back to Wiktionary when Free Dictionary returns non-array response', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk({ title: 'No Definitions Found' })).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockOk({ title: 'No Definitions Found' })).mockResolvedValueOnce(mockOk(makeWiktionaryResponse('en')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
expect(result!.provider).toBe('wiktionary')
|
||||
@@ -242,7 +271,7 @@ describe('useDictionary', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('Non-English: calls Wiktionary directly without calling Free Dictionary', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse('fr')))
|
||||
const { lookup } = await load()
|
||||
await lookup('bonjour', 'fr')
|
||||
|
||||
@@ -251,7 +280,7 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('Non-English: returns normalized Wiktionary result', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse('fr')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('bonjour', 'fr')
|
||||
|
||||
@@ -273,6 +302,351 @@ describe('useDictionary', () => {
|
||||
await expect(lookup('xyz', 'de')).rejects.toThrow('Wiktionary API error: 500')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Language sectioning (issue #1020: Latin senses leaked into English lookups)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('Wiktionary: returns only the requested language section', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404)).mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A single candelabrum.', examples: [] }] }],
|
||||
la: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'nominative/accusative/vocative plural of candēlābrum', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('candelabra', 'en')
|
||||
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
expect(result!.entries[0]!.definitions.map((d) => d.definition)).toEqual(['A single candelabrum.'])
|
||||
})
|
||||
|
||||
it('Wiktionary: does not fall back to another language section when the requested one is missing', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'English meaning.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('test', 'de')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('Wiktionary: returns null when the requested language section is not an array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
fr: 'not-an-array',
|
||||
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A greeting.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hi', 'fr')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Inflection pointers - words the reader looked up that only point elsewhere
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('pointer: follows a plural to the word that carries the definition', async () => {
|
||||
serveWiktionary({
|
||||
mice: enNoun('plural of mouse'),
|
||||
mouse: enNoun('Any small rodent of the genus Mus.'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('mice', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => [e.sourceWord, e.definitions[0]!.definition])).toEqual([
|
||||
['mice', 'plural of mouse'],
|
||||
['mouse', 'Any small rodent of the genus Mus.'],
|
||||
])
|
||||
})
|
||||
|
||||
it('pointer: follows a two-hop chain (issue #1020 candelabras case)', async () => {
|
||||
serveWiktionary({
|
||||
candelabras: enNoun('plural of candelabra'),
|
||||
candelabra: enNoun('plural of candelabrum', 'A single candelabrum.'),
|
||||
candelabrum: enNoun('A candle holder with branches to hold more than one candle.'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('candelabras', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['candelabras', 'candelabra', 'candelabrum'])
|
||||
expect(result!.entries.at(-1)!.definitions[0]!.definition).toBe('A candle holder with branches to hold more than one candle.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['plural of walk', 'walk'],
|
||||
['simple past and past participle of walk', 'walk'],
|
||||
['present participle and gerund of run', 'run'],
|
||||
['third-person singular simple present indicative of leave', 'leave'],
|
||||
['Alternative spelling of theater.', 'theater'],
|
||||
['Obsolete form of over.', 'over'],
|
||||
['genitive singular of wolf', 'wolf'],
|
||||
['(informal) plural of datum', 'datum'],
|
||||
])('pointer: recognises %j as pointing at %j', async (pointer, lemma) => {
|
||||
serveWiktionary({ probe: enNoun(pointer), [lemma]: enNoun('The real meaning.') })
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('probe', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['probe', lemma])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['A collection of sheets of paper bound together.'],
|
||||
['Informal form of address used by juniors.'],
|
||||
['An act or instance of running.'],
|
||||
['One of the two halves of a whole.'],
|
||||
['plural of ice cream'],
|
||||
['The state of being present.'],
|
||||
])('pointer: leaves the real definition %j alone', async (definition) => {
|
||||
serveWiktionary({ probe: enNoun(definition) })
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('probe', 'en')
|
||||
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
expect(requestedWords()).toEqual(['probe'])
|
||||
})
|
||||
|
||||
it('pointer: ignores a pointer that is not the leading sense of its block', async () => {
|
||||
// "book" leads with a real definition; "simple past of bake" is sense 10 and is trivia.
|
||||
serveWiktionary({
|
||||
book: enNoun('A collection of sheets of paper bound together.', 'simple past of bake'),
|
||||
bake: enNoun('To cook in an oven.'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('book', 'en')
|
||||
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
expect(requestedWords()).toEqual(['book'])
|
||||
})
|
||||
|
||||
it('pointer: follows the leading pointer of each part-of-speech block', async () => {
|
||||
serveWiktionary({
|
||||
leaves: {
|
||||
en: [
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'plural of leaf', examples: [] }] },
|
||||
{ partOfSpeech: 'Verb', definitions: [{ definition: 'third-person singular simple present indicative of leave', examples: [] }] },
|
||||
],
|
||||
},
|
||||
leaf: enNoun('A flattened organ of a plant.'),
|
||||
leave: enNoun('To depart.'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('leaves', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['leaves', 'leaves', 'leaf', 'leave'])
|
||||
})
|
||||
|
||||
it('pointer: does not follow a word that points at itself', async () => {
|
||||
serveWiktionary({ ditto: enNoun('plural of ditto') })
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('ditto', 'en')
|
||||
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
expect(requestedWords()).toEqual(['ditto'])
|
||||
})
|
||||
|
||||
it('pointer: survives a cycle between two words', async () => {
|
||||
serveWiktionary({
|
||||
alpha: enNoun('plural of beta'),
|
||||
beta: enNoun('plural of alpha'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('alpha', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['alpha', 'beta'])
|
||||
expect(requestedWords()).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('pointer: stops after the maximum chain depth', async () => {
|
||||
serveWiktionary({
|
||||
one: enNoun('plural of two'),
|
||||
two: enNoun('plural of three'),
|
||||
three: enNoun('plural of four'),
|
||||
four: enNoun('The real meaning.'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('one', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['one', 'two', 'three'])
|
||||
expect(requestedWords()).not.toContain('four')
|
||||
})
|
||||
|
||||
it('pointer: caps how many lemmas a single lookup may fetch', async () => {
|
||||
serveWiktionary({
|
||||
many: {
|
||||
en: [
|
||||
{ partOfSpeech: 'Noun', definitions: [{ definition: 'plural of alpha', examples: [] }] },
|
||||
{ partOfSpeech: 'Verb', definitions: [{ definition: 'plural of bravo', examples: [] }] },
|
||||
{ partOfSpeech: 'Adjective', definitions: [{ definition: 'plural of charlie', examples: [] }] },
|
||||
{ partOfSpeech: 'Adverb', definitions: [{ definition: 'plural of delta', examples: [] }] },
|
||||
],
|
||||
},
|
||||
alpha: enNoun('First.'),
|
||||
bravo: enNoun('Second.'),
|
||||
charlie: enNoun('Third.'),
|
||||
delta: enNoun('Fourth.'),
|
||||
})
|
||||
const { lookup } = await load()
|
||||
await lookup('many', 'en')
|
||||
|
||||
expect(requestedWords()).toEqual(['many', 'alpha', 'bravo', 'charlie'])
|
||||
})
|
||||
|
||||
it('pointer: a lemma that 404s still leaves the primary result intact', async () => {
|
||||
serveWiktionary({ mice: enNoun('plural of mouse') })
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('mice', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['mice'])
|
||||
expect(requestedWords()).toEqual(['mice', 'mouse'])
|
||||
})
|
||||
|
||||
it('pointer: a lemma that throws still leaves the primary result intact', async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const word = decodeURIComponent(String(input).split('/definition/')[1] ?? '')
|
||||
if (word === 'mice') return mockOk(enNoun('plural of mouse'))
|
||||
throw new Error('network error')
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('mice', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['mice'])
|
||||
})
|
||||
|
||||
it('pointer: a lemma that 500s still leaves the primary result intact', async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const word = decodeURIComponent(String(input).split('/definition/')[1] ?? '')
|
||||
return word === 'mice' ? mockOk(enNoun('plural of mouse')) : mockStatus(500)
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('mice', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['mice'])
|
||||
})
|
||||
|
||||
it('pointer: resolves lemmas on the same Wiktionary subdomain', async () => {
|
||||
serveWiktionary({ mice: enNoun('plural of mouse'), mouse: enNoun('A small rodent.') })
|
||||
const { lookup } = await load()
|
||||
await lookup('mice', 'en')
|
||||
|
||||
expect(fetchMock.mock.calls.map((c) => String(c[0])).filter((u) => u.includes('wiktionary'))).toEqual([
|
||||
'https://en.wiktionary.org/api/rest_v1/page/definition/mice',
|
||||
'https://en.wiktionary.org/api/rest_v1/page/definition/mouse',
|
||||
])
|
||||
})
|
||||
|
||||
it('pointer: leaves non-English inflection glosses alone', async () => {
|
||||
// fr.wiktionary writes its glosses in French ("pluriel de chat"). Pointer
|
||||
// detection is deliberately English-only, so these are shown as-is rather
|
||||
// than guessed at. Non-English lookups still benefit from language filtering.
|
||||
fetchMock.mockResolvedValueOnce(mockOk({ fr: [{ partOfSpeech: 'Nom', definitions: [{ definition: 'pluriel de chat', examples: [] }] }] }))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('chats', 'fr')
|
||||
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pointer: resolves lemmas through Free Dictionary when that provider answered', async () => {
|
||||
serveFreeDictionary({
|
||||
mice: [{ word: 'mice', meanings: [{ partOfSpeech: 'noun', definitions: [{ definition: 'plural of mouse' }] }] }],
|
||||
mouse: [{ word: 'mouse', meanings: [{ partOfSpeech: 'noun', definitions: [{ definition: 'A small rodent.' }] }] }],
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('mice', 'en')
|
||||
|
||||
expect(result!.provider).toBe('free-dictionary')
|
||||
expect(result!.entries.map((e) => e.sourceWord)).toEqual(['mice', 'mouse'])
|
||||
expect(fetchMock.mock.calls.every((c) => String(c[0]).includes('api.dictionaryapi.dev'))).toBe(true)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Source word attribution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('sourceWord: root entries are attributed to the looked-up word', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeFreeDictResponse()))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
expect(result!.entries.every((e) => e.sourceWord === 'hello')).toBe(true)
|
||||
})
|
||||
|
||||
it('sourceWord: uses the canonical word the provider reported, not the raw input', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeFreeDictResponse('Hello')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'en')
|
||||
expect(result!.word).toBe('Hello')
|
||||
expect(result!.entries[0]!.sourceWord).toBe('Hello')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Block merging and sense caps
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('merge: collapses blocks that share a part of speech', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk([
|
||||
{
|
||||
word: 'candelabra',
|
||||
meanings: [
|
||||
{ partOfSpeech: 'noun', definitions: [{ definition: 'A single candelabrum.' }] },
|
||||
{ partOfSpeech: 'noun', definitions: [{ definition: 'A candle holder.' }] },
|
||||
],
|
||||
},
|
||||
]),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('candelabra', 'en')
|
||||
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
expect(result!.entries[0]!.definitions.map((d) => d.definition)).toEqual(['A single candelabrum.', 'A candle holder.'])
|
||||
})
|
||||
|
||||
it('merge: keeps distinct parts of speech separate and in first-seen order', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk([
|
||||
{
|
||||
word: 'run',
|
||||
meanings: [
|
||||
{ partOfSpeech: 'verb', definitions: [{ definition: 'To move swiftly.' }] },
|
||||
{ partOfSpeech: 'noun', definitions: [{ definition: 'An act of running.' }] },
|
||||
{ partOfSpeech: 'verb', definitions: [{ definition: 'To operate.' }] },
|
||||
],
|
||||
},
|
||||
]),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('run', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.partOfSpeech)).toEqual(['verb', 'noun'])
|
||||
expect(result!.entries[0]!.definitions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cap: limits how many senses a single part-of-speech block renders', async () => {
|
||||
serveWiktionary({ mouse: enNoun('One.', 'Two.', 'Three.', 'Four.', 'Five.', 'Six.', 'Seven.') })
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('mouse', 'en')
|
||||
|
||||
expect(result!.entries[0]!.definitions.map((d) => d.definition)).toEqual(['One.', 'Two.', 'Three.', 'Four.', 'Five.'])
|
||||
})
|
||||
|
||||
it('cap: applies per block rather than across the whole result', async () => {
|
||||
serveWiktionary({
|
||||
probe: {
|
||||
en: [
|
||||
{ partOfSpeech: 'Noun', definitions: Array.from({ length: 6 }, (_, i) => ({ definition: `Noun ${i}.`, examples: [] })) },
|
||||
{ partOfSpeech: 'Verb', definitions: Array.from({ length: 6 }, (_, i) => ({ definition: `Verb ${i}.`, examples: [] })) },
|
||||
],
|
||||
},
|
||||
})
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('probe', 'en')
|
||||
|
||||
expect(result!.entries.map((e) => e.definitions.length)).toEqual([5, 5])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTML stripping in Wiktionary definitions
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -280,7 +654,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: strips HTML tags from definition strings', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: '<span class="term">A greeting</span> used daily.', examples: [] }],
|
||||
@@ -296,7 +670,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: decodes HTML entities in definitions', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: 'A & B <test> "quoted" 'apos'', examples: [] }],
|
||||
@@ -312,7 +686,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: decodes numeric HTML entities', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: 'Say 'hi'', examples: [] }],
|
||||
@@ -328,7 +702,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: strips script tags and their content from definitions', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: 'Normal text.<script>alert(1)</script>', examples: [] }],
|
||||
@@ -341,13 +715,48 @@ describe('useDictionary', () => {
|
||||
const def = result!.entries[0]!.definitions[0]!.definition
|
||||
expect(def).not.toContain('<script>')
|
||||
expect(def).not.toContain('</script>')
|
||||
expect(def).toContain('Normal text.')
|
||||
expect(def).not.toContain('alert(1)')
|
||||
expect(def).toBe('Normal text.')
|
||||
})
|
||||
|
||||
it('Wiktionary: strips inline style blocks rather than rendering their CSS', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Verb',
|
||||
definitions: [
|
||||
{
|
||||
definition: 'Used to express evaluations<style>.mw-parser-output .deprecated{color:olivedrab}</style> [with complement].',
|
||||
examples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('fare', 'fr')
|
||||
|
||||
expect(result!.entries[0]!.definitions[0]!.definition).toBe('Used to express evaluations [with complement].')
|
||||
})
|
||||
|
||||
it('Wiktionary: collapses newlines and runs of whitespace inside a definition', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
fr: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A greeting\n\n used daily.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hi', 'fr')
|
||||
|
||||
expect(result!.entries[0]!.definitions[0]!.definition).toBe('A greeting used daily.')
|
||||
})
|
||||
|
||||
it('Wiktionary: handles partial/unclosed HTML tags that old regex could not strip', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: '<script src="evil.js"', examples: [] }],
|
||||
@@ -365,7 +774,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: trims whitespace-only definitions after stripping', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [
|
||||
@@ -383,7 +792,7 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('Wiktionary: strips HTML from example text', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse('fr')))
|
||||
const { lookup } = await load()
|
||||
// Use non-English lang to hit Wiktionary directly (avoiding FreeDictionary fallback complexity)
|
||||
const result = await lookup('hello', 'fr')
|
||||
@@ -393,7 +802,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: handles string example (not object)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: 'A greeting.', examples: ['<b>Hi</b> there.'] }],
|
||||
@@ -409,7 +818,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: example is null when examples array is empty', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A greeting.', examples: [] }] }],
|
||||
fr: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A greeting.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
@@ -420,7 +829,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: skips entries with empty definitions after stripping', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [
|
||||
@@ -440,7 +849,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: treats non-array examples field as no examples', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: 'A greeting.', examples: 'not an array' }],
|
||||
@@ -456,7 +865,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: skips example object that has no string text field', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [{ definition: 'A greeting.', examples: [{ text: 42 }, { text: null }] }],
|
||||
@@ -476,22 +885,10 @@ describe('useDictionary', () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('Wiktionary: skips language sections whose value is not an array', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: 'not-an-array',
|
||||
fr: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A greeting.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hi', 'fr')
|
||||
expect(result!.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('Wiktionary: skips entries that are not objects', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [null, 'string-entry', { partOfSpeech: 'Noun', definitions: [{ definition: 'Valid.', examples: [] }] }],
|
||||
fr: [null, 'string-entry', { partOfSpeech: 'Noun', definitions: [{ definition: 'Valid.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
@@ -502,7 +899,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: treats non-array definitions field as no definitions (entry skipped)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [{ partOfSpeech: 'Noun', definitions: 'not-an-array' }],
|
||||
fr: [{ partOfSpeech: 'Noun', definitions: 'not-an-array' }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
@@ -513,7 +910,7 @@ describe('useDictionary', () => {
|
||||
it('Wiktionary: skips definition objects where definition field is not a string', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [
|
||||
fr: [
|
||||
{
|
||||
partOfSpeech: 'Noun',
|
||||
definitions: [
|
||||
@@ -530,18 +927,6 @@ describe('useDictionary', () => {
|
||||
expect(result!.entries[0]!.definitions[0]!.definition).toBe('Valid.')
|
||||
})
|
||||
|
||||
it('Wiktionary: flattens multiple language sections', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockOk({
|
||||
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'English meaning.', examples: [] }] }],
|
||||
fr: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'French meaning.', examples: [] }] }],
|
||||
}),
|
||||
)
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('test', 'fr')
|
||||
expect(result!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Language normalization
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -555,7 +940,7 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('lang normalization: fr-FR routes directly to Wiktionary', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse('fr')))
|
||||
const { lookup } = await load()
|
||||
await lookup('bonjour', 'fr-FR')
|
||||
expect(fetchMock.mock.calls[0]![0]).toContain('fr.wiktionary.org')
|
||||
@@ -585,7 +970,7 @@ describe('useDictionary', () => {
|
||||
})
|
||||
|
||||
it('lang normalization: unknown long language labels fall back to en', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse('en')))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'some-unknown-language')
|
||||
expect(result!.provider).toBe('wiktionary')
|
||||
|
||||
@@ -94,8 +94,146 @@ const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
chinese: 'zh',
|
||||
}
|
||||
|
||||
const MAX_DEFINITIONS_PER_ENTRY = 5
|
||||
const MAX_LEMMA_DEPTH = 2
|
||||
const MAX_LEMMA_LOOKUPS = 3
|
||||
|
||||
/**
|
||||
* Words allowed before "of" in an inflection gloss such as "simple past and
|
||||
* past participle of walk". Requiring every leading word to be grammatical is
|
||||
* what keeps ordinary definitions ("Informal form of address used by...") from
|
||||
* being mistaken for pointers.
|
||||
*/
|
||||
const GRAMMAR_TERMS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'ablative',
|
||||
'abbreviation',
|
||||
'accusative',
|
||||
'acronym',
|
||||
'active',
|
||||
'adjective',
|
||||
'adverb',
|
||||
'alternate',
|
||||
'alternative',
|
||||
'aorist',
|
||||
'archaic',
|
||||
'attributive',
|
||||
'augmentative',
|
||||
'capitalized',
|
||||
'clipping',
|
||||
'common',
|
||||
'comparative',
|
||||
'conditional',
|
||||
'conjugation',
|
||||
'construct',
|
||||
'contraction',
|
||||
'dated',
|
||||
'dative',
|
||||
'declension',
|
||||
'definite',
|
||||
'diminutive',
|
||||
'dual',
|
||||
'elative',
|
||||
'essive',
|
||||
'feminine',
|
||||
'first-person',
|
||||
'form',
|
||||
'forms',
|
||||
'future',
|
||||
'genitive',
|
||||
'gerund',
|
||||
'illative',
|
||||
'imperative',
|
||||
'imperfect',
|
||||
'imperfective',
|
||||
'indefinite',
|
||||
'indicative',
|
||||
'infinitive',
|
||||
'inflected',
|
||||
'inflection',
|
||||
'inflections',
|
||||
'initialism',
|
||||
'instrumental',
|
||||
'letter-case',
|
||||
'locative',
|
||||
'lowercase',
|
||||
'masculine',
|
||||
'misspelling',
|
||||
'neuter',
|
||||
'nominative',
|
||||
'nonstandard',
|
||||
'noun',
|
||||
'obsolete',
|
||||
'participle',
|
||||
'particle',
|
||||
'partitive',
|
||||
'passive',
|
||||
'past',
|
||||
'perfect',
|
||||
'perfective',
|
||||
'pluperfect',
|
||||
'plural',
|
||||
'positive',
|
||||
'prepositional',
|
||||
'present',
|
||||
'preterite',
|
||||
'second-person',
|
||||
'simple',
|
||||
'singular',
|
||||
'spelling',
|
||||
'substantive',
|
||||
'superlative',
|
||||
'supine',
|
||||
'tense',
|
||||
'third-person',
|
||||
'uppercase',
|
||||
'verb',
|
||||
'verbal',
|
||||
'vocative',
|
||||
])
|
||||
|
||||
const FILLER_TERMS = new Set(['a', 'an', 'the', 'and', 'or'])
|
||||
|
||||
const POINTER_PATTERN = /^(?:\([^)]*\)\s*)?([\p{L}\p{M}/ -]+?)\s+of\s+([\p{L}\p{M}][\p{L}\p{M}'’-]*)\s*[.:;,]?$/u
|
||||
|
||||
interface SenseBlock {
|
||||
partOfSpeech: string
|
||||
definitions: DictionaryDefinition[]
|
||||
}
|
||||
|
||||
type BlockFetcher = (word: string) => Promise<SenseBlock[]>
|
||||
|
||||
/**
|
||||
* Returns the lemma a definition merely points at, or null when the definition
|
||||
* stands on its own. "plural of candelabra" tells the reader nothing, so the
|
||||
* lookup follows it; "A candle holder." is an answer, so it does not.
|
||||
*/
|
||||
function pointerLemma(definition: string): string | null {
|
||||
const match = POINTER_PATTERN.exec(definition.trim())
|
||||
if (!match) return null
|
||||
|
||||
const modifiers = (match[1] ?? '')
|
||||
.toLowerCase()
|
||||
.split(/[\s/]+/)
|
||||
.filter(Boolean)
|
||||
if (modifiers.length === 0) return null
|
||||
if (!modifiers.every((term) => GRAMMAR_TERMS.has(term))) return null
|
||||
if (modifiers.every((term) => FILLER_TERMS.has(term))) return null
|
||||
|
||||
return (match[2] ?? '').toLowerCase()
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return (new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '').trim()
|
||||
const body = new DOMParser().parseFromString(html, 'text/html').body
|
||||
// textContent includes the text inside script and style elements, and
|
||||
// Wiktionary ships inline <style> blocks with some senses, so their CSS would
|
||||
// otherwise be rendered as part of the definition.
|
||||
for (const el of body.querySelectorAll('script, style')) el.remove()
|
||||
return (body.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function normalizeLang(lang: string): string {
|
||||
@@ -138,7 +276,7 @@ function extractPhonetics(entry: Record<string, unknown>): { phonetic: string |
|
||||
return { phonetic, audioUrl }
|
||||
}
|
||||
|
||||
function parseMeanings(meanings: Array<Record<string, unknown>>): DictionaryEntry[] {
|
||||
function parseMeanings(meanings: Array<Record<string, unknown>>): SenseBlock[] {
|
||||
return meanings.map((m) => {
|
||||
const partOfSpeech = typeof m['partOfSpeech'] === 'string' ? m['partOfSpeech'] : ''
|
||||
const rawDefs = Array.isArray(m['definitions']) ? (m['definitions'] as Array<Record<string, unknown>>) : []
|
||||
@@ -150,7 +288,39 @@ function parseMeanings(meanings: Array<Record<string, unknown>>): DictionaryEntr
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchFreeDictionary(word: string): Promise<DictionaryResult | null> {
|
||||
/**
|
||||
* Collapses blocks that share a part of speech. Both providers split a word
|
||||
* across several same-part-of-speech blocks (one per etymology), which would
|
||||
* otherwise render as repeated "NOUN" headings and hide whether a pointer is
|
||||
* the leading sense.
|
||||
*/
|
||||
function mergeBlocks(blocks: SenseBlock[]): SenseBlock[] {
|
||||
const merged: SenseBlock[] = []
|
||||
const byPartOfSpeech = new Map<string, SenseBlock>()
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.definitions.length === 0) continue
|
||||
const existing = byPartOfSpeech.get(block.partOfSpeech)
|
||||
if (existing) {
|
||||
existing.definitions.push(...block.definitions)
|
||||
continue
|
||||
}
|
||||
const copy: SenseBlock = { partOfSpeech: block.partOfSpeech, definitions: [...block.definitions] }
|
||||
byPartOfSpeech.set(block.partOfSpeech, copy)
|
||||
merged.push(copy)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
interface ProviderPayload {
|
||||
word: string
|
||||
phonetic: string | null
|
||||
audioUrl: string | null
|
||||
blocks: SenseBlock[]
|
||||
}
|
||||
|
||||
async function fetchFreeDictionaryPayload(word: string): Promise<ProviderPayload | null> {
|
||||
const res = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`)
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) throw new Error(`Free Dictionary API error: ${res.status}`)
|
||||
@@ -161,61 +331,63 @@ async function fetchFreeDictionary(word: string): Promise<DictionaryResult | nul
|
||||
const entry = data[0] as Record<string, unknown>
|
||||
const { phonetic, audioUrl } = extractPhonetics(entry)
|
||||
const meanings = Array.isArray(entry['meanings']) ? (entry['meanings'] as Array<Record<string, unknown>>) : []
|
||||
const entries = parseMeanings(meanings)
|
||||
const blocks = mergeBlocks(parseMeanings(meanings))
|
||||
|
||||
if (entries.length === 0) return null
|
||||
if (blocks.length === 0) return null
|
||||
|
||||
return {
|
||||
word: typeof entry['word'] === 'string' ? entry['word'] : word,
|
||||
phonetic,
|
||||
audioUrl,
|
||||
entries,
|
||||
provider: 'free-dictionary',
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
function parseWiktionaryEntries(data: Record<string, unknown>): DictionaryEntry[] {
|
||||
const entries: DictionaryEntry[] = []
|
||||
function parseWiktionaryBlocks(data: Record<string, unknown>, lang: string): SenseBlock[] {
|
||||
const blocks: SenseBlock[] = []
|
||||
|
||||
for (const langEntries of Object.values(data)) {
|
||||
if (!Array.isArray(langEntries)) continue
|
||||
for (const entry of langEntries) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const e = entry as Record<string, unknown>
|
||||
const partOfSpeech = typeof e['partOfSpeech'] === 'string' ? e['partOfSpeech'] : ''
|
||||
const rawDefs = Array.isArray(e['definitions']) ? (e['definitions'] as Array<Record<string, unknown>>) : []
|
||||
const definitions: DictionaryDefinition[] = []
|
||||
// Wiktionary keys its response by language and a single page carries every
|
||||
// language that spells the word the same way, so only the requested section
|
||||
// is ours. Reading them all mixed Latin and Faroese into English lookups.
|
||||
const langEntries = data[lang]
|
||||
if (!Array.isArray(langEntries)) return blocks
|
||||
|
||||
for (const d of rawDefs) {
|
||||
const def = typeof d['definition'] === 'string' ? stripHtml(d['definition']) : ''
|
||||
if (!def) continue
|
||||
const rawExamples = Array.isArray(d['examples']) ? (d['examples'] as unknown[]) : []
|
||||
let example: string | null = null
|
||||
for (const ex of rawExamples) {
|
||||
if (typeof ex === 'string') {
|
||||
example = stripHtml(ex)
|
||||
for (const entry of langEntries) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const e = entry as Record<string, unknown>
|
||||
const partOfSpeech = typeof e['partOfSpeech'] === 'string' ? e['partOfSpeech'] : ''
|
||||
const rawDefs = Array.isArray(e['definitions']) ? (e['definitions'] as Array<Record<string, unknown>>) : []
|
||||
const definitions: DictionaryDefinition[] = []
|
||||
|
||||
for (const d of rawDefs) {
|
||||
const def = typeof d['definition'] === 'string' ? stripHtml(d['definition']) : ''
|
||||
if (!def) continue
|
||||
const rawExamples = Array.isArray(d['examples']) ? (d['examples'] as unknown[]) : []
|
||||
let example: string | null = null
|
||||
for (const ex of rawExamples) {
|
||||
if (typeof ex === 'string') {
|
||||
example = stripHtml(ex)
|
||||
break
|
||||
} else if (ex && typeof ex === 'object') {
|
||||
const t = (ex as Record<string, unknown>)['text']
|
||||
if (typeof t === 'string') {
|
||||
example = stripHtml(t)
|
||||
break
|
||||
} else if (ex && typeof ex === 'object') {
|
||||
const t = (ex as Record<string, unknown>)['text']
|
||||
if (typeof t === 'string') {
|
||||
example = stripHtml(t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
definitions.push({ definition: def, example })
|
||||
}
|
||||
definitions.push({ definition: def, example })
|
||||
}
|
||||
|
||||
if (definitions.length > 0) {
|
||||
entries.push({ partOfSpeech, definitions })
|
||||
}
|
||||
if (definitions.length > 0) {
|
||||
blocks.push({ partOfSpeech, definitions })
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
return blocks
|
||||
}
|
||||
|
||||
async function fetchWiktionary(word: string, lang: string): Promise<DictionaryResult | null> {
|
||||
async function fetchWiktionaryPayload(word: string, lang: string): Promise<ProviderPayload | null> {
|
||||
const res = await fetch(`https://${encodeURIComponent(lang)}.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`)
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) throw new Error(`Wiktionary API error: ${res.status}`)
|
||||
@@ -223,16 +395,61 @@ async function fetchWiktionary(word: string, lang: string): Promise<DictionaryRe
|
||||
const data: unknown = await res.json()
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
const entries = parseWiktionaryEntries(data as Record<string, unknown>)
|
||||
if (entries.length === 0) return null
|
||||
const blocks = mergeBlocks(parseWiktionaryBlocks(data as Record<string, unknown>, lang))
|
||||
if (blocks.length === 0) return null
|
||||
|
||||
return {
|
||||
word,
|
||||
phonetic: null,
|
||||
audioUrl: null,
|
||||
entries,
|
||||
provider: 'wiktionary',
|
||||
return { word, phonetic: null, audioUrl: null, blocks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks inflection pointers breadth-first so a lookup that lands on a pure
|
||||
* inflection ("candelabras" -> "candelabra" -> "candelabrum") still reaches a
|
||||
* word that carries a real definition. Only a pointer in the leading sense of
|
||||
* its block is followed: a pointer buried at sense 69 of "run" is trivia, not
|
||||
* the answer the reader is missing.
|
||||
*/
|
||||
async function expandPointers(root: ProviderPayload, fetchBlocks: BlockFetcher): Promise<DictionaryEntry[]> {
|
||||
const entries: DictionaryEntry[] = []
|
||||
const visited = new Set<string>([root.word.toLowerCase()])
|
||||
const queue: Array<{ word: string; blocks: SenseBlock[]; depth: number }> = [{ word: root.word, blocks: root.blocks, depth: 0 }]
|
||||
let lookups = 0
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
|
||||
for (const block of current.blocks) {
|
||||
entries.push({
|
||||
partOfSpeech: block.partOfSpeech,
|
||||
definitions: block.definitions.slice(0, MAX_DEFINITIONS_PER_ENTRY),
|
||||
sourceWord: current.word,
|
||||
})
|
||||
}
|
||||
|
||||
if (current.depth >= MAX_LEMMA_DEPTH) continue
|
||||
|
||||
for (const block of current.blocks) {
|
||||
if (lookups >= MAX_LEMMA_LOOKUPS) break
|
||||
|
||||
const leading = block.definitions[0]
|
||||
if (!leading) continue
|
||||
const lemma = pointerLemma(leading.definition)
|
||||
if (!lemma || visited.has(lemma)) continue
|
||||
|
||||
visited.add(lemma)
|
||||
lookups += 1
|
||||
|
||||
let blocks: SenseBlock[] = []
|
||||
try {
|
||||
blocks = mergeBlocks(await fetchBlocks(lemma))
|
||||
} catch {
|
||||
// A lemma that fails to resolve must never take the primary result down
|
||||
continue
|
||||
}
|
||||
if (blocks.length > 0) queue.push({ word: lemma, blocks, depth: current.depth + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export function useDictionary() {
|
||||
@@ -241,17 +458,23 @@ export function useDictionary() {
|
||||
const trimmed = word.trim()
|
||||
|
||||
if (normalizedLang === 'en') {
|
||||
let result: DictionaryResult | null = null
|
||||
let payload: ProviderPayload | null = null
|
||||
try {
|
||||
result = await fetchFreeDictionary(trimmed)
|
||||
payload = await fetchFreeDictionaryPayload(trimmed)
|
||||
} catch {
|
||||
// Fall back to Wiktionary on any Free Dictionary error
|
||||
}
|
||||
if (result) return result
|
||||
return fetchWiktionary(trimmed, normalizedLang)
|
||||
if (payload) {
|
||||
const entries = await expandPointers(payload, async (lemma) => (await fetchFreeDictionaryPayload(lemma))?.blocks ?? [])
|
||||
return { word: payload.word, phonetic: payload.phonetic, audioUrl: payload.audioUrl, entries, provider: 'free-dictionary' }
|
||||
}
|
||||
}
|
||||
|
||||
return fetchWiktionary(trimmed, normalizedLang)
|
||||
const payload = await fetchWiktionaryPayload(trimmed, normalizedLang)
|
||||
if (!payload) return null
|
||||
|
||||
const entries = await expandPointers(payload, async (lemma) => (await fetchWiktionaryPayload(lemma, normalizedLang))?.blocks ?? [])
|
||||
return { word: payload.word, phonetic: payload.phonetic, audioUrl: payload.audioUrl, entries, provider: 'wiktionary' }
|
||||
}
|
||||
|
||||
return { lookup }
|
||||
|
||||
@@ -6,6 +6,12 @@ export interface DictionaryDefinition {
|
||||
export interface DictionaryEntry {
|
||||
partOfSpeech: string;
|
||||
definitions: DictionaryDefinition[];
|
||||
/**
|
||||
* The word these senses actually define. Differs from the looked-up word when
|
||||
* the lookup followed an inflection pointer ("plural of candelabra") to reach
|
||||
* a word that carries a real definition.
|
||||
*/
|
||||
sourceWord: string;
|
||||
}
|
||||
|
||||
export interface DictionaryResult {
|
||||
|
||||
Reference in New Issue
Block a user