(refactor)(nouns) use Numerus string union to simplify types

This commit is contained in:
Valentyne Stigloher 2025-07-06 15:41:03 +02:00
parent 003a6dbce6
commit 23dcc1a454
17 changed files with 121 additions and 109 deletions

View File

@ -3,6 +3,7 @@ import { loadGrammarTableVariantsConverter } from '~/src/data.ts';
import type { Example, ExampleValues } from '~/src/language/examples.ts';
import { expandVariantsForSection } from '~/src/language/grammarTables.ts';
import type { GrammarTableDefinition, Variant, SectionDefinition } from '~/src/language/grammarTables.ts';
import { symbolsByNumeri } from '~/src/nouns.ts';
const props = defineProps<{
grammarTable: GrammarTableDefinition;
@ -86,7 +87,9 @@ const rowHeaderCount = computed(() => {
<template v-if="variant.numerus || variant.icon">
<th class="pe-0">
<Tooltip v-if="variant.name" class="text-nowrap" :text="variant.name">
{{ variant.numerus === 'singular' ? '⋅' : '⁖' }}
<template v-if="variant.numerus">
{{ symbolsByNumeri[variant.numerus] }}
</template>
<Icon v-if="variant.icon" :v="variant.icon" />
</Tooltip>
</th>

View File

@ -1,12 +1,15 @@
<script setup lang="ts">
import { loadNounsData } from '~/src/data.ts';
import type { NounClass, NounConvention, NounWord } from '~/src/nouns.ts';
import { symbolsByNumeri } from '~/src/nouns.ts';
import type { NounClass, NounConvention, NounWord, Numerus } from '~/src/nouns.ts';
const props = defineProps<{
const props = withDefaults(defineProps<{
nounClass: NounClass & { key: string };
nounConvention: WithKey<NounConvention>;
plural?: boolean;
}>();
numerus?: Numerus;
}>(), {
numerus: 'singular',
});
const nounsData = await loadNounsData();
@ -38,13 +41,8 @@ const word = computed((): NounWord | undefined => {
<template>
<div v-if="word" class="mb-3">
<h5 class="h6">
<template v-if="!plural">
<T>nouns.singular</T>
</template>
<template v-else>
<T>nouns.plural</T>
</template>
{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T>
</h5>
<NounsDeclension :word open :plural />
<NounsDeclension :word open :numerus />
</div>
</template>

View File

@ -1,15 +1,23 @@
<script setup lang="ts">
import { availableNumeri } from '~/src/nouns.ts';
import type { NounClass, NounConvention } from '~/src/nouns.ts';
defineProps<{
nounClass: WithKey<NounClass>;
nounConvention: WithKey<NounConvention>;
}>();
const config = useConfig();
</script>
<template>
<div class="col-6 col-md-3 ">
<NounsClassExample :noun-class :noun-convention />
<NounsClassExample :noun-class :noun-convention plural />
<NounsClassExample
v-for="numerus of availableNumeri(config)"
:key="numerus"
:noun-class
:noun-convention
:numerus
/>
</div>
</template>

View File

@ -1,31 +1,29 @@
<script setup lang="ts">
import { loadNounsData } from '~/src/data.ts';
import { capitalise } from '~/src/helpers.ts';
import type { NounWord } from '~/src/nouns.ts';
import type { Numerus, NounWord } from '~/src/nouns.ts';
const props = withDefaults(defineProps<{
word: NounWord;
plural?: boolean;
numerus?: Numerus;
singularOptions?: string[];
open?: boolean;
condense?: boolean;
tooltip?: boolean;
}>(), {
plural: false,
numerus: 'singular',
});
const nounsData = await loadNounsData();
const numerus = computed(() => !props.plural ? 'singular' : 'plural');
const declensionByCase = computed((): Record<string, string[]> | undefined => {
if (props.word.declension === undefined) {
return undefined;
}
if (typeof props.word.declension === 'string') {
return nounsData.declensions?.[props.word.declension]?.[numerus.value];
return nounsData.declensions?.[props.word.declension]?.[props.numerus];
}
return props.word.declension[numerus.value];
return props.word.declension[props.numerus];
});
const nounConvention = computed(() => {
@ -39,7 +37,7 @@ const articles = computed(() => {
if (nounConvention.value === undefined) {
return {};
}
return Object.fromEntries(Object.entries(nounsData.classExample?.[numerus.value] ?? {})
return Object.fromEntries(Object.entries(nounsData.classExample?.[props.numerus] ?? {})
.map(([caseAbbreviation, article]) => {
const resolvedArticle = article.replace(/\{([^}]+)}/, (_match, morpheme) => {
const value = nounConvention.value?.morphemes?.[morpheme];

View File

@ -1,24 +1,26 @@
<script setup lang="ts">
import type { Noun } from '~/src/classes.ts';
import type { Gender } from '~/src/nouns.ts';
import type { Gender, Numerus } from '~/src/nouns.ts';
defineProps<{
withDefaults(defineProps<{
noun: Noun;
gender: Gender;
plural?: boolean;
}>();
numerus?: Numerus;
}>(), {
numerus: 'singular',
});
const config = useConfig();
</script>
<template>
<ul :class="[plural ? 'list-plural' : 'list-singular', 'mb-0 ps-0']">
<li v-for="(wordRaw, i) in noun.words[gender]?.[!plural ? 'singular' : 'plural']" :key="i">
<ul :class="[`list-${numerus}`, 'mb-0 ps-0']">
<li v-for="(wordRaw, i) in noun.words[gender]?.[numerus]" :key="i">
<NounsAbbreviation v-slot="{ word }" :word-raw>
<NounsDeclension
v-if="config.nouns.declension?.enabled"
:word
:plural
:numerus
tooltip
/>
<Spelling v-else :text="word.spelling" />

View File

@ -3,11 +3,11 @@ import type { Config } from '~/locale/config.ts';
import type { Noun, NounRaw } from '~/src/classes.ts';
import { loadNounAbbreviations } from '~/src/data.ts';
import { fromUnionEntries } from '~/src/helpers.ts';
import { availableGenders, genders } from '~/src/nouns.ts';
import type { Gender, NounWord, NounWords } from '~/src/nouns.ts';
import { availableGenders, availableNumeri, genders, symbolsByNumeri } from '~/src/nouns.ts';
import type { Numerus, Gender, NounWord, NounWords } from '~/src/nouns.ts';
interface NounFormValue extends Omit<NounRaw, 'id' | 'words'> {
words: Record<Gender, Record<'singular' | 'plural', NounWord[]>>;
words: Record<Gender, Record<Numerus, NounWord[]>>;
categories: NonNullable<NounRaw['categories']>;
}
@ -55,11 +55,17 @@ const templateBase = ref('');
const templateFilter = ref('');
const templateVisible = ref(false);
const canRemoveWord = (gender: Gender, plural: boolean): boolean => {
return availableGenders(config).filter((otherGender) => {
return otherGender !== gender &&
(form.value.words[otherGender]?.[!plural ? 'singular' : 'plural'] ?? []).length > 0;
}).length > 1 || (form.value.words[gender]?.[!plural ? 'singular' : 'plural'] ?? []).length > 1;
const canRemoveWord = (gender: Gender, numerus: Numerus): boolean => {
if (numerus === 'plural' && !config.nouns.pluralsRequired) {
return true;
}
const wordsOfOtherGenderAndSameNumerus = availableGenders(config).filter((otherGender) => {
return otherGender !== gender && (form.value.words[otherGender]?.[numerus] ?? []).length > 0;
});
if (wordsOfOtherGenderAndSameNumerus.length > 1) {
return true;
}
return (form.value.words[gender]?.[numerus] ?? []).length > 1;
};
const dialogue = useDialogue();
@ -132,29 +138,16 @@ const { data: sourcesKeys } = await useFetch('/api/sources/keys', { lazy: true,
</p>
</div>
<form v-else @submit.prevent="submit">
<div class="row">
<div v-for="numerus of availableNumeri(config)" :key="numerus" class="row">
<div v-if="config.nouns.plurals" class="col-12 text-nowrap mt-md-4">
<label><strong> <T>nouns.singular</T></strong></label>
<label><strong>{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T></strong></label>
</div>
<div v-for="gender in availableGenders(config)" :key="gender" class="col-12 col-md-6 mt-2">
<label><strong><NounsGenderLabel :gender="gender" /></strong></label>
<NounsWordsInput
v-model="form.words[gender].singular"
v-model="form.words[gender][numerus]"
:edit-declensions
:minitems="canRemoveWord(gender, false) ? 0 : 1"
/>
</div>
</div>
<div v-if="config.nouns.plurals" class="row">
<div class="col-12 text-nowrap">
<label><strong> <T>nouns.plural</T></strong></label>
</div>
<div v-for="gender in availableGenders(config)" :key="gender" class="col-12 col-md-6 mt-2">
<label><strong><NounsGenderLabel :gender="gender" /></strong></label>
<NounsWordsInput
v-model="form.words[gender].plural"
:edit-declensions
:minitems="!config.nouns.pluralsRequired || canRemoveWord(gender, true) ? 0 : 1"
:minitems="canRemoveWord(gender, numerus) ? 0 : 1"
/>
</div>
</div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Noun } from '~/src/classes.ts';
import { genders } from '~/src/nouns.ts';
import { availableNumeri, genders } from '~/src/nouns.ts';
const props = defineProps<{
noun: Noun;
@ -16,12 +16,7 @@ const visibleGenders = computed(() => {
});
});
const numerus = computed(() => {
if (config.nouns.plurals) {
return [false, true];
}
return [false];
});
const visibleNumeri = computed(() => availableNumeri(config));
</script>
<template>
@ -30,18 +25,18 @@ const numerus = computed(() => {
<NounsGenderLabel :gender="gender" concise />
</div>
<div
v-for="plural in numerus"
:key="plural ? 'plural' : 'singular'"
:style="{ gridArea: `${gender}${plural ? 'Pl' : ''}` }"
v-for="numerus in visibleNumeri"
:key="numerus"
:style="{ gridArea: `${gender}${numerus === 'plural' ? 'Pl' : ''}` }"
role="cell"
>
<NounsItem :noun="noun" :gender="gender" :plural="plural" />
<NounsItem :noun :gender :numerus />
<small v-if="base">
<p><strong><T>nouns.edited</T><T>quotation.colon</T></strong></p>
<Diff switchable>
<template #before><NounsItem :noun="base" :gender="gender" :plural="plural" /></template>
<template #after><NounsItem :noun="noun" :gender="gender" :plural="plural" /></template>
<template #before><NounsItem :noun="base" :gender :numerus /></template>
<template #after><NounsItem :noun="noun" :gender :numerus /></template>
</Diff>
</small>
</div>

View File

@ -1,4 +1,5 @@
import type { Cell, CellPart, VariantsFromBaseConverter } from '~/src/language/grammarTables.ts';
import type { Numerus } from '~/src/nouns.ts';
const cases = ['n', 'g', 'd', 'a'];
@ -8,7 +9,7 @@ interface Category {
}
interface Declension extends Category {
numerus: 'singular' | 'plural';
numerus: Numerus;
icon?: string;
}

View File

@ -9,7 +9,7 @@ import type { NounTemplatesData } from '~/locale/data.ts';
import { Noun, SourceLibrary } from '~/src/classes.ts';
import type { Source } from '~/src/classes.ts';
import { neutralGenderNameInjectionKey } from '~/src/injectionKeys.ts';
import { availableGenders, genders } from '~/src/nouns.ts';
import { availableGenders, genders, numeri, symbolsByNumeri } from '~/src/nouns.ts';
import type { NounDeclension, NounWords } from '~/src/nouns.ts';
const dukajDeclension: NounDeclension = {
@ -229,7 +229,7 @@ const generatorResult = computed(() => {
const words: NounWords = {};
for (const gender of genders) {
for (const numerus of ['singular', 'plural'] as const) {
for (const numerus of numeri) {
const genderWithNumerus = `${gender}${numerus === 'singular' ? '' : 'Pl'}` as const;
words[gender] ??= {};
words[gender][numerus] = (template.value[genderWithNumerus] ?? '').split('/').map((ending) => {
@ -325,13 +325,9 @@ onMounted(async () => {
</summary>
<div class="border-top">
<div class="d-flex flex-column flex-md-row">
<div class="p-3">
<h5> <T>nouns.singular</T></h5>
<NounsDeclension :word="{ spelling: '', declension: dukajExtendedDeclension }" open />
</div>
<div class="p-3">
<h5> <T>nouns.plural</T></h5>
<NounsDeclension :word="{ spelling: '', declension: dukajExtendedDeclension }" open plural />
<div v-for="numerus of numeri" :key="numerus" class="p-3">
<h5>{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T></h5>
<NounsDeclension :word="{ spelling: '', declension: dukajExtendedDeclension }" open :numerus />
</div>
</div>
</div>

View File

@ -10,7 +10,7 @@ import { Noun, SourceLibrary } from '~/src/classes.ts';
import type { Source } from '~/src/classes.ts';
import { removeSuffix } from '~/src/helpers.ts';
import { neutralGenderNameInjectionKey } from '~/src/injectionKeys.ts';
import { availableGenders, genders } from '~/src/nouns.ts';
import { availableGenders, genders, numeri, symbolsByNumeri } from '~/src/nouns.ts';
import type { NounWords, NounDeclension } from '~/src/nouns.ts';
const xDeclension: NounDeclension = {
@ -206,7 +206,7 @@ const generatorResult = computed(() => {
const words: NounWords = {};
for (const gender of genders) {
for (const numerus of ['singular', 'plural'] as const) {
for (const numerus of numeri) {
const genderWithNumerus = `${gender}${numerus === 'singular' ? '' : 'Pl'}` as const;
words[gender] ??= {};
words[gender][numerus] = (template.value[genderWithNumerus] ?? '').split('/').map((ending) => {
@ -295,13 +295,9 @@ onMounted(async () => {
</summary>
<div class="border-top">
<div class="d-flex flex-column flex-md-row">
<div class="p-3">
<h5> <T>nouns.singular</T></h5>
<NounsDeclension :word="{ spelling: '', declension: xExtendedDeclension }" open />
</div>
<div class="p-3">
<h5> <T>nouns.plural</T></h5>
<NounsDeclension :word="{ spelling: '', declension: xExtendedDeclension }" open plural />
<div v-for="numerus of numeri" :key="numerus" class="p-3">
<h5>{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T></h5>
<NounsDeclension :word="{ spelling: '', declension: xExtendedDeclension }" open :numerus />
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@ import NounsNav from './NounsNav.vue';
import useSimpleHead from '~/composables/useSimpleHead.ts';
import { removeSuffix } from '~/src/helpers.ts';
import { numeri, symbolsByNumeri } from '~/src/nouns.ts';
import type { NounDeclension } from '~/src/nouns.ts';
const { $translator: translator } = useNuxtApp();
@ -346,13 +347,9 @@ const neuterAltDeclension: Record<string, NounDeclension[]> = {
</summary>
<div class="border-top">
<div class="d-flex flex-column flex-md-row">
<div class="p-3">
<h5> <T>nouns.singular</T></h5>
<NounsDeclension :word="{ spelling: '', declension: neuterExtendedDeclension }" open />
</div>
<div class="p-3">
<h5> <T>nouns.plural</T></h5>
<NounsDeclension :word="{ spelling: '', declension: neuterExtendedDeclension }" open plural />
<div v-for="numerus of numeri" :key="numerus" class="p-3">
<h5>{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T></h5>
<NounsDeclension :word="{ spelling: '', declension: neuterExtendedDeclension }" open :numerus />
</div>
</div>
</div>

View File

@ -5,7 +5,14 @@ import { getLocale, loadConfig, loadTranslator } from '~/server/data.ts';
import { registerLocaleFont } from '~/server/localeFont.ts';
import { parseNounRow } from '~/server/nouns.ts';
import type { NounRow } from '~/server/nouns.ts';
import { availableGenders, iconUnicodesByGender, longIdentifierByGender } from '~/src/nouns.ts';
import {
availableGenders,
availableNumeri,
iconUnicodesByGender,
longIdentifierByGender,
numeri,
symbolsByNumeri,
} from '~/src/nouns.ts';
export default defineEventHandler(async (event) => {
const locale = getLocale(event);
@ -45,7 +52,7 @@ export default defineEventHandler(async (event) => {
let maxItems = 0;
genders.forEach((gender) => {
let items = 0;
for (const numerus of ['singular', 'plural'] as const) {
for (const numerus of numeri) {
items += noun.words[gender]?.[numerus]?.length ?? 0;
}
if (items > maxItems) {
@ -88,7 +95,8 @@ export default defineEventHandler(async (event) => {
context.font = `24pt '${fontName}'`;
genders.forEach((gender, column) => {
let i = 0;
for (const [numerus, symbol] of [['singular', '⋅'], ['plural', '⁖']] as const) {
for (const numerus of availableNumeri(config)) {
const symbol = symbolsByNumeri[numerus];
noun.words[gender]?.[numerus]?.forEach((word) => {
context.fillText(
`${symbol} ${word}`,

View File

@ -13,6 +13,7 @@ import type {
NounDeclensionsByFirstCase,
NounConvention,
Gender,
Numerus,
} from '~/src/nouns.ts';
export class PronounExample {
@ -855,7 +856,7 @@ const loadWord = (
config: Config,
declensionsByFirstCase: NounDeclensionsByFirstCase,
wordRaw: string | NounWord,
numerus: 'singular' | 'plural',
numerus: Numerus,
): NounWord => {
if (typeof wordRaw === 'string') {
wordRaw = { spelling: wordRaw };
@ -885,7 +886,7 @@ export const loadWords = (
const words = Object.fromEntries(Object.entries(nounRaw.words).map(([gender, wordsOfGender]) => {
return [gender, Object.fromEntries(Object.entries(wordsOfGender).map(([numerus, wordsOfNumerus]) => {
return [numerus, wordsOfNumerus
.map((wordRaw) => loadWord(config, declensionsByFirstCase, wordRaw, numerus as 'singular' | 'plural'))];
.map((wordRaw) => loadWord(config, declensionsByFirstCase, wordRaw, numerus as Numerus))];
}))];
}));

View File

@ -1,7 +1,7 @@
import type { Config } from '~/locale/config.ts';
import { capitalise, escapePronunciationString } from '~/src/helpers.ts';
import type { MorphemeValues } from '~/src/language/morphemes.ts';
import type { NounConvention, NounDeclension } from '~/src/nouns.ts';
import type { NounConvention, NounDeclension, Numerus } from '~/src/nouns.ts';
export type ExamplePart = string | ExamplePartMorpheme | ExamplePartNoun;
@ -16,7 +16,7 @@ interface ExamplePartNoun {
stems: Record<string, string>;
nounClass: string;
caseAbbreviation: string;
plural?: boolean;
numerus?: Numerus;
}
export interface ExampleValues {
@ -71,7 +71,7 @@ export class Example {
})),
nounClass: nounMatch.groups.nounClass,
caseAbbreviation: nounMatch.groups.caseAbbreviation,
plural: nounMatch.groups.numerus === 'plural',
numerus: nounMatch.groups.numerus as Numerus | undefined,
});
}
}
@ -113,7 +113,7 @@ export class Example {
return undefined;
}
const stem = part.stems[template.stem ?? 'default'];
const numerus = !part.plural ? 'singular' : 'plural';
const numerus = part.numerus ?? 'singular';
const declension = exampleValues.nounDeclensions[template.declension][numerus];
if (declension === undefined) {
return undefined;

View File

@ -1,6 +1,7 @@
import type { ExampleValues } from '~/src/language/examples.ts';
import { toMorphemeValue } from '~/src/language/morphemes.ts';
import type { MorphemeValue } from '~/src/language/morphemes.ts';
import type { Numerus } from '~/src/nouns.ts';
export type GrammarTablesDefinition = (GrammarTableDefinition | string)[]
| { simple: (GrammarTableDefinition | string)[]; comprehensive: (GrammarTableDefinition | string)[] };
@ -34,7 +35,7 @@ export type VariantsFromBaseConverter = Record<string, (variantsDefinition: Vari
type CellDefinition = string | CellPartDefinition | CellPartDefinition[] | null;
type CellPartDefinition = MorphemeCellDefinition | Record<'singular' | 'plural', MorphemeCellDefinition>;
type CellPartDefinition = MorphemeCellDefinition | Record<Numerus, MorphemeCellDefinition>;
export interface MorphemeCellDefinition {
morpheme: string;
@ -45,7 +46,7 @@ export interface MorphemeCellDefinition {
export interface Variant {
name?: string;
numerus?: 'singular' | 'plural';
numerus?: Numerus;
icon?: string;
cells: Cell[];
}

View File

@ -31,15 +31,30 @@ export const longIdentifierByGender: Record<Gender, string> = {
nb: 'nonbinary',
};
export const numeri = ['singular', 'plural'] as const;
export type Numerus = typeof numeri[number];
export const availableNumeri = (config: Config): readonly Numerus[] => {
if (config.nouns.plurals) {
return numeri;
}
return ['singular'];
};
export const symbolsByNumeri: Record<Numerus, string> = {
singular: '⋅',
plural: '⁖',
};
export interface NounWord {
spelling: string;
convention?: keyof Required<NounsData>['conventions'];
declension?: keyof Required<NounsData>['declensions'] | NounDeclension;
}
export type NounWordsRaw = Partial<Record<Gender, Partial<Record<'singular' | 'plural', (NounWord | string)[]>>>>;
export type NounWordsRaw = Partial<Record<Gender, Partial<Record<Numerus, (NounWord | string)[]>>>>;
export type NounWords = Partial<Record<Gender, Partial<Record<'singular' | 'plural', NounWord[]>>>>;
export type NounWords = Partial<Record<Gender, Partial<Record<Numerus, NounWord[]>>>>;
export interface NounConventionGroup {
name: string;
@ -92,7 +107,7 @@ export const buildNounDeclensionsByFirstCase = (
return { singular: {}, plural: {} };
}
const firstCaseAbbreviation = Object.keys(cases)[0];
return fromUnionEntries((['singular', 'plural'] as const).map((numerus) => {
return fromUnionEntries(numeri.map((numerus) => {
return [numerus, Object.fromEntries(Object.entries(declensions)
.flatMap(([declensionKey, declension]) => {
const endings = declension[numerus]?.[firstCaseAbbreviation];

View File

@ -8,7 +8,7 @@ import { loadSuml, loadTsv } from '~/server/loader.ts';
import { normaliseKey } from '~/src/buildPronoun.ts';
import { Example } from '~/src/language/examples.ts';
import type { VariantsFromBaseConverter } from '~/src/language/grammarTables.ts';
import { availableGenders, gendersWithNumerus } from '~/src/nouns.ts';
import { availableGenders, gendersWithNumerus, numeri } from '~/src/nouns.ts';
import type { NounsData } from '~/src/nouns.ts';
function toHaveValidMorphemes(actual: string, morphemes: string[]): SyncExpectationResult {
@ -219,7 +219,7 @@ describe.each(allLocales)('data files of $code', async ({ code }) => {
if (nounsData.cases && nounsData.declensions) {
test('declensions have valid cases', () => {
for (const declension of Object.values(nounsData.declensions!)) {
for (const numerus of ['singular', 'plural'] as const) {
for (const numerus of numeri) {
expect(Object.keys(nounsData.cases!))
.toEqual(expect.arrayContaining(Object.keys(declension[numerus] ?? {})));
}