diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..9750de2
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,39 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 0,
+ "narHash": "sha256-463SNPWmz46iLzJKRzO3Q2b0Aurff3U1n0nYItxq7jU=",
+ "path": "/nix/store/yw6kg4rb9v8s3ypjbpspig5r81m4lr5s-source",
+ "type": "path"
+ },
+ "original": {
+ "id": "nixpkgs",
+ "type": "indirect"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "systems": "systems"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..a5883ef
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,21 @@
+{
+ inputs = {
+ # nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ systems.url = "github:nix-systems/default";
+ };
+
+ outputs =
+ { systems, nixpkgs, ... }@inputs:
+ let
+ eachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
+ in
+ {
+ devShells = eachSystem (pkgs: {
+ default = pkgs.mkShell {
+ buildInputs = [
+ pkgs.nodejs_22
+ ];
+ };
+ });
+ };
+}
diff --git a/library.svg b/library.svg
new file mode 100644
index 0000000..5532a7d
--- /dev/null
+++ b/library.svg
@@ -0,0 +1,82 @@
+
+
diff --git a/metamorpov.js b/metamorpov.js
new file mode 100644
index 0000000..8df6e7d
--- /dev/null
+++ b/metamorpov.js
@@ -0,0 +1,630 @@
+const verbsHelper = require("english-verbs-helper");
+const verbsIrregular = require("english-verbs-irregular/dist/verbs.json");
+const verbsGerunds = require("english-verbs-gerunds/dist/gerunds.json");
+const verbsData = verbsHelper.mergeVerbsData(verbsIrregular, verbsGerunds);
+const articleHelper = require("indefinite");
+
+/* Replaces MetamorPOV markers with English according to the options */
+exports.translate = function(input, options) {
+ let request = {
+ input: input,
+ options: options,
+ }
+
+ if (typeof(input) == "object" && input instanceof Node) {
+ request.loop = loopNode;
+ }
+ else if (typeof(input) == "string") {
+ request.loop = loopString;
+ }
+ else {
+ console.error("Invalid input type");
+ return;
+ }
+
+ const isNameSet = !(options.name === undefined || options.name == "");
+ const isPronounsSet = !(options.preset === undefined || options.preset == "");
+ const isPovSet = !(options.pov === undefined || options.pov == "");
+ const isPovLegal = isPovSet && (options.pov != "third" || (isNameSet && isPronounsSet));
+
+ if (isPovLegal) {
+ vrb(request);
+ pov(request);
+ plv(request);
+ }
+
+ if (isNameSet) {
+ yn(request);
+ }
+
+ if (isPronounsSet) {
+ prn(request);
+ }
+
+ const isAlsoSet = !(options.also === undefined);
+ if (isAlsoSet) {
+ also(request);
+ }
+
+ const isAllSet = isNameSet && isPronounsSet && isPovLegal;
+ if (isAllSet) {
+ alt(request);
+ ife(request);
+ cut(request);
+ cap(request);
+ mrr(request);
+ aan(request);
+ }
+
+ return request.input;
+}
+
+/* Iterator for HTML Node elements */
+function loopNode(request, params, method) {
+ const node = request.input;
+ if (node.contentEditable == "true" || node.type == "textarea" || node.type == "input") { return; }
+ let child, next;
+ switch (node.nodeType) {
+ case 1: /* ELEMENT_NODE */
+ case 9: /* DOCUMENT_NODE */
+ case 11: /* DOCUMENT_FRAGMENT_NODE */
+ child = node.firstChild;
+ while (child) {
+ next = child.nextSibling;
+ loopNode(node, params, method);
+ child = next;
+ }
+ break;
+ case 3: /* TEXT_NODE */
+ loopString(node.nodeValue, params, method);
+ }
+}
+
+/* Iterator for string elements */
+function loopString(request, params, method) {
+ if (request.input.match(params.regexp) == null) { return; }
+ request.input = method(request.input, params);
+ loopString(request, params, method);
+}
+
+/* vrb/be/ becomes "were" in second-person and "was" in third-person POV */
+function vrb(request) {
+ const regexp = new RegExp([
+ /(?v)r/,
+ /(?[nb])\//,
+ /(?[\w\s]+)\//,
+ /(?:(?(?:(?:simple|progressive|perfect|perfect[ _]progressive|participle(?![ _]future))[ _])*(?:past|present|future))\/)*/
+ ].map(r => r.source).join(''),'i');
+
+ request.loop(request, {
+ regexp: regexp,
+ options: request.options
+ }, (input, params) => {
+ const options = params.options;
+ const vars = input.match(params.regexp).groups;
+ let tense = vars.tense;
+ if (tense == null) {
+ tense = "PAST";
+ } else {
+ tense = tense.toUpperCase().replaceAll(" ", "_");
+ }
+
+ let povIndex;
+ switch (vars.category) {
+ case "B": /* B and b vary by POV, capitalized to indicate a named subject */
+ if (options.pov == "third") {
+ povIndex = getPovIndex("third-singular", null);
+ } else {
+ povIndex = getPovIndex(options.pov, options);
+ }
+ break;
+ case "b":
+ povIndex = getPovIndex(options.pov, options);
+ break;
+ case "N": /* N and n are always third-person */
+ povIndex = getPovIndex("third-singular", null);
+ break;
+ case "n":
+ povIndex = getPovIndex("third", options);
+ }
+
+ let replacement = verbsHelper.getConjugation(verbsData, vars.verb, tense, povIndex);
+ const isCapital = /V/.test(vars.firstLetter);
+ if (isCapital) {
+ replacement = capitalize(replacement);
+ }
+ return input.replace(params.regexp, replacement);
+ });
+}
+
+/* pov/s becomes "I" or "you" or "she" by POV */
+function pov(request) {
+ const options = request.options;
+ let pronouns, params;
+ if (request.options.pov == "third") {
+ pronouns = getPronouns(options.preset, options.other);
+ params = [
+ {
+ regexp: /([Pp])ov\/S/,
+ pronoun: options.name
+ }, {
+ regexp: /([Pp])ov\/O/,
+ pronoun: options.name
+ }, {
+ regexp: /([Pp])ov\/P/,
+ pronoun: options.name + "’s"
+ }, {
+ regexp: /([Pp])ov\/A/,
+ pronoun: options.name + "’s"
+ }, {
+ regexp: /([Pp])ov\/R/,
+ pronoun: options.name + "’s self"
+ }
+ ];
+ } else {
+ pronouns = getPronouns(options.pov, null);
+ params = [
+ {
+ regexp: /([Pp])ov\/S/,
+ pronoun: pronouns["subjective"]
+ }, {
+ regexp: /([Pp])ov\/O/,
+ pronoun: pronouns["objective"]
+ }, {
+ regexp: /([Pp])ov\/P/,
+ pronoun: pronouns["possessive"]
+ }, {
+ regexp: /([Pp])ov\/A/,
+ pronoun: pronouns["adjective"]
+ }, {
+ regexp: /([Pp])ov\/R/,
+ pronoun: pronouns["reflexive"]
+ }
+ ];
+ }
+
+ params.push(
+ {
+ regexp: /([Pp])ov\/s/,
+ pronoun: pronouns["subjective"]
+ }, {
+ regexp: /([Pp])ov\/o/,
+ pronoun: pronouns["objective"]
+ }, {
+ regexp: /([Pp])ov\/p/,
+ pronoun: pronouns["possessive"]
+ }, {
+ regexp: /([Pp])ov\/a/,
+ pronoun: pronouns["adjective"]
+ }, {
+ regexp: /([Pp])ov\/r/,
+ pronoun: pronouns["reflexive"]
+ }
+ );
+
+ params.forEach((pronoun) => request.loop(request, pronoun, prnHelper));
+}
+
+/* prn/s becomes "we" or "they" by POV */
+function plv(request) {
+ const pronouns = getPronouns(request.options.pov + "-plural", null);
+ const params = [
+ {
+ regexp: /([Pp])lv\/s/,
+ pronoun: pronouns["subjective"]
+ }, {
+ regexp: /([Pp])lv\/o/,
+ pronoun: pronouns["objective"]
+ }, {
+ regexp: /([Pp])lv\/p/,
+ pronoun: pronouns["possessive"]
+ }, {
+ regexp: /([Pp])lv\/a/,
+ pronoun: pronouns["adjective"]
+ }, {
+ regexp: /([Pp])lv\/r/,
+ pronoun: pronouns["reflexive"]
+ }
+ ];
+
+ params.forEach((pronoun) => request.loop(request, pronoun, prnHelper));
+}
+
+/* Y/n becomes the name indicated in the options */
+function yn(request) {
+ request.loop(request, {
+ regexp: /\by\/n\b|\(y\/n\)|\[y\/n\]/ig,
+ replacement: request.options["name"]
+ }, (input, params) => {
+ return input.replaceAll(params.regexp, params.replacement);
+ });
+}
+
+/* prn/s becomes "he" or "she" and are always third-person */
+function prn(request) {
+ const pronouns = getPronouns(request.options.preset, request.options.other);
+ const params = [
+ {
+ regexp: /([Pp])rn\/s/,
+ pronoun: pronouns["subjective"]
+ }, {
+ regexp: /([Pp])rn\/o/,
+ pronoun: pronouns["objective"]
+ }, {
+ regexp: /([Pp])rn\/p/,
+ pronoun: pronouns["possessive"]
+ }, {
+ regexp: /([Pp])rn\/a/,
+ pronoun: pronouns["adjective"]
+ }, {
+ regexp: /([Pp])rn\/r/,
+ pronoun: pronouns["reflexive"]
+ }, {
+ regexp: /([Pp])rn\/H/,
+ pronoun: pronouns["honorific-abbr"]
+ }, {
+ regexp: /([Pp])rn\/h/,
+ pronoun: pronouns["honorific"]
+ }, {
+ regexp: /([Pp])rn\/N/,
+ pronoun: pronouns["adult"]
+ }, {
+ regexp: /([Pp])rn\/n/,
+ pronoun: pronouns["youth"]
+ }, {
+ regexp: /([Pp])rn\/F/,
+ pronoun: pronouns["parent"]
+ }, {
+ regexp: /([Pp])rn\/f/,
+ pronoun: pronouns["child"]
+ }, {
+ regexp: /([Pp])rn\/k/,
+ pronoun: pronouns["sibling"]
+ }, {
+ regexp: /([Pp])rn\/m/,
+ pronoun: pronouns["married"]
+ }, {
+ regexp: /([Pp])rn\/d/,
+ pronoun: pronouns["dating"]
+ }
+ ]
+
+ params.forEach((pronoun) => request.loop(request, pronoun, prnHelper));
+}
+
+/* Replaces specific words indicated in the options */
+function also(request) {
+ Object.entries(options.also).forEach(([searchTerm, replacement]) => {
+ let searchTermEscaped = searchTerm.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
+
+ // Remove whitespace from the front and back
+ if (searchTermEscaped[0].match(/[a-z]/i)) {
+ searchTermEscaped = `\\b${searchTermEscaped}`;
+ }
+ if (searchTermEscaped[searchTermEscaped.length - 1].match((/[a-z]/i))) {
+ searchTermEscaped = `${searchTermEscaped}\\b`;
+ }
+
+ const searchTermRegexp = new RegExp(searchTermEscaped);
+ request.loop(request, {
+ regexp: searchTermRegexp,
+ replacement: replacement
+ }, (input, params) => {
+ return input.replaceAll(params.regexp, params.replacement);
+ });
+ });
+}
+
+/* alt/first and second or third/word1/word2/ becomes "word1" in first and second-person POV but "word2" in third-person */
+function alt(request) {
+ const regexp1 = new RegExp([
+ /alt\/(?first|second|third)/,
+ /\/(?[^\/]*)\//
+ ].map(r => r.source).join(''),'i');
+ request.loop(request, {
+ regexp: regexp1,
+ pov: request.options.pov
+ }, (input, params) => {
+ const vars = input.match(params.regexp).groups;
+ if (vars.targetPov == params.pov) {
+ return input.replace(params.regexp, vars.replacement);
+ } else {
+ return input.replace(params.regexp, null);
+ }
+ });
+
+ const regexp2 = new RegExp([
+ /alt\/(?first|second|third) /,
+ /(?and|or) /,
+ /(?!\1)(?first|second|third)/,
+ /(?: (?!\2)(?and|or) (?!\1|\3)(?:first|second|third))?/,
+ /\/(?[^\/]*)\//,
+ /(?[^\/]*)\//
+ ].map(r => r.source).join(''),'i');
+ request.loop(request, {
+ regexp: regexp2,
+ pov: request.options.pov
+ }, (input, params) => {
+ const vars = input.match(params.regexp).groups;
+ if (vars.targetPov1 == params.pov || vars.conjunction1.toLowerCase() == "and" && vars.targetPov2 == params.pov) {
+ return input.replace(params.regexp, vars.replacement1);
+ } else if (vars.conjunction1.toLowerCase() == "or" && vars.targetPov2 == params.pov) {
+ return input.replace(params.regexp, vars.replacement2);
+ } else if (vars.conjunction2 == null) {
+ return input.replace(params.regexp, null);
+ } else {
+ return input.replace(params.regexp, vars.replacement2);
+ }
+ });
+
+ const regexp3 = new RegExp([
+ /alt\/(?[^\/]*)\//,
+ /(?[^\/]*)\//,
+ /(?[^\/]*)\//
+ ].map(r => r.source).join(''),'i');
+ request.loop(request, {
+ regexp: regexp3,
+ pov: request.options.pov
+ }, (input, params) => {
+ const vars = input.match(params.regexp).groups;
+ switch (params.pov) {
+ case "first":
+ return input.replace(params.regexp, vars.replacement1);
+ case "second":
+ return input.replace(params.regexp, vars.replacement2);
+ case "third":
+ return input.replace(params.regexp, vars.replacement3);
+ }
+ });
+}
+
+/* if/a is a/word/ becomes "word" because the condition is met */
+function ife(request) {
+ const regexp1 = new RegExp([
+ /if\/(?[^\/]*) is /,
+ /(?[^\/]*)\//,
+ /(?[^\/]*)\//
+ ].map(r => r.source).join(''),'i');
+ request.loop(request, {
+ regexp: regexp1
+ }, (input, params) => {
+ const vars = input.match(params.regexp).groups;
+ if (vars.lhs === vars.rhs) {
+ return input.replace(params.regexp, vars.replacement);
+ } else {
+ return null;
+ }
+ });
+
+ const regexp2 = new RegExp([
+ /ife\/(?[^\/]*) is /,
+ /(?[^\/]*)\//,
+ /(?[^\/]*)\//,
+ /(?[^\/]*)\//
+ ].map(r => r.source).join(''),'i');
+ request.loop(request, {
+ regexp: regexp1
+ }, (input, params) => {
+ const vars = input.match(params.regexp).groups;
+ if (vars.lhs === vars.rhs) {
+ return input.replace(params.regexp, vars.replacement);
+ } else {
+ return input.replace(params.regexp, vars.else);
+ }
+ });
+}
+
+/* cut/off first 1/word/ shortens into "ord" */
+function cut(request) {
+ const regexp = new RegExp([
+ /cut\/(?[^\/]+)\//,
+ /(?off|only) /,
+ /(?first|last) /,
+ /(?[1-9][0-9]*)\//
+ ].map(r => r.source).join(''),'i');
+ request.loop(request, {
+ regexp: regexp
+ }, (input, params) => {
+ const vars = input.match(params.regexp).groups;
+ const length = vars.target.length;
+ let start, end;
+ if (vars.offonly == "only") {
+ if (isFirst) { // only first n
+ start = 0;
+ end = Math.min(vars.index, length - 1);
+ } else { // only last n
+ start = Math.max(length - vars.index, 1);
+ end = length;
+ }
+ } else {
+ if (vars.firstlast == "first") { // off first n
+ start = Math.min(vars.index, length - 1);;
+ end = length;
+ } else { // off last n
+ start = 0;
+ end = Math.max(length - vars.index, 1);
+ }
+ }
+ return vars.target.slice(start, end);
+ });
+}
+
+/* cap/WORD/ changes the case to "word" (Cap/word/ for "Word" and CAP/word/ for "WORD") */
+function cap(request) {
+ const regexp = new RegExp([
+ /(?