(de)(nouns) grammar tables for noun conventions

This commit is contained in:
Valentyne Stigloher 2025-04-02 16:06:04 +02:00
parent d54b2c912c
commit ec5f0ba97d
11 changed files with 595 additions and 19 deletions

View File

@ -13,12 +13,6 @@ const routerOptions: RouterOptions = {
// before user plugins with enforce: pre are run
const config = await loadConfig();
return routes.flatMap((route) => {
// workaround for dynamic inclusion of nouns subroutes,
// as translatedPaths does not work because definePageMeta is not scanned in pages:extend hook in Nuxt 3
if (typeof route.name === 'string' &&
route.name.startsWith('nouns-') && !route.name.startsWith(`nouns-${config.locale}`)) {
return [];
}
if (route.meta?.translatedPaths) {
const translatedPaths = route.meta.translatedPaths(config);
if (translatedPaths.length === 0) {
@ -36,6 +30,11 @@ const routerOptions: RouterOptions = {
meta: translatedRoute.meta,
};
});
} else if (typeof route.name === 'string' &&
route.name.startsWith('nouns-') && !route.name.startsWith(`nouns-${config.locale}`)) {
// workaround for dynamic inclusion of nouns subroutes,
// as translatedPaths does not work because definePageMeta is not scanned in pages:extend hook in Nuxt 3
return [];
}
return [route];
});

View File

