Skip to content

Commit 2b08750

Browse files
committed
Update to 3.2.0
1 parent 23ab2f1 commit 2b08750

15 files changed

Lines changed: 600 additions & 131 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# WProofreader Plugin for WordPress Changelog
22

3+
## 3.2.0 - 2026-06-12
4+
5+
* Fixed: post content corruption on save in the block editor. The content cleanup filter processed slashed post data, which added escaped quotes to attribute values (href, id, class) on every save, and it also ran on posts that merely contained "wsc-" in URLs rather than actual proofreading artifacts.
6+
* Reworked content cleanup to splice artifact markup out of the original byte stream (WP_HTML_Tag_Processor) instead of re-serializing the whole document; content outside removed tags is never modified. Removed the lossy DOMDocument and regex fallbacks.
7+
* Content cleanup now also covers autosaves and revisions, so restored autosaves no longer contain proofreading markup.
8+
* Fixed the editor re-init guard to use the data-wpr-instance attribute the SDK actually sets, preventing duplicate instances; added a temporary init marker with timeout recovery so failed initializations can retry safely.
9+
* Block editor integration now retries initialization while the editor is still rendering and picks up blocks added after load.
10+
* Corrections in the classic editor now mark TinyMCE as dirty so they are reliably saved.
11+
* The proofreading bundle is loaded once per document (previously it could be requested twice in iframed block editors).
12+
* Settings page: clearer message when the session nonce has expired; settings fields are only registered on the settings screen and when saving.
13+
* Version migration no longer writes options during REST/cron/front-end requests; uninstall now cleans options on all sites in multisite.
14+
315
## 3.1.0 - 2026-04-24
416

517
* Refactored plugin internals into focused classes while preserving the public WProofreader class, option names, filters, and AJAX action.

assets/classic-environment.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
'use strict';
33

44
const EDITOR_ID = 'content';
5-
const INSTANCE_ATTRIBUTE = 'data-wsc-instance';
5+
// Own marker (set below) plus the attribute the SDK sets on its container.
6+
const OWN_INSTANCE_ATTRIBUTE = 'data-wsc-init';
7+
const SDK_INSTANCE_ATTRIBUTE = 'data-wpr-instance';
68
const INIT_DELAY = 100;
9+
const INIT_TIMEOUT = 5000;
710
const MAX_ATTEMPTS = 50;
811

912
let attempts = 0;
@@ -24,9 +27,15 @@
2427
}
2528

2629
function hasInstance(iframe) {
30+
// The SDK marks the container (the iframe element) with data-wpr-instance;
31+
// the temporary own marker prevents duplicate concurrent initialization.
32+
if (iframe.hasAttribute(OWN_INSTANCE_ATTRIBUTE) || iframe.hasAttribute(SDK_INSTANCE_ATTRIBUTE)) {
33+
return true;
34+
}
35+
2736
const body = iframe.contentDocument && iframe.contentDocument.body;
2837

29-
return Boolean(body && body.hasAttribute(INSTANCE_ATTRIBUTE));
38+
return Boolean(body && (body.hasAttribute(OWN_INSTANCE_ATTRIBUTE) || body.hasAttribute(SDK_INSTANCE_ATTRIBUTE)));
3039
}
3140

3241
function initializeClassicEditor() {
@@ -47,9 +56,27 @@
4756
return;
4857
}
4958

50-
window.WEBSPELLCHECKER.init(Object.assign({}, window.WEBSPELLCHECKER_CONFIG, {
51-
container: iframe,
52-
}));
59+
iframe.setAttribute(OWN_INSTANCE_ATTRIBUTE, '1');
60+
61+
// The SDK merges the global WEBSPELLCHECKER_CONFIG itself; pass only the container
62+
// (same convention as gutenberg-environment.js).
63+
try {
64+
window.WEBSPELLCHECKER.init({
65+
container: iframe,
66+
});
67+
68+
window.setTimeout(() => {
69+
const body = iframe.contentDocument && iframe.contentDocument.body;
70+
const sdkInstanceCreated = iframe.hasAttribute(SDK_INSTANCE_ATTRIBUTE) ||
71+
Boolean(body && body.hasAttribute(SDK_INSTANCE_ATTRIBUTE));
72+
73+
if (!sdkInstanceCreated) {
74+
iframe.removeAttribute(OWN_INSTANCE_ATTRIBUTE);
75+
}
76+
}, INIT_TIMEOUT);
77+
} catch (e) {
78+
iframe.removeAttribute(OWN_INSTANCE_ATTRIBUTE);
79+
}
5380
}
5481

