diff --git a/app/router.options.ts b/app/router.options.ts index 06656681e..a5f67e416 100644 --- a/app/router.options.ts +++ b/app/router.options.ts @@ -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]; }); diff --git a/components/Header.vue b/components/Header.vue index e16422ab4..ca06c003e 100644 --- a/components/Header.vue +++ b/components/Header.vue @@ -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}`); } diff --git a/eslint.config.js b/eslint.config.js index 34ebb7fe5..d18792ebe 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', + }, ], }], }, diff --git a/locale/de/language/grammarTableVariantsConverter.ts b/locale/de/language/grammarTableVariantsConverter.ts index dc9eb984b..bbffa7dff 100644 --- a/locale/de/language/grammarTableVariantsConverter.ts +++ b/locale/de/language/grammarTableVariantsConverter.ts @@ -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[] => { + 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; diff --git a/locale/de/nouns/nounConventions.suml b/locale/de/nouns/nounConventions.suml new file mode 100644 index 000000000..d8f267db4 --- /dev/null +++ b/locale/de/nouns/nounConventions.suml @@ -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' diff --git a/locale/generate.ts b/locale/generate.ts index 1473353bf..86e0731c8 100644 --- a/locale/generate.ts +++ b/locale/generate.ts @@ -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(), +]); diff --git a/pages/nouns/[convention].vue b/pages/nouns/[convention].vue new file mode 100644 index 000000000..40daa483d --- /dev/null +++ b/pages/nouns/[convention].vue @@ -0,0 +1,59 @@ + + + diff --git a/src/data.ts b/src/data.ts index 29c11fdfd..93859acb4 100644 --- a/src/data.ts +++ b/src/data.ts @@ -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 => { + 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* () { diff --git a/src/language/morphemes.ts b/src/language/morphemes.ts index b9181f8e7..9de8b4542 100644 --- a/src/language/morphemes.ts +++ b/src/language/morphemes.ts @@ -17,7 +17,12 @@ export interface MorphemeValue { } export class MorphemeValues { - constructor(private readonly values: Record) {} + private readonly values: Record; + + constructor(values: Record) { + this.values = Object.fromEntries(Object.entries(values) + .map(([morpheme, value]) => [morpheme, value !== undefined ? toMorphemeValue(value) : undefined])); + } getSpelling(morpheme: string): string | undefined { let capital = false; diff --git a/src/nouns.ts b/src/nouns.ts index aa5364a85..a575fdebc 100644 --- a/src/nouns.ts +++ b/src/nouns.ts @@ -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 = { neutr: 'neuter', nb: 'nonbinary', }; + +export interface NounConvention { + name: string; + normative: boolean; + morphemes: Record; +} + +export interface NounConventions { + morphemes: string[]; + grammarTables: GrammarTableDefinition[]; + conventions: Record; +} diff --git a/test/locales/data.test.ts b/test/locales/data.test.ts index 5cc42d642..eb88466ed 100644 --- a/test/locales/data.test.ts +++ b/test/locales/data.test.ts @@ -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(`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))); + } + }); + }); + } });