// Renders a BÍN paradigm (FORMS entry) as organized grammar tables instead of a
// flat list. Pure function: renderParadigm(entry, currentForm, lang) -> HTML.
(function (global) {
function esc(s) {
return (s || '').replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
}
const CASES = ['NF', 'ÞF', 'ÞGF', 'EF'];
const L = {
en: {
case: { NF: 'Nom.', ÞF: 'Acc.', ÞGF: 'Dat.', EF: 'Gen.' },
sg: 'Sg.', sgdef: 'Sg. +art.', pl: 'Pl.', pldef: 'Pl. +art.',
gender: { KK: 'Masc.', KVK: 'Fem.', HK: 'Neut.' },
voice: { GM: 'Active', MM: 'Middle' },
mood: { FH: 'Indicative', VH: 'Subjunctive' },
tense: { NT: 'Present', ÞT: 'Past' },
person: { '1P': '1st', '2P': '2nd', '3P': '3rd' },
infinitive: 'Infinitive', imperative: 'Imperative',
prespart: 'Present participle', pastpart: 'Past participle', supine: 'Supine',
indeclinable: 'Does not inflect',
deg: {
FSB: 'Positive · strong', FVB: 'Positive · weak', FST: 'Positive',
MST: 'Comparative', EST: 'Superlative · strong', ESB: 'Superlative · strong',
EVB: 'Superlative · weak', SB: 'Strong', VB: 'Weak'
}
},
zh: {
case: { NF: '主格', ÞF: '賓格', ÞGF: '與格', EF: '屬格' },
sg: '單數', sgdef: '單數+冠詞', pl: '複數', pldef: '複數+冠詞',
gender: { KK: '陽性', KVK: '陰性', HK: '中性' },
voice: { GM: '主動', MM: '中間' },
mood: { FH: '直陳', VH: '虛擬' },
tense: { NT: '現在', ÞT: '過去' },
person: { '1P': '第一人稱', '2P': '第二人稱', '3P': '第三人稱' },
infinitive: '不定式', imperative: '命令式',
prespart: '現在分詞', pastpart: '過去分詞', supine: '動名詞',
indeclinable: '不變化',
deg: {
FSB: '原級 · 強', FVB: '原級 · 弱', FST: '原級',
MST: '比較級', EST: '最高級 · 強', ESB: '最高級 · 強',
EVB: '最高級 · 弱', SB: '強變化', VB: '弱變化'
}
}
};
const norm = t => t.replace(/\d+$/, ''); // drop only trailing variant digit (keep 1P/2P/3P)
const NOUN = { kk: 1, kvk: 1, hk: 1 };
// group forms by a normalized tag -> [forms]
function index(forms) {
const m = {};
for (const [tag, f] of forms) {
const k = norm(tag);
(m[k] = m[k] || []).push(f);
}
return m;
}
function cell(forms, cur) {
if (!forms || !forms.length) return '
· | ';
const hot = forms.some(f => f.toLowerCase() === cur);
const html = forms.map(f => f.toLowerCase() === cur ? `${esc(f)}` : esc(f)).join(' / ');
return `${html} | `;
}
// Generic case×(columns) table. cols = [{label, key(caseCode)->tagKey}]
function caseTable(m, cur, lang, cols, title) {
const t = L[lang];
const present = cols.filter(c => CASES.some(cs => m[c.key(cs)]));
if (!present.length) return '';
let h = title ? `${esc(title)}
` : '';
h += ' | ' +
present.map(c => `${esc(c.label)} | `).join('') + '
';
for (const cs of CASES) {
if (!present.some(c => m[c.key(cs)])) continue;
h += `| ${t.case[cs]} | ` +
present.map(c => cell(m[c.key(cs)], cur)).join('') + '
';
}
return h + '
';
}
function nounTables(e, cur, lang) {
const m = index(e.forms);
const t = L[lang];
const cols = [
{ label: t.sg, key: c => c + 'ET' },
{ label: t.sgdef, key: c => c + 'ETgr' },
{ label: t.pl, key: c => c + 'FT' },
{ label: t.pldef, key: c => c + 'FTgr' }
];
return caseTable(m, cur, lang, cols, '');
}
// Declension with gender columns, grouped by a leading code (adjectives,
// gendered pronouns/numerals, past participles).
function declTables(forms, cur, lang) {
const t = L[lang];
// group -> gender -> number -> caseCode -> [forms]
const groups = {};
const order = [];
for (const [tag, f] of forms) {
const parts = norm(tag).split('-');
const gender = parts.find(p => p in t.gender) || '_';
let cn = null;
for (const p of parts) { const mm = p.match(/^(NF|ÞF|ÞGF|EF)(ET|FT)$/); if (mm) { cn = mm; break; } }
if (!cn) continue;
const cas = cn[1], num = cn[2];
const rest = parts.filter(p => p !== gender && p !== cn[0] &&
!['GM', 'MM', 'LH', 'LHÞT', 'SP', 'OP'].includes(p));
const g = rest.join('-');
if (!(g in groups)) { groups[g] = {}; order.push(g); }
const key = gender + ':' + cas + ':' + num;
(groups[g][key] = groups[g][key] || []).push(f);
}
const prio = ['FSB', 'FVB', 'FST', 'MST', 'EST', 'ESB', 'EVB', 'SB', 'VB', ''];
order.sort((a, b) => {
const ia = prio.indexOf(a), ib = prio.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
let html = '';
for (const g of order) {
const map = groups[g];
const genders = ['KK', 'KVK', 'HK'].filter(gd =>
CASES.some(c => map[gd + ':' + c + ':ET'] || map[gd + ':' + c + ':FT']));
const useGender = genders.length > 0;
const heading = t.deg[g] || g;
for (const num of ['ET', 'FT']) {
const numLabel = num === 'ET' ? t.sg : t.pl;
if (useGender) {
const cols = genders.map(gd => ({ gd }));
const has = CASES.some(c => genders.some(gd => map[gd + ':' + c + ':' + num]));
if (!has) continue;
html += `${esc([heading, numLabel].filter(Boolean).join(' · '))}
`;
html += ' | ' +
genders.map(gd => `${t.gender[gd]} | `).join('') + '
';
for (const c of CASES) {
if (!genders.some(gd => map[gd + ':' + c + ':' + num])) continue;
html += `| ${t.case[c]} | ` +
genders.map(gd => cell(map[gd + ':' + c + ':' + num], cur)).join('') + '
';
}
html += '
';
} else {
const has = CASES.some(c => map['_:' + c + ':' + num]);
if (!has) continue;
html += `${esc([heading, numLabel].filter(Boolean).join(' · '))}
`;
html += '';
for (const c of CASES) {
if (!map['_:' + c + ':' + num]) continue;
html += `| ${t.case[c]} | ` + cell(map['_:' + c + ':' + num], cur) + '
';
}
html += '
';
}
}
}
return html;
}
function line(label, forms, cur) {
if (!forms || !forms.length) return '';
const html = forms.map(f => f.toLowerCase() === cur ? `${esc(f)}` : esc(f)).join(' / ');
return `${esc(label)}${html}
`;
}
function verbTables(e, cur, lang) {
const t = L[lang];
const m = index(e.forms);
const get = k => m[k];
let html = '';
// Non-finite
html += line(t.infinitive, get('GM-NH') || get('NH'), cur);
html += line(t.infinitive + ' (MM)', get('MM-NH'), cur);
html += line(t.prespart, get('LHNT'), cur);
html += line(t.supine, get('GM-SAGNB') || get('SAGNB') || get('MM-SAGNB'), cur);
// Imperative (any BH tag)
const imp = [];
for (const [tag, f] of e.forms) if (/BH/.test(tag)) imp.push(f);
html += line(t.imperative, imp, cur);
// Finite tables: voice · mood · tense, rows persons × cols sg/pl
for (const voice of ['GM', 'MM']) {
for (const mood of ['FH', 'VH']) {
for (const tense of ['NT', 'ÞT']) {
const rows = ['1P', '2P', '3P'].map(p => ({
p, et: get([voice, mood, tense, p, 'ET'].join('-')),
ft: get([voice, mood, tense, p, 'FT'].join('-'))
}));
if (!rows.some(r => r.et || r.ft)) continue;
const hasPl = rows.some(r => r.ft);
html += `${[t.voice[voice], t.mood[mood], t.tense[tense]].join(' · ')}
`;
html += ' | ' + t.sg + ' | ' +
(hasPl ? '' + t.pl + ' | ' : '') + '
';
for (const r of rows) {
if (!r.et && !r.ft) continue;
html += `| ${t.person[r.p]} | ` + cell(r.et, cur) +
(hasPl ? cell(r.ft, cur) : '') + '
';
}
html += '
';
}
}
}
// Past participle (declines like an adjective)
const pp = e.forms.filter(([tag]) => /LHÞT/.test(tag));
if (pp.length) {
html += `${t.pastpart}
`;
html += declTables(pp.map(([tag, f]) => [tag.replace(/.*LHÞT-?/, ''), f]), cur, lang);
}
return html;
}
function fallbackList(e, cur, lang) {
return '' + e.forms.map(([tag, f]) =>
`| ${esc(tag)} | ` + cell([f], cur) + '
').join('') + '
';
}
function renderParadigm(e, currentForm, lang) {
const cur = (currentForm || '').toLowerCase();
if (!e || !e.forms || !e.forms.length) return '';
if (e.forms.length === 1 && /OBEYGJANLEGT/.test(e.forms[0][0])) {
return `${esc(e.forms[0][1])}${L[lang].indeclinable}
`;
}
if (NOUN[e.ofl]) return nounTables(e, cur, lang);
if (e.ofl === 'so') return verbTables(e, cur, lang);
if (e.ofl === 'lo') return declTables(e.forms, cur, lang);
// gendered pronoun / numeral?
if (e.forms.some(([tag]) => /(^|-)(KK|KVK|HK)(-|$)/.test(tag))) return declTables(e.forms, cur, lang);
// case-inflected without gender (personal pronouns etc.)
if (e.forms.some(([tag]) => /^(NF|ÞF|ÞGF|EF)(ET|FT)/.test(norm(tag)))) return nounTables(e, cur, lang);
return fallbackList(e, cur, lang);
}
global.renderParadigm = renderParadigm;
})(typeof window !== 'undefined' ? window : globalThis);