Initial import
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..e2f22aa
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,440 @@
+#!/usr/bin/env node
+
+// conllu-gender
+// Reads CoNLL-U format from stdin and annotates German gender-sensitive personal
+// nouns, gendered determiners/pronouns, and neo-pronouns with correct POS (UPOS
+// and XPOS/STTS), lemma, and morphological features.
+//
+// Based on the morphosyntactic analysis in:
+// Ochs, S. (2026). Die morphosyntaktische Integration neuer Gendersuffixe:
+// Eine korpusbasierte Analyse deutschsprachiger Pressetexte.
+// Gender Linguistics, 2. doi: https://doi.org/10.65020/0619d927
+//
+// Gender marker types (following Ochs & Rüdiger 2025):
+// Non-binary intended: Genderstern (*), Doppelpunkt (:), Unterstrich (_)
+// Binary intended: Binnen-I (I), Klammern (()), Schrägstrich (/)
+
+// ---------------------------------------------------------------------------
+// Regex patterns for gender-sensitive NOUNS
+// ---------------------------------------------------------------------------
+// Each regex captures: (base, marker, suffix)
+// suffix is either 'in' (singular) or 'innen' (plural)
+
+// Genderstern: Lehrer*in, Bürger*innen, Ärzt*innen
+const nounGenderStarRegex = /^(.+)\*(in(?:nen)?)$/i;
+// Doppelpunkt: Lehrer:in, Bürger:innen
+const nounGenderColonRegex = /^(.+):(in(?:nen)?)$/i;
+// Unterstrich: Lehrer_in, Bürger_innen
+const nounGenderUnderscoreRegex = /^(.+)_(in(?:nen)?)$/i;
+// Binnen-I: LehrerIn, LehrerInnen (case-sensitive – the I is uppercase)
+// The base must end in a lowercase letter to avoid matching regular proper nouns
+// that start a sentence. We require at least one lowercase letter before the I.
+const nounBinnenIRegex = /^([A-ZÄÖÜ][a-zäöüß].*?[a-zäöüß])(In(?:nen)?)$/;
+// Klammern: Lehrer(in), Lehrer(innen)
+const nounKlammernRegex = /^(.+)\((in(?:nen)?)\)$/i;
+// Schrägstrich: Lehrer/in, Lehrer/innen, Lehrer/-in, Lehrer/-innen
+const nounSchraegstrichRegex = /^(.+)\/-?(in(?:nen)?)$/i;
+
+// ---------------------------------------------------------------------------
+// Regex patterns for gender-sensitive DETERMINERS / PRONOUNS
+// (jede*r, ein*e, der*die, des*r, eines*r, etc.)
+// ---------------------------------------------------------------------------
+// Inflected forms of articles, indefinite articles, and pronouns with gender
+// markers. Non-binary intended markers (*, :, _) are the most common.
+// We match: any known determiner/pronoun stem + gender_marker + ending
+
+// Gendered forms like: jede*r, jede:r, jede_r, kein*e, kein:e, ein*e, ein:e,
+// ein_e, der*die, die*der, des*r, des*der, dem*der, den*die, etc.
+// Strategy: match known Determiner/Pronoun base forms followed by gender marker
+// and a short inflectional ending.
+
+// Combined pattern: known pronoun/det base + non-binary marker + short ending
+// This covers forms documented in Ochs (2026) §7.3.2–7.3.4
+const detNonBinaryRegex = /^(jede[mn]?|jede[rs]?|keine?[mrns]?|eine?[mrns]?|de[mrns]|die|das|de[rs]|dem|den|aller?|manche[mrns]?|solche[mrns]?|welche[mrns]?|irgendeine[mrns]?)([*:_])([a-zäöürs]{1,3})$/i;
+
+// Binnen-I variants of determiners: einE, jedeR, jedeN, JedeR, etc.
+// Base (lowercase or title-case) + uppercase inflection letter(s)
+const detBinnenIRegex = /^(jede[mn]?|keine?[mrns]?|eine?[mrns]?|alle?|manche?|solche?|welche?)([RNSEM]{1,2})$/;
+
+// Doppelform determiners merged with Schrägstrich (the only binary-intended merge
+// character for articles per Ochs 2026): ein/e, die/der, einen/r, etc.
+// Non-binary markers (*, :, _) are handled by detNonBinaryRegex with Gender=NonBin.
+const detDoppelformRegex = /^(der|die|das|dem|den|des|ein|eine|einen|einem|einer|eines)\/(der|die|das|dem|den|des|ein|eine|einen|einem|einer|eines|[rns])$/i;
+
+// ---------------------------------------------------------------------------
+// Neo-pronouns (new gender-neutral pronouns in German)
+// ---------------------------------------------------------------------------
+// Gendered-star pronoun pairs (sie*er, er*sie, ihr*sein, etc.)
+const neopronGenderStarPairRegex = /^(sie|er|ihr|ihn?|ihm?|dich|sich|mich|mir|uns|euch|ihnen|seinen?|ihrem?|deren?|denen)([*:_])(sie|er|ihr|ihn?|ihm?|dich|sich|mich|mir|uns|euch|ihnen|seinen?|ihrem?|deren?|denen)$/i;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Determine if a suffix string represents singular or plural.
+ * 'in' (length 2) → Sing
+ * 'innen' (length 5) → Plur
+ * Works case-insensitively (In / Innen for Binnen-I forms).
+ */
+function getNumber(suffix) {
+ return /^innen$/i.test(suffix) ? 'Plur' : 'Sing';
+}
+
+/**
+ * Build the canonical lemma for a gendered noun.
+ * The lemma is always the nominative singular form, preserving the original
+ * gender marker. This follows the convention that the lemma reflects the
+ * citation form of the gendered derivate (Ochs 2026 §2).
+ *
+ * @param {string} base - derivation base (before the gender marker)
+ * @param {string} marker - gender marker character(s), e.g. '*', ':', '_', 'I',
+ * '(in)', '/in', etc.
+ * @param {string} markerType - 'star'|'colon'|'underscore'|'binnenI'|
+ * 'klammern'|'schraegstrich'
+ */
+function buildNounLemma(base, marker, markerType) {
+ switch (markerType) {
+ case 'star': return base + '*in';
+ case 'colon': return base + ':in';
+ case 'underscore': return base + '_in';
+ case 'binnenI': return base + 'In';
+ case 'klammern': return base + '(in)';
+ case 'schraegstrich':return base + '/in';
+ default: return base + marker + 'in';
+ }
+}
+
+/**
+ * Build the morphological features string for a gendered noun token.
+ * Per CoNLL-U conventions, features are sorted alphabetically by feature name.
+ *
+ * Gender values used (extending standard UD practice for German):
+ * NonBin – non-binary intended forms (*, :, _)
+ * Masc,Fem – binary inclusive forms (I, (), /)
+ *
+ * Case is not set here because it cannot be determined from surface form alone
+ * for the vast majority of gendered noun tokens (Ochs 2026 §7.1).
+ *
+ * @param {string} number - 'Sing' | 'Plur'
+ * @param {string} markerType - see buildNounLemma
+ */
+function buildNounFeatures(number, markerType) {
+ const genderIsNonBinary = ['star', 'colon', 'underscore'].includes(markerType);
+ const genderIsBinary = ['binnenI', 'klammern', 'schraegstrich'].includes(markerType);
+
+ const feats = [];
+ if (genderIsNonBinary) {
+ feats.push('Gender=NonBin');
+ } else if (genderIsBinary) {
+ feats.push('Gender=Masc,Fem');
+ }
+ feats.push('Number=' + number);
+ return feats.join('|');
+}
+
+// ---------------------------------------------------------------------------
+// Command-line interface (mirrors conllu-cmc)
+// ---------------------------------------------------------------------------
+
+const optionDefinitions = [
+ { name: 'sparse', alias: 's', type: Boolean,
+ description: 'Print only the tokens that received new annotations.' },
+ { name: 'help', alias: 'h', type: Boolean,
+ description: 'Print this usage guide.' },
+];
+
+const sections = [
+ {
+ header: 'conllu-gender',
+ content: 'Reads CoNLL-U format from stdin and annotates German gender-sensitive ' +
+ 'personal nouns, gendered determiners/pronouns, and neo-pronouns with ' +
+ 'correct POS, lemma, and morphological features. Writes CoNLL-U to stdout.'
+ },
+ {
+ header: 'Synopsis',
+ content: '$ conllu-gender [-s] < input.conllu > output.conllu'
+ },
+ {
+ header: 'Options',
+ optionList: optionDefinitions
+ }
+];
+
+const getUsage = require('command-line-usage');
+const commandLineArgs = require('command-line-args');
+
+var options;
+try {
+ options = commandLineArgs(optionDefinitions);
+} catch (e) {
+ console.error(e.message);
+ options = { help: true };
+}
+
+if (options.help) {
+ const usage = getUsage(sections);
+ console.log(usage);
+ process.exit(0);
+}
+
+// ---------------------------------------------------------------------------
+// CoNLL-U processing
+// ---------------------------------------------------------------------------
+
+const readline = require('readline');
+global.header = '';
+global.fileheader = '';
+global.standalone = false;
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: false,
+});
+
+/**
+ * Attempt to annotate a single CoNLL-U token (word form).
+ * Returns an annotation object on success, or null if the token is not a
+ * recognised gender-sensitive form.
+ *
+ * Annotation object shape:
+ * { lemma, upos, xpos, feats }
+ */
+function classifyToken(word) {
+ let m;
+
+ // ------------------------------------------------------------------
+ // 1. Gender-sensitive NOUNS
+ // ------------------------------------------------------------------
+
+ // Genderstern (non-binary intended)
+ if ((m = nounGenderStarRegex.exec(word))) {
+ const [, base, suffix] = m;
+ const number = getNumber(suffix);
+ return {
+ lemma: buildNounLemma(base, '*', 'star'),
+ upos: 'NOUN',
+ xpos: 'NN',
+ feats: buildNounFeatures(number, 'star'),
+ };
+ }
+
+ // Doppelpunkt (non-binary intended)
+ if ((m = nounGenderColonRegex.exec(word))) {
+ const [, base, suffix] = m;
+ const number = getNumber(suffix);
+ return {
+ lemma: buildNounLemma(base, ':', 'colon'),
+ upos: 'NOUN',
+ xpos: 'NN',
+ feats: buildNounFeatures(number, 'colon'),
+ };
+ }
+
+ // Unterstrich (non-binary intended)
+ if ((m = nounGenderUnderscoreRegex.exec(word))) {
+ const [, base, suffix] = m;
+ const number = getNumber(suffix);
+ return {
+ lemma: buildNounLemma(base, '_', 'underscore'),
+ upos: 'NOUN',
+ xpos: 'NN',
+ feats: buildNounFeatures(number, 'underscore'),
+ };
+ }
+
+ // Schrägstrich (binary intended) – before Binnen-I to avoid false matches
+ if ((m = nounSchraegstrichRegex.exec(word))) {
+ const [, base, suffix] = m;
+ const number = getNumber(suffix);
+ return {
+ lemma: buildNounLemma(base, '/', 'schraegstrich'),
+ upos: 'NOUN',
+ xpos: 'NN',
+ feats: buildNounFeatures(number, 'schraegstrich'),
+ };
+ }
+
+ // Klammern (binary intended)
+ if ((m = nounKlammernRegex.exec(word))) {
+ const [, base, suffix] = m;
+ const number = getNumber(suffix);
+ return {
+ lemma: buildNounLemma(base, '()', 'klammern'),
+ upos: 'NOUN',
+ xpos: 'NN',
+ feats: buildNounFeatures(number, 'klammern'),
+ };
+ }
+
+ // Binnen-I (binary intended) – requires at least one lowercase letter before
+ // the I to distinguish from sentence-initial capitalisation
+ if ((m = nounBinnenIRegex.exec(word))) {
+ const [, base, suffix] = m;
+ const number = getNumber(suffix);
+ return {
+ lemma: buildNounLemma(base, 'I', 'binnenI'),
+ upos: 'NOUN',
+ xpos: 'NN',
+ feats: buildNounFeatures(number, 'binnenI'),
+ };
+ }
+
+ // ------------------------------------------------------------------
+ // 2. Gender-sensitive DETERMINERS / PRONOUNS
+ // ------------------------------------------------------------------
+
+ // Doppelform determiners merged with gender marker (der*die, des*r, etc.)
+ // Checked before detNonBinaryRegex because die*der is a Doppelform, not purely
+ // non-binary intended, and should receive Gender=Masc,Fem features.
+ if ((m = detDoppelformRegex.exec(word))) {
+ const [fullMatch, form1] = m;
+ return {
+ lemma: fullMatch,
+ upos: 'DET',
+ xpos: inferDetXpos(form1),
+ feats: 'Gender=Masc,Fem',
+ };
+ }
+
+ // Non-binary marker determiners (jede*r, ein:e, kein_e, etc.)
+ if ((m = detNonBinaryRegex.exec(word))) {
+ const [, detBase, marker, ending] = m;
+ // Preserve full base + marker + ending as lemma (no stripping needed;
+ // gendered determiners have no established uninflected citation form).
+ return {
+ lemma: detBase + marker + ending,
+ upos: 'DET',
+ xpos: inferDetXpos(detBase),
+ feats: 'Gender=NonBin',
+ };
+ }
+
+ // Binnen-I determiners (einE, JedeR, jedeN, etc.)
+ if ((m = detBinnenIRegex.exec(word))) {
+ const [, detBase, endings] = m;
+ return {
+ lemma: detBase + endings,
+ upos: 'DET',
+ xpos: inferDetXpos(detBase),
+ feats: 'Gender=Masc,Fem',
+ };
+ }
+
+ // ------------------------------------------------------------------
+ // 3. Neo-pronouns / gendered pronoun pairs
+ // ------------------------------------------------------------------
+
+ if ((m = neopronGenderStarPairRegex.exec(word))) {
+ const [fullMatch, pron1, marker, pron2] = m;
+ const markerType = marker === '*' ? 'star' : marker === ':' ? 'colon' : 'underscore';
+ return {
+ lemma: fullMatch,
+ upos: 'PRON',
+ xpos: inferPronXpos(pron1),
+ feats: markerType === 'star' || markerType === 'colon' || markerType === 'underscore'
+ ? 'Gender=NonBin' : 'Gender=Masc,Fem',
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Infer STTS XPOS tag for a determiner/article base.
+ */
+function inferDetXpos(base) {
+ const b = base.toLowerCase();
+ if (/^(der|die|das|de[mrns])/.test(b)) return 'ART';
+ if (/^(ein|eine|einen|einem|einer|eines|kein|keine|keinen|keinem|keiner|keines)/.test(b)) return 'ART';
+ if (/^(jede|jeder|jeden|jedem|jedes|jedem)/.test(b)) return 'PIAT';
+ if (/^(alle|aller|allen|alles|allem)/.test(b)) return 'PIAT';
+ if (/^(manche|mancher|manchen|manchem|manches)/.test(b)) return 'PIAT';
+ if (/^(solche|solcher|solchen|solchem|solches)/.test(b)) return 'PIAT';
+ if (/^(welche|welcher|welchen|welchem|welches)/.test(b)) return 'PWAT';
+ if (/^(irgend)/.test(b)) return 'PIAT';
+ return 'ART';
+}
+
+/**
+ * Infer STTS XPOS tag for a personal pronoun base.
+ */
+function inferPronXpos(base) {
+ const b = base.toLowerCase();
+ if (/^(ich|du|er|sie|es|wir|ihr|sie|mich|mir|dich|dir|sich|ihn|ihm|uns|euch)$/.test(b)) return 'PPER';
+ return 'PPER';
+}
+
+// ---------------------------------------------------------------------------
+// Main line-by-line processing loop (mirrors conllu-cmc approach)
+// ---------------------------------------------------------------------------
+
+function parseConllu(line) {
+ // Handle foundry comment: change to 'gender'
+ if (line.match('#\\s*foundry')) {
+ if (line.match('=\\s*base')) {
+ if (options.sparse) {
+ global.standalone = true;
+ }
+ process.stdout.write('# foundry = gender\n');
+ } else {
+ process.stdout.write(`${line}\n`);
+ }
+ return;
+ }
+
+ if (global.standalone) {
+ if (line.match('^#\\s*filename')) {
+ global.fileheader = `${line}\n`;
+ return;
+ } else if (line.match('^#\\s*text_id')) {
+ global.fileheader += `${line}\n`;
+ return;
+ } else if (line.match('^#\\s*eo[ft]')) {
+ process.stdout.write(`${line}\n`);
+ return;
+ } else if (line.match('^#')) {
+ global.header += `${line}\n`;
+ return;
+ } else if (line.trim().match('^$')) {
+ if (global.header === '') {
+ process.stdout.write('\n');
+ }
+ global.header = '';
+ return;
+ }
+ } else {
+ if (!line.match('^\\d+')) {
+ process.stdout.write(`${line}\n`);
+ return;
+ }
+ }
+
+ const columns = line.trim().split('\t');
+ // CoNLL-U columns (0-indexed):
+ // 0:ID 1:FORM 2:LEMMA 3:UPOS 4:XPOS 5:FEATS 6:HEAD 7:DEPREL 8:DEPS 9:MISC
+
+ const word = columns[1];
+ const annotation = classifyToken(word);
+
+ if (annotation) {
+ // Replace lemma (col 2), UPOS (col 3), XPOS (col 4), FEATS (col 5)
+ columns[2] = annotation.lemma;
+ columns[3] = annotation.upos;
+ columns[4] = annotation.xpos;
+ columns[5] = annotation.feats;
+
+ if (global.standalone) {
+ process.stdout.write(global.fileheader);
+ process.stdout.write(global.header);
+ global.header = global.fileheader = '';
+ }
+ process.stdout.write(columns.join('\t') + '\n');
+ } else if (!global.standalone) {
+ process.stdout.write(`${line}\n`);
+ }
+}
+
+rl.on('line', parseConllu);
+rl.on('close', () => process.exit(0));