@ -79,7 +79,7 @@ const links = computed((): HeaderLink[] => {
}
if (config.nouns.enabled) {
const extras = [];
const extras = ['nouns-convention'];
for (const subroute of config.nouns.subroutes || []) {
extras.push(`/${subroute}`);
}

View File

@ -86,7 +86,7 @@ export default withNuxt(
eslintPluginJsonSchemaValidator.configs['flat/base'],
{
name: 'pronouns-page/validation',
files: ['locale/*/config.suml'],
files: ['locale/**/*.suml'],
rules: {
'json-schema-validator/no-invalid': ['error', {
schemas: [
@ -94,6 +94,10 @@ export default withNuxt(
fileMatch: ['locale/*/config.suml'],
schema: 'locale/config.schema.json',
},
{
fileMatch: ['locale/*/nouns/nounConventions.suml'],
schema: 'locale/nounConventions.schema.json',
},
],
}],
},

View File

@ -2,11 +2,14 @@ import type { MorphemeCell, VariantsFromBaseConverter } from '~/src/language/gra
const cases = ['n', 'g', 'd', 'a'];
interface Declension {
interface Category {
name: string;
abbreviation: string;
}
interface Declension extends Category {
numerus: 'singular' | 'plural';
icon?: string;
abbreviation: string;
}
const declensions: Declension[] = [
@ -41,8 +44,37 @@ const declensions: Declension[] = [
},
];
const morphemesByCase = (morphemeBase: string): MorphemeCell[] => {
return cases.map((caseAbbreviation) => ({ morpheme: `${morphemeBase}_${caseAbbreviation}` }));
const definitenessCategories: Category[] = [
{
name: 'bestimmt',
abbreviation: '',
},
{
name: 'unbestimmt',
abbreviation: 'indefinite_',
},
];
const adjectiveDeclensions: Category[] = [
{
name: 'schwach',
abbreviation: 'weak',
},
{
name: 'gemischt',
abbreviation: 'mixed',
},
{
name: 'stark',
abbreviation: 'strong',
},
];
const morphemesByCase = (
morphemeBase: string,
attributes: Pick<MorphemeCell, 'prefix' | 'suffix'> = {},
): MorphemeCell[] => {
return cases.map((caseAbbreviation) => ({ morpheme: `${morphemeBase}_${caseAbbreviation}`, ...attributes }));
};
export default {
@ -57,4 +89,19 @@ export default {
morphemeCells: morphemesByCase(`${sectionVariants.base}_${declension.abbreviation}`),
}));
},
'definiteness-with-case': (sectionVariants) => {
return definitenessCategories.map((definitenessCategory) => ({
name: definitenessCategory.name,
morphemeCells: morphemesByCase(`${definitenessCategory.abbreviation}${sectionVariants.base}`),
}));
},
'adjective-declension-with-case': (sectionVariants) => {
return adjectiveDeclensions.map((adjectiveDeclension) => {
const morphemeBase = `${sectionVariants.base}_${adjectiveDeclension.abbreviation}`;
return {
name: adjectiveDeclension.name,
morphemeCells: morphemesByCase(morphemeBase, { prefix: { spelling: '-' } }),
};
});
},
} satisfies VariantsFromBaseConverter;

View File

@ -0,0 +1,402 @@
morphemes:
- 'article_n'
- 'article_g'
- 'article_d'
- 'article_a'
- 'indefinite_article_n'
- 'indefinite_article_g'
- 'indefinite_article_d'
- 'indefinite_article_a'
- 'adjective_weak_n'
- 'adjective_weak_g'
- 'adjective_weak_d'
- 'adjective_weak_a'
- 'adjective_mixed_n'
- 'adjective_mixed_g'
- 'adjective_mixed_d'
- 'adjective_mixed_a'
- 'adjective_strong_n'
- 'adjective_strong_g'
- 'adjective_strong_d'
- 'adjective_strong_a'
grammarTables:
-
columnHeader:
-
name: 'Nominativ'
short: 'N'
-
name: 'Genitiv'
short: 'G'
-
name: 'Dativ'
short: 'D'
-
name: 'Akkusativ'
short: 'A'
sections:
-
header:
name: 'Artikel'
variants:
base: 'article'
type: 'definiteness-with-case'
-
header:
name: 'Adjektivdeklination'
variants:
base: 'adjective'
type: 'adjective-declension-with-case'
conventions:
maskulinum:
name: 'Maskulinum'
normative: true
morphemes:
article_n: 'der'
article_g: 'des'
article_d: 'dem'
article_a: 'den'
indefinite_article_n: 'ein'
indefinite_article_g: 'eines'
indefinite_article_d: 'einem'
indefinite_article_a: 'einen'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'er'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'en'
adjective_strong_n: 'er'
adjective_strong_g: 'en'
adjective_strong_d: 'em'
adjective_strong_a: 'en'
femininum:
name: 'Femininum'
normative: true
morphemes:
article_n: 'die'
article_g: 'der'
article_d: 'der'
article_a: 'die'
indefinite_article_n: 'eine'
indefinite_article_g: 'einer'
indefinite_article_d: 'einer'
indefinite_article_a: 'eine'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'e'
adjective_strong_n: 'e'
adjective_strong_g: 'er'
adjective_strong_d: 'er'
adjective_strong_a: 'e'
diminuitiv:
name: 'Diminuitiv'
normative: true
morphemes:
article_n: 'das'
article_g: 'des'
article_d: 'dem'
article_a: 'das'
indefinite_article_n: 'ein'
indefinite_article_g: 'eines'
indefinite_article_d: 'einem'
indefinite_article_a: 'ein'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'es'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'es'
adjective_strong_n: 'es'
adjective_strong_g: 'en'
adjective_strong_d: 'em'
adjective_strong_a: 'es'
person-formen:
name: 'Person-Formen'
normative: true
morphemes:
article_n: 'die'
article_g: 'der'
article_d: 'der'
article_a: 'die'
indefinite_article_n: 'eine'
indefinite_article_g: 'einer'
indefinite_article_d: 'einer'
indefinite_article_a: 'eine'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'e'
adjective_strong_n: 'e'
adjective_strong_g: 'er'
adjective_strong_d: 'er'
adjective_strong_a: 'e'
mensch-formen:
name: 'Mensch-Formen'
normative: true
morphemes:
article_n: 'der'
article_g: 'des'
article_d: 'dem'
article_a: 'den'
indefinite_article_n: 'ein'
indefinite_article_g: 'eines'
indefinite_article_d: 'einem'
indefinite_article_a: 'einen'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'er'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'en'
adjective_strong_n: 'er'
adjective_strong_g: 'en'
adjective_strong_d: 'em'
adjective_strong_a: 'en'
y-formen:
name: 'Y-Formen'
normative: false
morphemes:
article_n: 'das'
article_g: 'des'
article_d: 'dem'
article_a: 'das'
indefinite_article_n: 'ein'
indefinite_article_g: 'eines'
indefinite_article_d: 'einem'
indefinite_article_a: 'ein'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'es'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'es'
adjective_strong_n: 'es'
adjective_strong_g: 'en'
adjective_strong_d: 'em'
adjective_strong_a: 'es'
i-formen:
name: 'I-Formen'
normative: false
morphemes:
article_n: 'das'
article_g: 'des'
article_d: 'dem'
article_a: 'das'
indefinite_article_n: 'ein'
indefinite_article_g: 'eines'
indefinite_article_d: 'einem'
indefinite_article_a: 'ein'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'es'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'es'
adjective_strong_n: 'es'
adjective_strong_g: 'en'
adjective_strong_d: 'em'
adjective_strong_a: 'es'
inklusivum:
name: 'Inklusivum'
normative: false
morphemes:
article_n: 'de'
article_g: 'ders'
article_d: 'derm'
article_a: 'de'
indefinite_article_n: 'ein'
indefinite_article_g: 'einers'
indefinite_article_d: 'einerm'
indefinite_article_a: 'ein'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'e'
adjective_strong_n: 'ey'
adjective_strong_g: 'ers'
adjective_strong_d: 'erm'
adjective_strong_a: 'ey'
indefinitivum:
name: 'Indefinitivum'
normative: false
morphemes:
article_n: 'din'
article_g: 'dins'
article_d: 'dim'
article_a: 'din'
indefinite_article_n: 'einin'
indefinite_article_g: 'einins'
indefinite_article_d: 'einim'
indefinite_article_a: 'einir'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'in'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'in'
ens-formen:
name: 'ens-Formen'
normative: false
morphemes:
article_n: 'dens'
article_g: 'dens'
article_d: 'dens'
article_a: 'dens'
adjective_weak_n: 'e'
adjective_weak_g: 'e'
adjective_weak_d: 'e'
adjective_weak_a: 'e'
ex-formen:
name: 'ex-Formen'
normative: false
morphemes: {}
ojum:
name: 'Ojum'
normative: false
morphemes:
article_n: 'dej'
article_g: 'dejs'
article_d: 'dojm'
article_a: 'dojn'
nona-system:
name: 'NoNa-System'
normative: false
morphemes:
article_n: 'dai'
article_g: 'dais'
article_d: 'dam'
article_a: 'dai'
indefinite_article_n: 'eint'
indefinite_article_g: 'einter'
indefinite_article_d: 'eintem'
indefinite_article_a: 'eint'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e'
adjective_mixed_g: 'er'
adjective_mixed_d: 'em'
adjective_mixed_a: 'e'
genderdoppelpunkt:
name: 'Genderdoppelpunkt'
normative: false
morphemes:
article_n: 'der:die'
article_g: 'des:der'
article_d: 'dem:der'
article_a: 'den:die'
indefinite_article_n: 'ein:e'
indefinite_article_g: 'einer:s'
indefinite_article_d: 'einer:m'
indefinite_article_a: 'eine:n'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e:r'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'e:n'
adjective_strong_n: 'e:r'
adjective_strong_g: 'er:n'
adjective_strong_d: 'er:m'
adjective_strong_a: 'e:n'
gendergap:
name: 'Gendergap'
normative: false
morphemes:
article_n: 'der_die'
article_g: 'des_der'
article_d: 'dem_der'
article_a: 'den_die'
indefinite_article_n: 'ein_e'
indefinite_article_g: 'einer_s'
indefinite_article_d: 'einer_m'
indefinite_article_a: 'eine_n'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e_r'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'e_n'
adjective_strong_n: 'e_r'
adjective_strong_g: 'er_n'
adjective_strong_d: 'er_m'
adjective_strong_a: 'e_n'
gendersternchen:
name: 'Gendersternchen'
normative: false
morphemes:
article_n: 'der*die'
article_g: 'des*der'
article_d: 'dem*der'
article_a: 'den*die'
indefinite_article_n: 'ein*e'
indefinite_article_g: 'einer*s'
indefinite_article_d: 'einer*m'
indefinite_article_a: 'eine*n'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'e*r'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'e*n'
adjective_strong_n: 'e*r'
adjective_strong_g: 'er*n'
adjective_strong_d: 'er*m'
adjective_strong_a: 'e*n'
binnen-i:
name: 'Binnen-I'
normative: false
morphemes:
article_n: 'derDie'
article_g: 'desDer'
article_d: 'demDer'
article_a: 'denDie'
indefinite_article_n: 'einE'
indefinite_article_g: 'eineRs'
indefinite_article_d: 'eineRm'
indefinite_article_a: 'einEn'
adjective_weak_n: 'e'
adjective_weak_g: 'en'
adjective_weak_d: 'en'
adjective_weak_a: 'e'
adjective_mixed_n: 'Er'
adjective_mixed_g: 'en'
adjective_mixed_d: 'en'
adjective_mixed_a: 'En'
adjective_strong_n: 'Er'
adjective_strong_g: 'eRn'
adjective_strong_d: 'eRm'
adjective_strong_a: 'En'

View File

@ -8,17 +8,20 @@ import { loadSuml } from '~/server/loader.ts';
const __dirname = new URL('.', import.meta.url).pathname;
const generateConfigJsonSchema = async () => {
const generateJsonSchema = async (path: string, typeName: string) => {
const schema = createGenerator({
path: `${__dirname}/config.ts`,
path,
tsconfig: `${__dirname}/../tsconfig.json`,
strictTuples: true,
markdownDescription: true,
// speed up schema generation; type checking happens separately
skipTypeCheck: true,
}).createSchema('Config');
}).createSchema(typeName);
await fs.writeFile(`${__dirname}/config.schema.json`, `${JSON.stringify(schema, null, 4)}\n`);
await fs.writeFile(
`${__dirname}/${typeName[0].toLowerCase()}${typeName.substring(1)}.schema.json`,
`${JSON.stringify(schema, null, 4)}\n`,
);
};
const generateFontsModule = async () => {
@ -35,4 +38,8 @@ const generateFontsModule = async () => {
);
};
await Promise.all([generateConfigJsonSchema(), generateFontsModule()]);
await Promise.all([
generateJsonSchema(`${__dirname}/config.ts`, 'Config'),
generateJsonSchema(`${__dirname}/../src/nouns.ts`, 'NounConventions'),
generateFontsModule(),
]);

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { loadNounConventions } from '~/src/data.ts';
import { MorphemeValues } from '~/src/language/morphemes.ts';
import type { NounConvention } from '~/src/nouns.ts';
definePageMeta({
translatedPaths: (config) => {
return translatedPathByConfigModule(config.nouns).map((nounsRoute) => `${nounsRoute}/:convention`);
},
});
const route = useRoute();
const nounConventions = await loadNounConventions();
const findNounConvention = (nounConventionKey: string): NounConvention | undefined => {
for (const [key, nounConvention] of Object.entries(nounConventions?.conventions ?? {})) {
if (key === nounConventionKey) {
return nounConvention;
}
}
};
const nounConvention = typeof route.params.convention === 'string'
? findNounConvention(route.params.convention)
: undefined;
</script>
<template>
<Page>
<NotFound v-if="nounConventions === undefined || nounConvention === undefined" />
<template v-else>
<h2>
<Icon v="book-alt" />
</h2>
<section>
<div class="alert alert-primary">
<h2 class="text-center mb-0">
<strong><Spelling :text="nounConvention.name" /></strong>
</h2>
</div>
</section>
<section>
<h2 class="h4">
<Icon v="spell-check" />
<T>pronouns.grammarTable</T><T>quotation.colon</T>
</h2>
<GrammarTable
v-for="(grammarTable, t) in nounConventions.grammarTables"
:key="t"
:grammar-table="grammarTable"
:morpheme-values="new MorphemeValues(nounConvention.morphemes)"
/>
</section>
</template>
</Page>
</template>

View File

@ -8,6 +8,7 @@ import type { Translations } from '~/locale/translations.ts';
import { buildCalendar } from '~/src/calendar/calendar.ts';
import { getLocaleForUrl, getUrlForLocale } from '~/src/domain.ts';
import type { VariantsFromBaseConverter } from '~/src/language/grammarTables.ts';
import type { NounConventions } from '~/src/nouns.ts';
export const getLocale = () => {
return getLocaleForUrl(useRequestURL()) ?? '_';
@ -62,6 +63,14 @@ export const loadPronounLibrary = async (config: Config) => {
return new PronounLibrary(config, pronounGroups, pronouns);
};
export const loadNounConventions = async (): Promise<NounConventions | undefined> => {
try {
return (await import(`~/locale/${getLocale()}/nouns/nounConventions.suml`)).default;
} catch (error) {
return undefined;
}
};
export const loadNounTemplates = async () => {
const nounTemplatesRaw = (await import(`~/locale/${getLocale()}/nouns/nounTemplates.tsv`)).default;
return buildList(function* () {

View File

@ -17,7 +17,12 @@ export interface MorphemeValue {
}
export class MorphemeValues {
constructor(private readonly values: Record<string, MorphemeValue | undefined>) {}
private readonly values: Record<string, MorphemeValue | undefined>;
constructor(values: Record<string, MorphemeValue | string | undefined>) {
this.values = Object.fromEntries(Object.entries(values)
.map(([morpheme, value]) => [morpheme, value !== undefined ? toMorphemeValue(value) : undefined]));
}
getSpelling(morpheme: string): string | undefined {
let capital = false;

View File

@ -1,4 +1,6 @@
import type { Config } from '~/locale/config.ts';
import type { GrammarTableDefinition } from '~/src/language/grammarTables.ts';
import type { MorphemeValue } from '~/src/language/morphemes.ts';
export const genders = ['masc', 'fem', 'neutr', 'nb'] as const;
export type Gender = typeof genders[number];
@ -27,3 +29,15 @@ export const longIdentifierByGender: Record<Gender, string> = {
neutr: 'neuter',
nb: 'nonbinary',
};
export interface NounConvention {
name: string;
normative: boolean;
morphemes: Record<string, MorphemeValue | string>;
}
export interface NounConventions {
morphemes: string[];
grammarTables: GrammarTableDefinition[];
conventions: Record<string, NounConvention>;
}

View File

@ -7,8 +7,9 @@ import allLocales from '~/locale/locales.ts';
import { loadSuml, loadTsv } from '~/server/loader.ts';
import { normaliseKey } from '~/src/buildPronoun.ts';
import { Example } from '~/src/classes.ts';
import type { VariantsFromBaseConverter } from '~/src/language/grammarTables.ts';
import { getBaseMorpheme } from '~/src/language/morphemes.ts';
import { availableGenders, gendersWithNumerus } from '~/src/nouns.ts';
import { availableGenders, gendersWithNumerus, type NounConventions } from '~/src/nouns.ts';
function toHaveValidMorphemes(actual: string, morphemes: string[]): SyncExpectationResult {
const containedMorphemes = Example.parse(actual).filter((part) => part.variable)
@ -155,4 +156,33 @@ describe.each(allLocales)('data files of $code', async ({ code }) => {
}
}
});
let nounConventions;
try {
nounConventions = await loadSuml<NounConventions>(`locale/${code}/nouns/nounConventions.suml`);
} catch (error) {
nounConventions = undefined;
}
if (nounConventions !== undefined) {
describe('nouns/nounConventions.suml', () => {
test('has valid variant types', async () => {
const grammarTableVariantsConverter = (await import(`~/locale/${code}/language/grammarTableVariantsConverter.ts`)).default as VariantsFromBaseConverter;
for (const grammarTable of nounConventions.grammarTables) {
for (const section of grammarTable.sections) {
if (!Array.isArray(section.variants)) {
expect(Object.keys(grammarTableVariantsConverter)).toContain(section.variants.type);
}
}
}
});
test('has valid morphemes', () => {
for (const convention of Object.values(nounConventions.conventions)) {
expect(nounConventions.morphemes)
.toEqual(expect.arrayContaining(Object.keys(convention.morphemes)));
}
});
});
}
});