5582
if (document.readyState === 'loading') {

assets/environmentChecker.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,38 @@ window.addEventListener("load", (event) => {
33
var bundlePath = serviceConfig.bundleUrl || 'https://svc.webspellchecker.net/spellcheck31/wscbundle/wscbundle.js';
44

55
function loadScript(doc, src) {
6+
// One bundle copy per document. This script runs both in the top frame
7+
// and inside the editor-canvas iframe, and the top copy also injects
8+
// into the iframe, so without the flag the bundle could load twice.
9+
var win = doc.defaultView;
10+
if (!win || win.__wscBundleRequested) {
11+
return;
12+
}
13+
win.__wscBundleRequested = true;
14+
615
const script = doc.createElement('script');
716
const appendTo = doc.head || doc.documentElement;
817

918
script.type = 'text/javascript';
10-
script.charset = 'UTF-8';
1119
script.async = false;
1220
script.src = src;
1321

1422
appendTo.appendChild(script);
1523
}
1624

25+
if (!window.WEBSPELLCHECKER_CONFIG) {
26+
return;
27+
}
28+
1729
window.gutenbergIframe = document.querySelector('[name=editor-canvas]');
18-
window.WEBSPELLCHECKER_CONFIG.globalBadge = true;
1930

2031
if (window.gutenbergIframe) {
21-
window.WEBSPELLCHECKER_CONFIG.globalBadge = false;
32+
// The iframe document hosts the editable content and shows the badge;
33+
// the top frame keeps a badge-less SDK for classic metabox fields.
34+
const iframeConfig = window.gutenbergIframe.contentWindow.WEBSPELLCHECKER_CONFIG;
35+
if (iframeConfig && window.WEBSPELLCHECKER_CONFIG.globalBadge) {
36+
window.WEBSPELLCHECKER_CONFIG.globalBadge = false;
37+
}
2238
loadScript(window.gutenbergIframe.contentWindow.document, bundlePath);
2339
window.gutenbergIframe = null;
2440
}

assets/gutenberg-environment.js

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,21 @@
88
TABLE_CELL: 'wp-block-table__cell-content'
99
};
1010

11-
const INSTANCE_ATTRIBUTE = 'data-wsc-instance';
11+
// Own marker set right before init: keeps re-init idempotent even when the
12+
// SDK instance fails to start (e.g. service unreachable).
13+
const OWN_INSTANCE_ATTRIBUTE = 'data-wsc-init';
14+
// Attribute the SDK sets on a container once an instance is created.
15+
const SDK_INSTANCE_ATTRIBUTE = 'data-wpr-instance';
16+
1217
const INIT_DELAY = 100;
18+
const INIT_TIMEOUT = 5000;
19+
const MAX_ATTEMPTS = 50;
20+
const OBSERVER_DEBOUNCE = 250;
21+
22+
let attempts = 0;
23+
let observer = null;
24+
let observerTimer = null;
25+
const pendingRoots = new Set();
1326

1427
const isGutenbergActive = () => {
1528
return document.body.classList.contains(SELECTORS.GUTENBERG_PAGE) ||
@@ -19,8 +32,10 @@
1932
function isContentEditable(element) {
2033
return element.isContentEditable;
2134
}
35+
2236
function isInstanceCreated(element) {
23-
return element.hasAttribute(INSTANCE_ATTRIBUTE);
37+
return element.hasAttribute(OWN_INSTANCE_ATTRIBUTE) ||
38+
element.hasAttribute(SDK_INSTANCE_ATTRIBUTE);
2439
}
2540

2641
function isGutenbergTableCell(element) {
@@ -44,24 +59,103 @@
4459
};
4560

4661
const createInstance = (element) => {
47-
WEBSPELLCHECKER.init({
48-
container: element,
49-
});
62+
element.setAttribute(OWN_INSTANCE_ATTRIBUTE, '1');
63+
64+
try {
65+
WEBSPELLCHECKER.init({
66+
container: element,
67+
});
68+
69+
// The SDK reports successful creation with data-wpr-instance. Do
70+
// not let our pending marker suppress retries forever otherwise.
71+
window.setTimeout(() => {
72+
if (!element.hasAttribute(SDK_INSTANCE_ATTRIBUTE)) {
73+
element.removeAttribute(OWN_INSTANCE_ATTRIBUTE);
74+
}
75+
}, INIT_TIMEOUT);
76+
} catch (e) {
77+
// Allow a later retry if init threw synchronously.
78+
element.removeAttribute(OWN_INSTANCE_ATTRIBUTE);
79+
}
5080
};
5181

52-
const initializeElements = (selector) => {
53-
document.querySelectorAll(selector).forEach((element) => {
82+
const initializeElements = (selector, root = document) => {
83+
let found = 0;
84+
const elements = [];
85+
86+
if (root.nodeType === Node.ELEMENT_NODE && root.matches(selector)) {
87+
elements.push(root);
88+
}
89+
90+
root.querySelectorAll(selector).forEach((element) => {
91+
elements.push(element);
92+
});
93+
94+
elements.forEach((element) => {
95+
found++;
96+
5497
if (shouldIgnoreElement(element)) {
5598
return;
5699
}
57100

58101
createInstance(element);
59102
});
103+
104+
return found;
105+
};
106+
107+
const initializeAll = (root = document) => {
108+
return initializeElements(SELECTORS.RICH_TEXT, root) +
109+
initializeElements(SELECTORS.MCE_CONTENT_BODY, root);
110+
};
111+
112+
// Blocks added after load (new paragraphs, async patterns) get instances too.
113+
const observeLateBlocks = () => {
114+
if (observer || typeof MutationObserver === 'undefined') {
115+
return;
116+
}
117+
118+
observer = new MutationObserver((mutations) => {
119+
mutations.forEach((mutation) => {
120+
mutation.addedNodes.forEach((node) => {
121+
if (node.nodeType === Node.ELEMENT_NODE) {
122+
pendingRoots.add(node);
123+
}
124+
});
125+
});
126+
127+
if (pendingRoots.size === 0) {
128+
return;
129+
}
130+
131+
if (observerTimer) {
132+
window.clearTimeout(observerTimer);
133+
}
134+
135+
observerTimer = window.setTimeout(() => {
136+
pendingRoots.forEach((root) => {
137+
if (root.isConnected) {
138+
initializeAll(root);
139+
}
140+
});
141+
pendingRoots.clear();
142+
}, OBSERVER_DEBOUNCE);
143+
});
144+
145+
observer.observe(document.body, { childList: true, subtree: true });
60146
};
61147

62148
const handleGutenbergReady = () => {
63-
initializeElements(SELECTORS.RICH_TEXT);
64-
initializeElements(SELECTORS.MCE_CONTENT_BODY);
149+
attempts++;
150+
151+
const found = initializeAll();
152+
153+
if (found === 0 && attempts < MAX_ATTEMPTS) {
154+
window.setTimeout(handleGutenbergReady, INIT_DELAY);
155+
return;
156+
}
157+
158+
observeLateBlocks();
65159
};
66160

67161
const handleGutenbergReadyWithDelay = () => {
@@ -75,4 +169,4 @@
75169

76170
handleGutenbergReadyWithDelay();
77171
};
78-
})();
172+
})();

assets/instance.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ jQuery(function ($) {
5555
})
5656
.fail((jqXHR, textStatus) => {
5757
let msg = 'Failed to load language list.';
58-
if (jqXHR && jqXHR.responseJSON && jqXHR.responseJSON.data && jqXHR.responseJSON.data.message) {
58+
if (jqXHR && jqXHR.status === 403) {
59+
msg = 'Your session has expired. Reload the page and try again.';
60+
} else if (jqXHR && jqXHR.responseJSON && jqXHR.responseJSON.data && jqXHR.responseJSON.data.message) {
5961
msg = jqXHR.responseJSON.data.message;
6062
} else if (textStatus) {
6163
msg += ` (${textStatus})`;

assets/proofreaderConfig.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@
1414
const serviceConfig = window.WSCServiceConfig || {};
1515
const proofreaderConfig = window.WSCProofreaderConfig || {};
1616

17+
// TinyMCE does not treat a bare DOM input event as an edit; without this the
18+
// classic editor may consider the document unchanged and skip saving a correction.
19+
function markTinyMceDirty(element) {
20+
try {
21+
const win = element.ownerDocument.defaultView;
22+
const tinymce = win.tinymce || (win.parent && win.parent.tinymce);
23+
if (!tinymce || !tinymce.editors) {
24+
return;
25+
}
26+
27+
Array.prototype.forEach.call(tinymce.editors, function (editor) {
28+
const body = editor.getBody && editor.getBody();
29+
if (body && (body === element || body.contains(element))) {
30+
editor.setDirty(true);
31+
}
32+
});
33+
} catch (e) {
34+
// Cross-frame access or missing TinyMCE — nothing to mark.
35+
}
36+
}
37+
1738
const isBadgeEnabled = asBoolean(proofreaderConfig.enableBadgeButton, true);
1839
const badgeActions = isBadgeEnabled
1940
? ['addWord', 'ignoreAll', 'settings', 'toggle', 'proofreadDialog']
@@ -37,6 +58,9 @@
3758
'#ping_sites',
3859
'#permalink_structure',
3960
'.inline-edit-password-input',
61+
'#_sale_price',
62+
'#_regular_price',
63+
'#_weight',
4064
];
4165

4266
window.WEBSPELLCHECKER_CONFIG = {
@@ -66,14 +90,16 @@
6690
this.subscribe('replaceProblem', function () {
6791
try {
6892
const element = instance.getContainerNode();
93+
// Gutenberg's RichText syncs block state from the DOM on input.
6994
element.dispatchEvent(new Event('input', { bubbles: true }));
95+
markTinyMceDirty(element);
7096
} catch (e) {
7197
// Container may have been detached by the host editor — safe to ignore.
7298
}
7399
});
74100
},
75101
onBeforeAutoSearchInstanceCreate: function (activeElement) {
76-
const id = activeElement.element.id;
102+
const id = activeElement && activeElement.element ? activeElement.element.id : '';
77103
return !(id && id.indexOf('url-input-control') === 0);
78104
},
79105
};

includes/class-wproofreader-ajax.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,39 @@ public static function get_proofreader_info() {
3939
);
4040
}
4141

42+
/** Upper bound for the getInfo JSON payload (a language list is a few KB). */
43+
const MAX_PAYLOAD_BYTES = 65536;
44+
4245
/**
46+
* The SDK getInfo result arrives either as a JSON string or, when jQuery
47+
* serializes the result object into form fields, as a nested array.
48+
*
4349
* @param mixed $payload Raw POST payload.
4450
* @return array|null
4551
*/
4652
private static function parse_payload( $payload ) {
4753
if ( is_string( $payload ) ) {
4854
$payload = wp_unslash( $payload );
49-
if ( '' === $payload ) {
55+
if ( '' === $payload || strlen( $payload ) > self::MAX_PAYLOAD_BYTES ) {
5056
return null;
5157
}
5258

5359
$decoded = json_decode( $payload, true );
60+
5461
return ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) ? $decoded : null;
5562
}
5663

5764
if ( is_array( $payload ) ) {
58-
return wp_unslash( $payload );
65+
$payload = wp_unslash( $payload );
66+
67+
// jQuery may submit getInfoResult as nested form fields. Measure the
68+
// normalized representation so this path cannot bypass the size cap.
69+
$encoded = wp_json_encode( $payload );
70+
if ( false === $encoded || strlen( $encoded ) > self::MAX_PAYLOAD_BYTES ) {
71+
return null;
72+
}
73+
74+
return $payload;
5975
}
6076

6177
return null;

0 commit comments

Comments
 (0)