initial
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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
@@ -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.
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user