This commit is contained in:
2026-08-08 17:09:43 +02:00
commit d47d87da82
18 changed files with 1046 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# Glagolify
Glagolify is a dependency-free Firefox WebExtension that converts modern Russian Cyrillic page text to Glagolitic and restores the exact source text when switched off.
## Install in Firefox
Requires Firefox 142 or newer.
1. Open `about:debugging#/runtime/this-firefox`.
2. Choose **Load Temporary Add-on…**.
3. Select this directory's `manifest.json`.
Temporary add-ons remain installed until Firefox restarts. For permanent local use, package and sign the extension through Mozilla Add-ons.
## Use
1. Open a normal web page.
2. Select the Glagolify toolbar icon.
3. Press **Enable Glagolitic**. Press **Restore Cyrillic** to undo it.
The popup intentionally has one action. The background script owns one absolute enabled/disabled state per tab, broadcasts it to every frame, and supplies it to frames created or navigated later. State lasts across navigation and is discarded when the tab closes. Firefox internal pages (`about:`, browser UI), extension pages, and other protected pages cannot be modified; the popup reports that restriction.
Glagolify updates existing text and watches for text nodes that are inserted or changed later. It leaves `script`, `style`, `noscript`, `template`, `textarea`, `select`, `option`, effective `contenteditable` regions, and documents in `designMode` untouched. Visible form labels, validation messages, and button text are converted, while text controls and selectable options are not. Textual DOM hidden by HTML, CSS, or `aria-hidden` is converted proactively so it is already Glagolitic if revealed. The implementation only changes text-node data and a reversible inline font fallback; it does not replace or wrap page elements. Disconnected nodes are restored and released after each mutation batch, while nodes moved and reconnected in the same batch keep their state.
Open shadow roots present when Glagolify is enabled are handled, including nested roots. Open roots under hosts added later are discovered through the host's DOM mutation. An open shadow root attached later to an already-connected, otherwise unchanged host may not be detected. Closed shadow roots are inaccessible to extensions and cannot be converted.
## Transliteration conventions
The converter has an explicit, case-preserving mapping for all 33 letters of the modern Russian alphabet (66 uppercase/lowercase entries). Punctuation, whitespace, digits, Latin text, Cyrillic characters outside that mapping, and already-Glagolitic text remain unchanged. Letters shared by Russian and another Cyrillic alphabet are converted because their code points are the same.
The mapping uses dedicated Glagolitic letters for `Ё` (Yo), `Й` (I), `Щ` (Shta), `Ю` (Yu), and `Я` (Small Yus). Modern Russian letters without exact historical one-to-one equivalents use these practical conventions:
- `Ь` → Yati (`Ⱑ` / `ⱑ`)
- `Ы` → Yeri (`Ⱐ` / `ⱐ`)
- `Э` → Yestu, the same output as `Е` (`Ⰵ` / `ⰵ`)
## Bundled font
`fonts/NotoSansGlagolitic-Regular.ttf` is bundled so converted characters remain legible when the operating system has no Glagolitic font. It is inserted before each affected element's existing computed font family, so non-Glagolitic characters continue to use the page's own typography. If the page genuinely changes an affected element's inline `font-family` while Glagolify is enabled, the extension retains that latest page-owned declaration, reapplies its Glagolitic fallback, and restores the latest page value when switched off.
The font is **Noto Sans Glagolitic Regular**, sourced from the [Google Fonts repository](https://github.com/google/fonts/tree/main/ofl/notosansglagolitic) and distributed under the SIL Open Font License 1.1. The included license is at `fonts/OFL.txt`.
## Automated checks
No install or build step is required. With Node.js available, run:
```sh
npm test
```
The test suite validates the complete 66-entry mapping, mixed-content preservation, case, already-Glagolitic behavior, and authoritative per-tab/background frame inheritance using Node's built-in test runner.
+79
View File
@@ -0,0 +1,79 @@
(function (root) {
"use strict";
function createBackgroundController(api) {
const tabStates = new Map();
function validTabId(tabId) {
return Number.isInteger(tabId) && tabId >= 0;
}
async function broadcast(tabId, enabled) {
try {
await api.tabs.sendMessage(tabId, {
type: "glagolify:set-enabled",
enabled
});
return true;
} catch (error) {
return false;
}
}
api.runtime.onMessage.addListener((message, sender) => {
if (!message || typeof message.type !== "string") {
return undefined;
}
if (message.type === "glagolify:document-ready") {
const tabId = sender && sender.tab && sender.tab.id;
return Promise.resolve({
enabled: validTabId(tabId) && tabStates.get(tabId) === true
});
}
if (message.type === "glagolify:get-tab-state") {
const tabId = message.tabId;
if (!validTabId(tabId)) {
return Promise.resolve({ enabled: false, available: false });
}
const enabled = tabStates.get(tabId) === true;
return broadcast(tabId, enabled).then((available) => ({ enabled, available }));
}
if (message.type === "glagolify:set-tab-enabled") {
const tabId = message.tabId;
if (!validTabId(tabId) || typeof message.enabled !== "boolean") {
return Promise.resolve({ enabled: false, available: false });
}
tabStates.set(tabId, message.enabled);
return broadcast(tabId, message.enabled).then((available) => ({
enabled: message.enabled,
available
}));
}
return undefined;
});
api.tabs.onRemoved.addListener((tabId) => {
tabStates.delete(tabId);
});
return { tabStates, broadcast };
}
const exported = Object.freeze({ createBackgroundController });
if (typeof browser === "object" && browser.runtime && browser.tabs) {
createBackgroundController(browser);
}
if (typeof module === "object" && module.exports) {
module.exports = exported;
}
root.GlagolifyBackground = exported;
})(typeof globalThis === "object" ? globalThis : this);
+8
View File
@@ -0,0 +1,8 @@
@font-face {
font-family: "Glagolify Noto Sans Glagolitic";
src: url("fonts/NotoSansGlagolitic-Regular.ttf") format("truetype");
font-style: normal;
font-weight: 400;
font-display: swap;
unicode-range: U+2C00-2C5F;
}
+327
View File
@@ -0,0 +1,327 @@
(function () {
"use strict";
const EXCLUDED_ANCESTORS = "head, script, style, noscript, template, textarea, select, option";
const FONT_FAMILY = '"Glagolify Noto Sans Glagolitic"';
const trackedText = new Map();
const styledElements = new Map();
const observers = new Map();
let ownTextMutationCounts = new WeakMap();
let ownStyleMutationCounts = new WeakMap();
let enabled = false;
function isEditable(element) {
return (
element.ownerDocument.designMode === "on" ||
element.isContentEditable
);
}
function isEligible(node) {
const parent = node.parentElement;
return Boolean(
parent &&
!parent.closest(EXCLUDED_ANCESTORS) &&
!isEditable(parent)
);
}
function observedRootFor(node) {
const root = node.getRootNode();
return observers.has(root) ? root : null;
}
function markOwnMutation(counts, node) {
if (!observedRootFor(node)) {
return;
}
counts.set(node, (counts.get(node) || 0) + 1);
}
function consumeOwnMutation(counts, node) {
const count = counts.get(node) || 0;
if (count === 0) {
return false;
}
if (count === 1) {
counts.delete(node);
} else {
counts.set(node, count - 1);
}
return true;
}
function appliedFontFamily(computedFamily) {
return computedFamily
? `${FONT_FAMILY}, ${computedFamily}`
: `${FONT_FAMILY}, sans-serif`;
}
function writeAppliedFont(element, record, computedFamily) {
const appliedValue = appliedFontFamily(computedFamily);
record.appliedValue = appliedValue;
if (
element.style.getPropertyValue("font-family") === appliedValue &&
element.style.getPropertyPriority("font-family") === "important"
) {
return;
}
markOwnMutation(ownStyleMutationCounts, element);
element.style.setProperty("font-family", appliedValue, "important");
}
function applyFont(element) {
if (styledElements.has(element)) {
return;
}
const record = {
pageValue: element.style.getPropertyValue("font-family"),
pagePriority: element.style.getPropertyPriority("font-family"),
appliedValue: ""
};
const computedFamily = getComputedStyle(element).fontFamily;
styledElements.set(element, record);
writeAppliedFont(element, record, computedFamily);
}
function refreshPageFont(element) {
const record = styledElements.get(element);
if (!record) {
return;
}
const currentValue = element.style.getPropertyValue("font-family");
const currentPriority = element.style.getPropertyPriority("font-family");
if (currentValue === record.appliedValue && currentPriority === "important") {
return;
}
record.pageValue = currentValue;
record.pagePriority = currentPriority;
writeAppliedFont(element, record, getComputedStyle(element).fontFamily);
}
function restoreFont(element, record) {
const currentValue = element.style.getPropertyValue("font-family");
const currentPriority = element.style.getPropertyPriority("font-family");
if (currentValue !== record.appliedValue || currentPriority !== "important") {
return;
}
markOwnMutation(ownStyleMutationCounts, element);
if (record.pageValue) {
element.style.setProperty("font-family", record.pageValue, record.pagePriority);
} else {
element.style.removeProperty("font-family");
}
}
function restoreText(node) {
const record = trackedText.get(node);
if (!record) {
return;
}
if (node.data === record.transformed) {
markOwnMutation(ownTextMutationCounts, node);
node.data = record.original;
}
trackedText.delete(node);
}
function transformText(node) {
if (!isEligible(node)) {
restoreText(node);
return;
}
const previous = trackedText.get(node);
if (previous && node.data === previous.transformed) {
return;
}
const original = node.data;
const transformed = Glagolify.transliterate(original);
if (transformed === original) {
trackedText.delete(node);
return;
}
trackedText.set(node, { original, transformed });
applyFont(node.parentElement);
markOwnMutation(ownTextMutationCounts, node);
node.data = transformed;
}
function visitText(root, callback) {
if (root.nodeType === Node.TEXT_NODE) {
callback(root);
return;
}
if (
root.nodeType !== Node.ELEMENT_NODE &&
root.nodeType !== Node.DOCUMENT_NODE &&
root.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
) {
return;
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
callback(node);
}
}
function reconcile(root) {
visitText(root, transformText);
}
function discoverOpenShadowRoots(root) {
if (
root.nodeType === Node.ELEMENT_NODE &&
root.shadowRoot &&
root.shadowRoot.mode === "open"
) {
observeRoot(root.shadowRoot);
}
if (typeof root.querySelectorAll !== "function") {
return;
}
for (const element of root.querySelectorAll("*")) {
if (element.shadowRoot && element.shadowRoot.mode === "open") {
observeRoot(element.shadowRoot);
}
}
}
function cleanupDisconnected() {
for (const node of trackedText.keys()) {
if (!node.isConnected) {
restoreText(node);
}
}
for (const [element, record] of styledElements) {
if (!element.isConnected) {
restoreFont(element, record);
styledElements.delete(element);
}
}
for (const [root, observer] of observers) {
if (root !== document && !root.host.isConnected) {
observer.disconnect();
observers.delete(root);
}
}
}
function handleMutations(records) {
for (const record of records) {
if (record.type === "characterData") {
if (!consumeOwnMutation(ownTextMutationCounts, record.target)) {
reconcile(record.target);
}
continue;
}
if (record.type === "attributes") {
if (record.attributeName === "style") {
if (!consumeOwnMutation(ownStyleMutationCounts, record.target)) {
refreshPageFont(record.target);
}
} else if (record.attributeName === "contenteditable") {
reconcile(record.target);
}
continue;
}
for (const node of record.addedNodes) {
reconcile(node);
discoverOpenShadowRoots(node);
}
}
cleanupDisconnected();
}
function observeRoot(root) {
if (observers.has(root)) {
return;
}
reconcile(root);
const observer = new MutationObserver(handleMutations);
observer.observe(root, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["contenteditable", "style"]
});
observers.set(root, observer);
discoverOpenShadowRoots(root);
}
function enable() {
if (enabled) {
return;
}
enabled = true;
observeRoot(document);
}
function disable() {
if (!enabled) {
return;
}
enabled = false;
for (const observer of observers.values()) {
observer.disconnect();
}
observers.clear();
for (const node of trackedText.keys()) {
restoreText(node);
}
for (const [element, record] of styledElements) {
restoreFont(element, record);
}
styledElements.clear();
ownTextMutationCounts = new WeakMap();
ownStyleMutationCounts = new WeakMap();
}
function setEnabled(target) {
if (target) {
enable();
} else {
disable();
}
return { enabled };
}
browser.runtime.onMessage.addListener((message) => {
if (!message || message.type !== "glagolify:set-enabled" || typeof message.enabled !== "boolean") {
return undefined;
}
return Promise.resolve(setEnabled(message.enabled));
});
browser.runtime.sendMessage({ type: "glagolify:document-ready" })
.then((response) => {
setEnabled(Boolean(response && response.enabled));
})
.catch(() => {
setEnabled(false);
});
})();
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/glagolitic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Glagolify">
<defs><linearGradient id="g" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse"><stop stop-color="#8b5de3"/><stop offset="1" stop-color="#4e2a94"/></linearGradient></defs>
<rect x="3" y="3" width="58" height="58" rx="15" fill="url(#g)"/>
<path d="M19 47V25c0-7 5-12 13-12s13 5 13 12-5 12-13 12H19m13-15a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="48" r="4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Glagolify">
<defs><linearGradient id="g" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse"><stop stop-color="#8b5de3"/><stop offset="1" stop-color="#4e2a94"/></linearGradient></defs>
<rect x="3" y="3" width="58" height="58" rx="15" fill="url(#g)"/>
<path d="M19 47V25c0-7 5-12 13-12s13 5 13 12-5 12-13 12H19m13-15a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="48" r="4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Glagolify">
<defs><linearGradient id="g" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse"><stop stop-color="#8b5de3"/><stop offset="1" stop-color="#4e2a94"/></linearGradient></defs>
<rect x="3" y="3" width="58" height="58" rx="15" fill="url(#g)"/>
<path d="M19 47V25c0-7 5-12 13-12s13 5 13 12-5 12-13 12H19m13-15a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="48" r="4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Glagolify">
<defs><linearGradient id="g" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse"><stop stop-color="#8b5de3"/><stop offset="1" stop-color="#4e2a94"/></linearGradient></defs>
<rect x="3" y="3" width="58" height="58" rx="15" fill="url(#g)"/>
<path d="M19 47V25c0-7 5-12 13-12s13 5 13 12-5 12-13 12H19m13-15a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="48" r="4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+52
View File
@@ -0,0 +1,52 @@
{
"manifest_version": 2,
"name": "Glagolify",
"version": "1.0.0",
"description": "Toggle modern Russian page text between Cyrillic and Glagolitic.",
"browser_specific_settings": {
"gecko": {
"id": "glagolify@example.local",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": ["none"]
}
}
},
"permissions": [
"activeTab",
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"browser_action": {
"default_title": "Glagolify this page",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.svg",
"32": "icons/icon-32.svg",
"48": "icons/icon-48.svg",
"96": "icons/icon-96.svg"
}
},
"icons": {
"16": "icons/icon-16.svg",
"32": "icons/icon-32.svg",
"48": "icons/icon-48.svg",
"96": "icons/icon-96.svg"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["transliterate.js", "content.js"],
"css": ["content.css"],
"run_at": "document_idle",
"all_frames": true,
"match_about_blank": true
}
],
"web_accessible_resources": [
"fonts/NotoSansGlagolitic-Regular.ttf"
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "glagolify-firefox-extension",
"version": "1.0.0",
"private": true,
"description": "A dependency-free Firefox extension for toggling modern Russian text to Glagolitic.",
"scripts": {
"test": "node --test"
}
}
+114
View File
@@ -0,0 +1,114 @@
:root {
color-scheme: light dark;
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
width: 292px;
margin: 0;
color: #22202b;
background: #fbfaff;
}
main {
padding: 20px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
header img {
flex: 0 0 auto;
filter: drop-shadow(0 3px 7px rgb(49 30 93 / 20%));
}
h1,
p {
margin: 0;
}
h1 {
font-size: 18px;
line-height: 1.2;
letter-spacing: -0.01em;
}
header p {
margin-top: 2px;
color: #6d6878;
font-size: 12px;
}
.status {
min-height: 20px;
margin-bottom: 10px;
color: #5f596a;
font-size: 13px;
}
button {
width: 100%;
min-height: 42px;
border: 0;
border-radius: 10px;
padding: 10px 16px;
color: #fff;
background: #6842b8;
box-shadow: 0 5px 14px rgb(77 45 146 / 25%);
font: inherit;
font-weight: 650;
cursor: pointer;
transition: background 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
background: #56329e;
box-shadow: 0 7px 17px rgb(77 45 146 / 31%);
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
button:focus-visible {
outline: 3px solid #bba2ec;
outline-offset: 2px;
}
button[aria-pressed="true"] {
background: #34303d;
box-shadow: 0 5px 14px rgb(24 20 32 / 22%);
}
button:disabled {
color: #a29da9;
background: #e7e3eb;
box-shadow: none;
cursor: not-allowed;
}
@media (prefers-color-scheme: dark) {
body {
color: #f4f0fa;
background: #201d26;
}
header p,
.status {
color: #bdb5c7;
}
button:disabled {
color: #77717e;
background: #343039;
}
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glagolify</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<main>
<header>
<img src="icons/icon-48.svg" width="40" height="40" alt="">
<div>
<h1>Glagolify</h1>
<p>Modern Russian in Glagolitic</p>
</div>
</header>
<p id="status" class="status" aria-live="polite">Checking this page…</p>
<button id="toggle" type="button" aria-pressed="false" disabled>Enable Glagolitic</button>
</main>
<script src="popup.js"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
(function () {
"use strict";
const status = document.getElementById("status");
const toggle = document.getElementById("toggle");
let activeTabId = null;
let currentEnabled = false;
let busy = false;
function render(enabled) {
currentEnabled = enabled;
toggle.disabled = busy || activeTabId === null;
toggle.setAttribute("aria-pressed", String(enabled));
toggle.textContent = enabled ? "Restore Cyrillic" : "Enable Glagolitic";
status.textContent = enabled ? "Glagolitic is on for this tab." : "Cyrillic is unchanged in this tab.";
}
function showUnavailable() {
activeTabId = null;
currentEnabled = false;
toggle.disabled = true;
toggle.setAttribute("aria-pressed", "false");
toggle.textContent = "Unavailable on this page";
status.textContent = "Firefox protects this page from extensions.";
}
async function askBackground(type, extra = {}) {
return browser.runtime.sendMessage({ type, ...extra });
}
toggle.addEventListener("click", async () => {
if (busy || activeTabId === null) {
return;
}
const targetEnabled = !currentEnabled;
busy = true;
toggle.disabled = true;
status.textContent = "Updating tab…";
try {
const response = await askBackground("glagolify:set-tab-enabled", {
tabId: activeTabId,
enabled: targetEnabled
});
if (!response || !response.available) {
showUnavailable();
return;
}
render(response.enabled === true);
} catch (error) {
showUnavailable();
} finally {
busy = false;
if (activeTabId !== null) {
toggle.disabled = false;
}
}
});
async function initialize() {
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
if (!tabs[0] || typeof tabs[0].id !== "number") {
showUnavailable();
return;
}
activeTabId = tabs[0].id;
const response = await askBackground("glagolify:get-tab-state", {
tabId: activeTabId
});
if (!response || !response.available) {
showUnavailable();
return;
}
render(response.enabled === true);
} catch (error) {
showUnavailable();
}
}
initialize();
})();
+100
View File
@@ -0,0 +1,100 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createBackgroundController } = require("../background.js");
function makeBrowser() {
let messageListener;
let removedListener;
const broadcasts = [];
const api = {
runtime: {
onMessage: {
addListener(listener) {
messageListener = listener;
}
}
},
tabs: {
async sendMessage(tabId, message) {
broadcasts.push({ tabId, message });
return { enabled: message.enabled };
},
onRemoved: {
addListener(listener) {
removedListener = listener;
}
}
}
};
return {
api,
broadcasts,
send(message, sender = {}) {
return messageListener(message, sender);
},
remove(tabId) {
removedListener(tabId);
}
};
}
test("owns absolute state per tab and supplies it to new frames", async () => {
const fake = makeBrowser();
const controller = createBackgroundController(fake.api);
assert.deepEqual(
await fake.send({ type: "glagolify:document-ready" }, { tab: { id: 17 }, frameId: 0 }),
{ enabled: false }
);
assert.deepEqual(
await fake.send({ type: "glagolify:set-tab-enabled", tabId: 17, enabled: true }),
{ enabled: true, available: true }
);
assert.deepEqual(fake.broadcasts.at(-1), {
tabId: 17,
message: { type: "glagolify:set-enabled", enabled: true }
});
assert.deepEqual(
await fake.send({ type: "glagolify:document-ready" }, { tab: { id: 17 }, frameId: 4 }),
{ enabled: true }
);
await fake.send({ type: "glagolify:set-tab-enabled", tabId: 17, enabled: true });
assert.equal(controller.tabStates.get(17), true, "repeated absolute enable cannot invert state");
assert.deepEqual(
await fake.send({ type: "glagolify:set-tab-enabled", tabId: 17, enabled: false }),
{ enabled: false, available: true }
);
assert.deepEqual(
await fake.send({ type: "glagolify:document-ready" }, { tab: { id: 17 }, frameId: 5 }),
{ enabled: false }
);
fake.remove(17);
assert.equal(controller.tabStates.has(17), false);
});
test("keeps states isolated between tabs and reports protected tabs", async () => {
const fake = makeBrowser();
createBackgroundController(fake.api);
await fake.send({ type: "glagolify:set-tab-enabled", tabId: 3, enabled: true });
assert.deepEqual(
await fake.send({ type: "glagolify:document-ready" }, { tab: { id: 4 } }),
{ enabled: false }
);
fake.api.tabs.sendMessage = async () => {
throw new Error("No matching recipient");
};
assert.deepEqual(
await fake.send({ type: "glagolify:get-tab-state", tabId: 99 }),
{ enabled: false, available: false }
);
});
+25
View File
@@ -0,0 +1,25 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { characterMap, transliterate } = require("../transliterate.js");
test("maps every modern Russian uppercase and lowercase letter", () => {
const russian = "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя";
const expected = "ⰀⰁⰂⰃⰄⰅⰦⰆⰈⰉⰋⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰜⰝⰞⰛⰟⰠⰡⰅⰣⰤⰰⰱⰲⰳⰴⰵⱖⰶⰸⰹⰻⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱌⱍⱎⱋⱏⱐⱑⰵⱓⱔ";
assert.equal(characterMap.size, 66);
assert.equal(transliterate(russian), expected);
});
test("preserves case, punctuation, spacing, digits, and non-Russian text", () => {
assert.equal(
transliterate("Привет, мир! Ёж № 5 — JavaScript."),
"Ⱂⱃⰹⰲⰵⱅ, ⰿⰹⱃ! Ⱖⰶ № 5 — JavaScript."
);
});
test("leaves already Glagolitic and unmapped Cyrillic letters unchanged", () => {
const text = "Ⰳⰾⰰⰳⱁⰾⰹⱌⰰ · ІЇЄҐЎЅ · 123";
assert.equal(transliterate(text), text);
});
+57
View File
@@ -0,0 +1,57 @@
(function (root) {
"use strict";
const pairs = [
["А", "Ⰰ"], ["а", "ⰰ"],
["Б", "Ⰱ"], ["б", "ⰱ"],
["В", "Ⰲ"], ["в", "ⰲ"],
["Г", "Ⰳ"], ["г", "ⰳ"],
["Д", "Ⰴ"], ["д", "ⰴ"],
["Е", "Ⰵ"], ["е", "ⰵ"],
["Ё", "Ⱖ"], ["ё", "ⱖ"],
["Ж", "Ⰶ"], ["ж", "ⰶ"],
["З", "Ⰸ"], ["з", "ⰸ"],
["И", "Ⰹ"], ["и", "ⰹ"],
["Й", "Ⰻ"], ["й", "ⰻ"],
["К", "Ⰽ"], ["к", "ⰽ"],
["Л", "Ⰾ"], ["л", "ⰾ"],
["М", "Ⰿ"], ["м", "ⰿ"],
["Н", "Ⱀ"], ["н", "ⱀ"],
["О", "Ⱁ"], ["о", "ⱁ"],
["П", "Ⱂ"], ["п", "ⱂ"],
["Р", "Ⱃ"], ["р", "ⱃ"],
["С", "Ⱄ"], ["с", "ⱄ"],
["Т", "Ⱅ"], ["т", "ⱅ"],
["У", "Ⱆ"], ["у", "ⱆ"],
["Ф", "Ⱇ"], ["ф", "ⱇ"],
["Х", "Ⱈ"], ["х", "ⱈ"],
["Ц", "Ⱌ"], ["ц", "ⱌ"],
["Ч", "Ⱍ"], ["ч", "ⱍ"],
["Ш", "Ⱎ"], ["ш", "ⱎ"],
["Щ", "Ⱋ"], ["щ", "ⱋ"],
["Ъ", "Ⱏ"], ["ъ", "ⱏ"],
["Ы", "Ⱐ"], ["ы", "ⱐ"],
["Ь", "Ⱑ"], ["ь", "ⱑ"],
["Э", "Ⰵ"], ["э", "ⰵ"],
["Ю", "Ⱓ"], ["ю", "ⱓ"],
["Я", "Ⱔ"], ["я", "ⱔ"]
];
const characterMap = new Map(pairs);
const russianPattern = /[А-Яа-яЁё]/g;
function transliterate(text) {
return text.replace(russianPattern, (character) => characterMap.get(character));
}
const api = Object.freeze({
characterMap,
transliterate
});
root.Glagolify = api;
if (typeof module === "object" && module.exports) {
module.exports = api;
}
})(typeof globalThis === "object" ? globalThis : this);