Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2021-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import css from 'css';
20import fs from 'fs';
21import path from 'path';
22import {fileURLToPath} from 'url';
23
24const dirname = path.dirname(fileURLToPath(import.meta.url));
25
26/**
27 * @returns {{cssFilePath: string, overridesCssFilePath: string, outputPath: string}[]}
28 */
29export function getTargets() {
30 return [
31 {
32 cssFilePath: path.join(dirname, '..', 'ext/css/structured-content.css'),
33 overridesCssFilePath: path.join(dirname, 'data/structured-content-overrides.css'),
34 outputPath: path.join(dirname, '..', 'ext/data/structured-content-style.json'),
35 },
36 {
37 cssFilePath: path.join(dirname, '..', 'ext/css/display-pronunciation.css'),
38 overridesCssFilePath: path.join(dirname, 'data/display-pronunciation-overrides.css'),
39 outputPath: path.join(dirname, '..', 'ext/data/pronunciation-style.json'),
40 },
41 ];
42}
43
44/**
45 * @param {import('css-style-applier').RawStyleData} rules
46 * @param {string[]} selectors
47 * @returns {number}
48 */
49function indexOfRule(rules, selectors) {
50 const jj = selectors.length;
51 for (let i = 0, ii = rules.length; i < ii; ++i) {
52 const ruleSelectors = rules[i].selectors;
53 if (ruleSelectors.length !== jj) { continue; }
54 let okay = true;
55 for (let j = 0; j < jj; ++j) {
56 if (selectors[j] !== ruleSelectors[j]) {
57 okay = false;
58 break;
59 }
60 }
61 if (okay) { return i; }
62 }
63 return -1;
64}
65
66/**
67 * @param {import('css-style-applier').RawStyleDataStyleArray} styles
68 * @param {string} property
69 * @param {Map<string, number>} removedProperties
70 * @returns {number}
71 */
72function removeProperty(styles, property, removedProperties) {
73 let removeCount = removedProperties.get(property);
74 if (typeof removeCount !== 'undefined') { return removeCount; }
75 removeCount = 0;
76 for (let i = 0, ii = styles.length; i < ii; ++i) {
77 const key = styles[i][0];
78 if (key !== property) { continue; }
79 styles.splice(i, 1);
80 --i;
81 --ii;
82 ++removeCount;
83 }
84 removedProperties.set(property, removeCount);
85 return removeCount;
86}
87
88/**
89 * Manually formats JSON for easier CSS parseability.
90 * @param {import('css-style-applier').RawStyleData} rules CSS ruleset.
91 * @returns {string}
92 */
93export function formatRulesJson(rules) {
94 // This is similar to the following code, but formatted a but more succinctly:
95 // return JSON.stringify(rules, null, 4);
96 const indent1 = ' ';
97 const indent2 = indent1.repeat(2);
98 const indent3 = indent1.repeat(3);
99 let result = '';
100 result += '[';
101 let ruleIndex = 0;
102 for (const {selectors, styles} of rules) {
103 if (ruleIndex > 0) { result += ','; }
104 result += `\n${indent1}{\n${indent2}"selectors": `;
105 result += (
106 selectors.length === 1 ?
107 `[${JSON.stringify(selectors[0], null, 4)}]` :
108 JSON.stringify(selectors, null, 4).replace(/\n/g, '\n' + indent2)
109 );
110 result += `,\n${indent2}"styles": [`;
111 let styleIndex = 0;
112 for (const [key, value] of styles) {
113 if (styleIndex > 0) { result += ','; }
114 result += `\n${indent3}[${JSON.stringify(key)}, ${JSON.stringify(value)}]`;
115 ++styleIndex;
116 }
117 if (styleIndex > 0) { result += `\n${indent2}`; }
118 result += `]\n${indent1}}`;
119 ++ruleIndex;
120 }
121 if (ruleIndex > 0) { result += '\n'; }
122 result += ']';
123 result += '\n';
124 return result;
125}
126
127/**
128 * Generates a CSS ruleset.
129 * @param {string} cssFilePath
130 * @param {string} overridesCssFilePath
131 * @returns {import('css-style-applier').RawStyleData}
132 * @throws {Error}
133 */
134export function generateRules(cssFilePath, overridesCssFilePath) {
135 const cssFileContent = fs.readFileSync(cssFilePath, {encoding: 'utf8'});
136 const overridesCssFileContent = fs.readFileSync(overridesCssFilePath, {encoding: 'utf8'});
137 const defaultStylesheet = /** @type {css.StyleRules} */ (css.parse(cssFileContent, {}).stylesheet);
138 const overridesStylesheet = /** @type {css.StyleRules} */ (css.parse(overridesCssFileContent, {}).stylesheet);
139
140 const removePropertyPattern = /^remove-property\s+([\w\W]+)$/;
141 const removeRulePattern = /^remove-rule$/;
142 const propertySeparator = /\s+/;
143
144 /** @type {import('css-style-applier').RawStyleData} */
145 const rules = [];
146
147 for (const rule of defaultStylesheet.rules) {
148 if (rule.type !== 'rule') { continue; }
149 const {selectors, declarations} = /** @type {css.Rule} */ (rule);
150 if (typeof selectors === 'undefined') { continue; }
151 /** @type {import('css-style-applier').RawStyleDataStyleArray} */
152 const styles = [];
153 if (typeof declarations !== 'undefined') {
154 for (const declaration of declarations) {
155 if (declaration.type !== 'declaration') {
156 console.log(declaration);
157 continue;
158 }
159 const {property, value} = /** @type {css.Declaration} */ (declaration);
160 if (typeof property !== 'string' || typeof value !== 'string') { continue; }
161 styles.push([property, value]);
162 }
163 }
164 if (styles.length > 0) {
165 rules.push({selectors, styles});
166 }
167 }
168
169 for (const rule of overridesStylesheet.rules) {
170 if (rule.type !== 'rule') { continue; }
171 const {selectors, declarations} = /** @type {css.Rule} */ (rule);
172 if (typeof selectors === 'undefined' || typeof declarations === 'undefined') { continue; }
173 /** @type {Map<string, number>} */
174 const removedProperties = new Map();
175 for (const declaration of declarations) {
176 switch (declaration.type) {
177 case 'declaration':
178 {
179 const index = indexOfRule(rules, selectors);
180 let entry;
181 if (index >= 0) {
182 entry = rules[index];
183 } else {
184 entry = {selectors, styles: []};
185 rules.push(entry);
186 }
187 const {property, value} = /** @type {css.Declaration} */ (declaration);
188 if (typeof property === 'string' && typeof value === 'string') {
189 removeProperty(entry.styles, property, removedProperties);
190 entry.styles.push([property, value]);
191 }
192 }
193 break;
194 case 'comment':
195 {
196 const index = indexOfRule(rules, selectors);
197 if (index < 0) { throw new Error('Could not find rule with matching selectors'); }
198 const comment = (/** @type {css.Comment} */ (declaration).comment || '').trim();
199 let m;
200 if ((m = removePropertyPattern.exec(comment)) !== null) {
201 for (const property of m[1].split(propertySeparator)) {
202 const removeCount = removeProperty(rules[index].styles, property, removedProperties);
203 if (removeCount === 0) { throw new Error(`Property removal is unnecessary; ${property} does not exist`); }
204 }
205 } else if (removeRulePattern.test(comment)) {
206 rules.splice(index, 1);
207 }
208 }
209 break;
210 }
211 }
212 }
213
214 // Remove empty
215 for (let i = 0, ii = rules.length; i < ii; ++i) {
216 if (rules[i].styles.length > 0) { continue; }
217 rules.splice(i, 1);
218 --i;
219 --ii;
220 }
221
222 return rules;
223}