From 89cdf6723436c4a09387367b685eb36082986149 Mon Sep 17 00:00:00 2001 From: re-fort Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] Enable to mask texts (#73) * chore: reorder options * feat: enable to mask texts * feat: add the default mask function * refactor: rename options to identify the difference between mask text and mask input * test: add tests about masking --- src/index.ts | 2 + src/snapshot.ts | 82 +++++++++++++++++++++++++- src/types.ts | 2 + test/__snapshots__/integration.ts.snap | 15 +++++ test/html/mask-text.html | 17 ++++++ typings/index.d.ts | 4 +- typings/snapshot.d.ts | 11 +++- typings/types.d.ts | 1 + 8 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 test/html/mask-text.html diff --git a/src/index.ts b/src/index.ts index 9c1fd389..8335768f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import snapshot, { transformAttribute, visitSnapshot, cleanupSnapshot, + needMaskingText, IGNORED_NODE, } from './snapshot'; import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; @@ -18,5 +19,6 @@ export { transformAttribute, visitSnapshot, cleanupSnapshot, + needMaskingText, IGNORED_NODE, }; diff --git a/src/snapshot.ts b/src/snapshot.ts index 210386aa..94a2b904 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -7,6 +7,7 @@ import { idNodeMap, MaskInputOptions, SlimDOMOptions, + MaskTextFn, } from './types'; import { isElement, isShadowRoot } from './utils'; @@ -206,6 +207,40 @@ export function _isBlockedElement( return false; } +export function needMaskingText( + node: Node | null, + maskTextClass: string | RegExp, + maskTextSelector: string | null, +): boolean { + if (!node) { + return false; + } + if (node.nodeType === node.ELEMENT_NODE) { + if (typeof maskTextClass === 'string') { + if ((node as HTMLElement).classList.contains(maskTextClass)) { + return true; + } + } else { + (node as HTMLElement).classList.forEach((className) => { + if (maskTextClass.test(className)) { + return true; + } + }); + } + if (maskTextSelector) { + if ((node as HTMLElement).matches(maskTextSelector)) { + return true; + } + } + return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); + } + if (node.nodeType === node.TEXT_NODE) { + // check parent node since text node do not have class name + return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); + } + return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); +} + // https://stackoverflow.com/a/36155560 function onceIframeLoaded( iframeEl: HTMLIFrameElement, @@ -259,8 +294,11 @@ function serializeNode( doc: Document; blockClass: string | RegExp; blockSelector: string | null; + maskTextClass: string | RegExp; + maskTextSelector: string | null; inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; + maskTextFn: MaskTextFn | undefined; recordCanvas: boolean; }, ): serializedNode | false { @@ -268,8 +306,11 @@ function serializeNode( doc, blockClass, blockSelector, + maskTextClass, + maskTextSelector, inlineStylesheet, maskInputOptions = {}, + maskTextFn, recordCanvas, } = options; // Only record root id when document object is not the base document @@ -412,12 +453,23 @@ function serializeNode( n.parentNode && (n.parentNode as HTMLElement).tagName; let textContent = (n as Text).textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; + const isScript = parentTagName === 'SCRIPT' ? true : undefined; if (isStyle && textContent) { textContent = absoluteToStylesheet(textContent, getHref()); } - if (parentTagName === 'SCRIPT') { + if (isScript) { textContent = 'SCRIPT_PLACEHOLDER'; } + if ( + !isStyle && + !isScript && + needMaskingText(n, maskTextClass, maskTextSelector) && + textContent + ) { + textContent = maskTextFn + ? maskTextFn(textContent) + : textContent.replace(/[\S]/g, '*'); + } return { type: NodeType.Text, textContent: textContent || '', @@ -540,9 +592,12 @@ export function serializeNodeWithId( map: idNodeMap; blockClass: string | RegExp; blockSelector: string | null; + maskTextClass: string | RegExp; + maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; maskInputOptions?: MaskInputOptions; + maskTextFn: MaskTextFn | undefined; slimDOMOptions: SlimDOMOptions; recordCanvas?: boolean; preserveWhiteSpace?: boolean; @@ -556,9 +611,12 @@ export function serializeNodeWithId( map, blockClass, blockSelector, + maskTextClass, + maskTextSelector, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, + maskTextFn, slimDOMOptions, recordCanvas = false, onSerialize, @@ -570,8 +628,11 @@ export function serializeNodeWithId( doc, blockClass, blockSelector, + maskTextClass, + maskTextSelector, inlineStylesheet, maskInputOptions, + maskTextFn, recordCanvas, }); if (!_serializedNode) { @@ -628,9 +689,12 @@ export function serializeNodeWithId( map, blockClass, blockSelector, + maskTextClass, + maskTextSelector, skipChild, inlineStylesheet, maskInputOptions, + maskTextFn, slimDOMOptions, recordCanvas, preserveWhiteSpace, @@ -675,9 +739,12 @@ export function serializeNodeWithId( map, blockClass, blockSelector, + maskTextClass, + maskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, + maskTextFn, slimDOMOptions, recordCanvas, preserveWhiteSpace, @@ -702,11 +769,14 @@ function snapshot( n: Document, options?: { blockClass?: string | RegExp; + blockSelector?: string | null; + maskTextClass?: string | RegExp; + maskTextSelector?: string | null; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; + maskTextFn?: MaskTextFn; slimDOM?: boolean | SlimDOMOptions; recordCanvas?: boolean; - blockSelector?: string | null; preserveWhiteSpace?: boolean; onSerialize?: (n: INode) => unknown; onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; @@ -715,10 +785,13 @@ function snapshot( ): [serializedNodeWithId | null, idNodeMap] { const { blockClass = 'rr-block', + blockSelector = null, + maskTextClass = 'rr-mask', + maskTextSelector = null, inlineStylesheet = true, recordCanvas = false, - blockSelector = null, maskAllInputs = false, + maskTextFn, slimDOM = false, preserveWhiteSpace, onSerialize, @@ -772,9 +845,12 @@ function snapshot( map: idNodeMap, blockClass, blockSelector, + maskTextClass, + maskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, + maskTextFn, slimDOMOptions, recordCanvas, preserveWhiteSpace, diff --git a/src/types.ts b/src/types.ts index 0a9eb630..b38a4778 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,3 +105,5 @@ export type SlimDOMOptions = Partial<{ headMetaAuthorship: boolean; headMetaVerification: boolean; }>; + +export type MaskTextFn = (text: string) => string; diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index e6577820..72b317f1 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -202,6 +202,21 @@ exports[`[html file]: invalid-tagname.html 1`] = ` " `; +exports[`[html file]: mask-text.html 1`] = ` +" + + + + Document + +

**** *

+
+ **** * +
+
**** *
+ " +`; + exports[`[html file]: picture.html 1`] = ` " diff --git a/test/html/mask-text.html b/test/html/mask-text.html new file mode 100644 index 00000000..fe177a61 --- /dev/null +++ b/test/html/mask-text.html @@ -0,0 +1,17 @@ + + + + + + + Document + + + +

mask 1

+
+ mask 2 +
+
mask 3
+ + diff --git a/typings/index.d.ts b/typings/index.d.ts index c9f12359..000750d0 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,5 +1,5 @@ -import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, IGNORED_NODE } from './snapshot'; +import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot'; import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; export * from './types'; export * from './utils'; -export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, IGNORED_NODE, }; +export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, }; diff --git a/typings/snapshot.d.ts b/typings/snapshot.d.ts index 8992fff8..91bec1a9 100644 --- a/typings/snapshot.d.ts +++ b/typings/snapshot.d.ts @@ -1,17 +1,21 @@ -import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOptions } from './types'; +import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, MaskTextFn, SlimDOMOptions } from './types'; export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; export declare function absoluteToDoc(doc: Document, attributeValue: string): string; export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string): string; export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null): boolean; +export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null): boolean; export declare function serializeNodeWithId(n: Node | INode, options: { doc: Document; map: idNodeMap; blockClass: string | RegExp; blockSelector: string | null; + maskTextClass: string | RegExp; + maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; maskInputOptions?: MaskInputOptions; + maskTextFn?: MaskTextFn; slimDOMOptions: SlimDOMOptions; recordCanvas?: boolean; preserveWhiteSpace?: boolean; @@ -21,11 +25,14 @@ export declare function serializeNodeWithId(n: Node | INode, options: { }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { blockClass?: string | RegExp; + blockSelector?: string | null; + maskTextClass?: string | RegExp; + maskTextSelector?: string | null; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; + maskTextFn?: MaskTextFn; slimDOM?: boolean | SlimDOMOptions; recordCanvas?: boolean; - blockSelector?: string | null; preserveWhiteSpace?: boolean; onSerialize?: (n: INode) => unknown; onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; diff --git a/typings/types.d.ts b/typings/types.d.ts index 1b8740b8..f9394a39 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -86,3 +86,4 @@ export declare type SlimDOMOptions = Partial<{ headMetaAuthorship: boolean; headMetaVerification: boolean; }>; +export declare type MaskTextFn = (text: string) => string;