@event4u/agent-config 16.1.0 → 16.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +237 -248
- package/dist/agent-src/commands/roadmap/process-full.md +1 -1
- package/dist/agent-src/commands/work.md +4 -0
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
- package/dist/agent-src/rules/ui-audit-gate.md +1 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
- package/dist/agent-src/skills/design-review/SKILL.md +30 -4
- package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +16 -1
- package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
- package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
- package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +23 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +30 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
- package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
- package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
- package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
- package/dist/cli/python/knowledge_ingest.js +1 -1
- package/dist/cli/python/workspace_drive.js +1 -1
- package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-4XFP2Y3E.js} +1 -1
- package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-7XGBX6ZH.js} +471 -387
- package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-FP36SWJD.js} +432 -144
- package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-IGD6ERX6.js} +31 -0
- package/dist/cli-delegate/{chunk-FXYUYU4N.js → chunk-JNMVLWLR.js} +42 -1
- package/dist/cli-delegate/{chunk-QJWAUMJW.js → chunk-YJ72UIAK.js} +730 -15
- package/dist/cli-delegate/cmd_conformance.js +902 -782
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +4 -4
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +10 -10
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +6666 -2581
- package/dist/hooks/dispatch.sha256 +1 -0
- package/dist/install/install.mjs +848 -733
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +2747 -1510
- package/dist/server/schemas/settings.js +23 -0
- package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
- package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
- package/dist/ui/index.html +2 -2
- package/docs/contracts/settings-classes.md +6 -4
- package/package.json +4 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
- package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
- package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
- package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
- package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
- package/src/config/agent-settings.template.yml +29 -0
- package/src/config/ci-local-parity.yml +18 -0
- package/src/config/continuity-surface.json +2 -2
- package/src/config/gate-coverage.yml +234 -3
- package/src/config/gate-violation-baselines.json +7 -3
- package/src/config/hook-latency-budget.json +21 -0
- package/src/config/hook-token-budget.json +2 -2
- package/src/config/metric-registry.yml +114 -0
- package/src/config/placeholder-drift-budget.json +10 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/scripts/_cli/cmd_doctor.ts +5 -19
- package/src/scripts/_cli/doctor_execution.ts +100 -17
- package/src/scripts/_cli/doctor_network_posture.ts +226 -0
- package/src/scripts/_lib/body_portable.ts +102 -0
- package/src/scripts/_lib/cc_transcript.ts +132 -1
- package/src/scripts/_lib/concern_sla_window.ts +200 -0
- package/src/scripts/_lib/forge_protection.ts +114 -0
- package/src/scripts/_lib/forge_reader.ts +558 -0
- package/src/scripts/_lib/host_env_write.ts +111 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
- package/src/scripts/_lib/obligations.ts +119 -8
- package/src/scripts/_lib/probe_inputs.ts +271 -0
- package/src/scripts/_lib/read_surface_scan.ts +322 -0
- package/src/scripts/_lib/release_highlights.ts +2 -1
- package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
- package/src/scripts/_lib/shell_write_shapes.ts +108 -0
- package/src/scripts/_lib/structural_hiding.ts +622 -0
- package/src/scripts/_lib/turn_end_refusals.ts +525 -4
- package/src/scripts/_lib/turn_end_transcript.ts +216 -0
- package/src/scripts/_lib/verification_command.ts +324 -0
- package/src/scripts/_lib/verification_evidence.ts +647 -0
- package/src/scripts/_lib/verify_clause.ts +280 -0
- package/src/scripts/ai_team/team_dispatch.ts +4 -0
- package/src/scripts/before_complete_hook.ts +340 -23
- package/src/scripts/bench_hook_latency.ts +412 -5
- package/src/scripts/cache_realization_report.ts +61 -1
- package/src/scripts/check_argument_hint.ts +287 -0
- package/src/scripts/check_claims.ts +125 -1
- package/src/scripts/check_enforcement_coverage.ts +38 -9
- package/src/scripts/check_enforcement_matrix.ts +245 -3
- package/src/scripts/check_finding_dispositions.ts +22 -3
- package/src/scripts/check_generator_sync.ts +71 -0
- package/src/scripts/check_host_docs_digest.ts +786 -0
- package/src/scripts/check_host_format_column.ts +559 -0
- package/src/scripts/check_input_references.ts +228 -0
- package/src/scripts/check_inputs_parity.ts +235 -0
- package/src/scripts/check_kernel_edit_ratified.ts +47 -5
- package/src/scripts/check_kill_switch_table.ts +294 -0
- package/src/scripts/check_placeholder_drift.ts +221 -0
- package/src/scripts/check_read_surface_coverage.ts +515 -0
- package/src/scripts/check_release_holds.ts +2 -1
- package/src/scripts/check_routing_coverage.ts +378 -11
- package/src/scripts/check_static_parity.ts +17 -0
- package/src/scripts/ci_settle.ts +145 -19
- package/src/scripts/closure_scan.ts +115 -1
- package/src/scripts/compile_hook_manifest.ts +60 -16
- package/src/scripts/external_sources_denylist.json +29 -4
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +42 -5
- package/src/scripts/hooks/block_config_weakening.ts +229 -4
- package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
- package/src/scripts/hooks/block_plumbing_writes.ts +314 -0
- package/src/scripts/hooks/bundle_integrity.ts +291 -0
- package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
- package/src/scripts/hooks/code_graph_context_hook.ts +1 -2
- package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/concern_timings.ts +94 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
- package/src/scripts/hooks/design_pass_hook.ts +100 -9
- package/src/scripts/hooks/design_slop_hook.ts +1 -2
- package/src/scripts/hooks/dispatch_hook.ts +25 -19
- package/src/scripts/hooks/edit_shape_hook.ts +1 -2
- package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
- package/src/scripts/hooks/evidence_independence.ts +1 -2
- package/src/scripts/hooks/exit_codes.ts +128 -0
- package/src/scripts/hooks/host_lowering.json +1 -0
- package/src/scripts/hooks/host_lowering.ts +128 -3
- package/src/scripts/hooks/host_lowering.yaml +262 -46
- package/src/scripts/hooks/host_semantics.ts +30 -2
- package/src/scripts/hooks/injection_budget.ts +9 -2
- package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
- package/src/scripts/hooks/journal_record_hook.ts +1 -2
- package/src/scripts/hooks/memory_recall_hook.ts +1 -2
- package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
- package/src/scripts/hooks/one_question_per_ask_hook.ts +25 -4
- package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
- package/src/scripts/hooks/permission_gate.ts +1 -2
- package/src/scripts/hooks/push_settle_hook.ts +1 -3
- package/src/scripts/hooks/replay_hook.ts +2 -1
- package/src/scripts/hooks/reread_guard_hook.ts +1 -2
- package/src/scripts/hooks/review_baseline_hook.ts +1 -2
- package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
- package/src/scripts/hooks/rule_inject_hook.ts +8 -5
- package/src/scripts/hooks/run_continuation_hook.ts +13 -15
- package/src/scripts/hooks/skill_route_hook.ts +1 -2
- package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
- package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
- package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
- package/src/scripts/hooks/table_fingerprint.ts +49 -0
- package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
- package/src/scripts/hooks/telemetry_usage_hook.ts +1 -2
- package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
- package/src/scripts/hooks/turn_end_gate_hook.ts +337 -324
- package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
- package/src/scripts/install.ts +3 -4
- package/src/scripts/lint_deny_text.ts +419 -0
- package/src/scripts/lint_exit_codes.ts +262 -0
- package/src/scripts/lint_hook_manifest.ts +55 -0
- package/src/scripts/lint_roadmap_blockers.ts +78 -12
- package/src/scripts/lint_skill_descriptions.ts +1 -1
- package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
- package/src/scripts/mcp_server/prompts.ts +142 -1
- package/src/scripts/measure_skill_ranker_baseline.ts +305 -51
- package/src/scripts/measure_turn_end_gate.ts +193 -0
- package/src/scripts/memory_lookup.ts +1 -1
- package/src/scripts/prepack-check.mjs +53 -0
- package/src/scripts/release_publication.ts +13 -1
- package/src/scripts/report_invocation_surface.ts +383 -0
- package/src/scripts/report_obligation_carriers.ts +699 -0
- package/src/scripts/report_obligation_writer_split.ts +236 -0
- package/src/scripts/report_skill_menu_census.ts +208 -0
- package/src/scripts/report_standing_payload_by_host.ts +1 -1
- package/src/scripts/roadmap_verify_share.ts +213 -0
- package/src/scripts/schemas/command.schema.json +44 -0
- package/src/scripts/schemas/review-findings.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +44 -0
- package/src/scripts/self_review_gate.ts +359 -10
- package/src/scripts/semantic_parity_census.ts +314 -0
- package/src/scripts/session_eol_report.ts +54 -1
- package/src/scripts/sweep_dead_scan_roots.ts +1 -1
- package/src/scripts/trigger_eval_grandfather.json +0 -2
- package/src/scripts/trigger_eval_rotation.ts +122 -27
- package/src/scripts/ui_conformance_probe.ts +156 -4
- package/src/scripts/update_prices.ts +85 -3
- package/src/scripts/write_bundle_digest.mjs +74 -0
- package/src/server/schemas/settings.ts +31 -0
- package/src/templates/consumer-settings/ONBOARDING.md +1 -0
- package/dist/ui/assets/index-DH1xc62v.js +0 -29
- package/dist/ui/assets/index-jSfMW8_t.css +0 -1
- /package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-7RQP4UDH.js} +0 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural-hiding pre-pass for fetched markup
|
|
3
|
+
* (road-to-a-sanitize-list-that-is-generated, Phase 3).
|
|
4
|
+
*
|
|
5
|
+
* WHAT THIS IS FOR
|
|
6
|
+
* ----------------
|
|
7
|
+
* `retrieval_sanitize.sanitize_text` is a CODEPOINT floor: it removes bidi
|
|
8
|
+
* controls, zero-width characters, the Unicode Tag block, control-char noise
|
|
9
|
+
* and invisible fillers. Every one of those is a property of a character.
|
|
10
|
+
*
|
|
11
|
+
* Fetched markup carries a channel class that no codepoint predicate can see.
|
|
12
|
+
* Text that is perfectly ordinary at the codepoint layer becomes invisible to a
|
|
13
|
+
* human reader — and stays fully visible to a model reading the source — when
|
|
14
|
+
* the MARKUP hides it: an HTML comment, a `<template>` block, a
|
|
15
|
+
* `style="display:none"` span. This module is that pre-pass. It runs AHEAD of
|
|
16
|
+
* the codepoint floor, on the model-facing copy of fetched markup only, and it
|
|
17
|
+
* never touches a repository file.
|
|
18
|
+
*
|
|
19
|
+
* WHY A TOKENIZER AND NOT A MATCHER
|
|
20
|
+
* ---------------------------------
|
|
21
|
+
* A first version of this module was BUILT and REFUSED by an independent
|
|
22
|
+
* two-provider council review on 2026-09-29. It scanned forward with regexes
|
|
23
|
+
* and `indexOf`, and a scanner cannot answer the only question that matters
|
|
24
|
+
* here — "is this `<` a tag?" — because the answer depends on the state the
|
|
25
|
+
* parser is in. Four shapes the review named, each of which defeats a scanner
|
|
26
|
+
* and each of which has a fixture in `tests/scripts/structural_hiding.test.ts`:
|
|
27
|
+
*
|
|
28
|
+
* 1. a comment containing a same-name opening or closing tag
|
|
29
|
+
* (`<!-- </div> -->` inside a hidden `<div>` closed it early);
|
|
30
|
+
* 2. tag-like text inside `<script>` / `<style>`, which are RAWTEXT elements
|
|
31
|
+
* where `<div>` is character data and not a tag at all;
|
|
32
|
+
* 3. nested same-name hidden and visible elements, which need a real open-
|
|
33
|
+
* element stack rather than a forward re-scan;
|
|
34
|
+
* 4. unterminated markup — an unclosed comment, tag or element at EOF.
|
|
35
|
+
*
|
|
36
|
+
* So this version tokenizes first ({@link tokenize}) and walks the token
|
|
37
|
+
* stream against an explicit open-element stack ({@link strip_structural_hiding}).
|
|
38
|
+
* A stripper that can be confused by the content it is defending against is
|
|
39
|
+
* worse than none, because it reports success.
|
|
40
|
+
*
|
|
41
|
+
* REMOVAL POLICY — REMOVAL IS NEVER SILENT
|
|
42
|
+
* ----------------------------------------
|
|
43
|
+
* The same review named silent irreversible deletion as a defect in its own
|
|
44
|
+
* right: a caller debugging a corrupted result has nothing to go on. Four
|
|
45
|
+
* properties, all asserted by the fixture suite:
|
|
46
|
+
*
|
|
47
|
+
* - **A sentinel is left in place.** Every removal emits
|
|
48
|
+
* `[removed: structural-hiding/<channel-id>]` where the content was.
|
|
49
|
+
* - **The sentinel vocabulary is closed.** `<channel-id>` always comes from
|
|
50
|
+
* {@link STRUCTURAL_HIDING_CHANNELS}; no byte of the input is ever echoed
|
|
51
|
+
* into it, so the marker cannot itself carry a payload.
|
|
52
|
+
* - **Every removal is reported structurally.** {@link StructuralStripResult}
|
|
53
|
+
* carries one {@link StructuralRemoval} per removed span with its offsets
|
|
54
|
+
* into the INPUT, so the caller — which still holds the input — can recover
|
|
55
|
+
* exactly what was dropped. The pre-pass is pure and writes nothing.
|
|
56
|
+
* - **Unterminated markup fails closed, and says so.** An unresolvable tail
|
|
57
|
+
* is dropped rather than emitted, and `truncated` is set.
|
|
58
|
+
*
|
|
59
|
+
* loss_class: recoverable-lossy
|
|
60
|
+
* loss_recovery: StructuralStripResult.removals — one {channel, start, end}
|
|
61
|
+
* record per removed span, whose offsets index the CALLER'S OWN input
|
|
62
|
+
* string. The pre-pass is pure and stores nothing, so the caller already
|
|
63
|
+
* holds the only copy it needs; the locator is the range, not a path.
|
|
64
|
+
*
|
|
65
|
+
* The class is `recoverable-lossy` and not `ephemeral-lossy` because that is
|
|
66
|
+
* the whole point of the removal policy: a reviewer refused the previous
|
|
67
|
+
* version partly for deleting content with no trace, and a caller that cannot
|
|
68
|
+
* reconstruct what went cannot debug a corrupted result. `ephemeral-lossy`
|
|
69
|
+
* would be the honest class for a version that returned only the string.
|
|
70
|
+
*
|
|
71
|
+
* WHAT IT DOES NOT CATCH — see {@link STRUCTURAL_HIDING_GAPS}
|
|
72
|
+
* ----------------------------------------------------------
|
|
73
|
+
* The register below is part of this module's contract rather than a footnote.
|
|
74
|
+
* An inline-style substring matcher cited as "the taxonomy" is how a detector
|
|
75
|
+
* like this becomes a false green, so every surface that claims this coverage
|
|
76
|
+
* cites the register alongside the claim.
|
|
77
|
+
*
|
|
78
|
+
* NO RECALL OR COVERAGE FIGURE IS PUBLISHED FOR THIS LAYER, ANYWHERE.
|
|
79
|
+
* A rate requires a frozen corpus of hiding techniques and none exists in this
|
|
80
|
+
* tree. The covered-channel list and the gap register are the honest statement
|
|
81
|
+
* of what this does; a number would be an invented denominator. The absence is
|
|
82
|
+
* machine-checked — `check_read_surface_coverage` refuses a percentage token in
|
|
83
|
+
* this file, and refuses any surface that claims this coverage without citing
|
|
84
|
+
* the gap register.
|
|
85
|
+
*
|
|
86
|
+
* WHAT THE MISSING CORPUS MEANS, since "none exists" reads two ways. It is not
|
|
87
|
+
* "we could build one and have not got to it". The space of hiding techniques
|
|
88
|
+
* is adversarial and not enumerable: a corpus would fix a denominator that the
|
|
89
|
+
* next technique invalidates, and the number computed against it would outlive
|
|
90
|
+
* its own validity. A corpus is a research artefact, not a module dependency,
|
|
91
|
+
* and none is in scope for this layer. What the module can honestly state is
|
|
92
|
+
* the list of channels it declares, which is enumerable, and what it knows it
|
|
93
|
+
* misses, which is the register — so that is what it states.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One structural-hiding channel this pre-pass removes.
|
|
98
|
+
*
|
|
99
|
+
* Exported so a caller can print exactly what it covers rather than
|
|
100
|
+
* paraphrasing it, and so the fixture suite can assert one case per channel
|
|
101
|
+
* without a second hand-written list to drift from.
|
|
102
|
+
*/
|
|
103
|
+
export interface HidingChannel {
|
|
104
|
+
/** Stable id, used by the fixture suite and by the removal sentinel. */
|
|
105
|
+
readonly id: string;
|
|
106
|
+
/** What the channel looks like in markup. */
|
|
107
|
+
readonly detail: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The channels removed. Anything not here is not removed by this module. */
|
|
111
|
+
export const STRUCTURAL_HIDING_CHANNELS: readonly HidingChannel[] = [
|
|
112
|
+
{ id: 'html-comment', detail: '<!-- … --> — invisible to a reader, plain text to a parser' },
|
|
113
|
+
{ id: 'template-element', detail: '<template>…</template> — inert content the browser never renders' },
|
|
114
|
+
{ id: 'inline-display-none', detail: 'style="display:none"' },
|
|
115
|
+
{ id: 'inline-visibility-hidden', detail: 'style="visibility:hidden"' },
|
|
116
|
+
{ id: 'inline-opacity-zero', detail: 'style="opacity:0"' },
|
|
117
|
+
{ id: 'inline-font-size-zero', detail: 'style="font-size:0"' },
|
|
118
|
+
{ id: 'inline-zero-box', detail: 'style="width:0" / "height:0"' },
|
|
119
|
+
{ id: 'hidden-attribute', detail: 'the HTML5 boolean `hidden` attribute' },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deliberately NOT channels, with the reason.
|
|
124
|
+
*
|
|
125
|
+
* This list exists because its first entry was a shipped defect. A reviewer
|
|
126
|
+
* asking "why is `aria-hidden` missing?" must find the answer here rather than
|
|
127
|
+
* read the omission as an oversight and restore it.
|
|
128
|
+
*/
|
|
129
|
+
export const STRUCTURAL_HIDING_NON_CHANNELS: readonly HidingChannel[] = [
|
|
130
|
+
{
|
|
131
|
+
id: 'aria-hidden',
|
|
132
|
+
detail:
|
|
133
|
+
'aria-hidden="true" is NOT a hiding channel. Its content is VISIBLE to sighted ' +
|
|
134
|
+
'readers — it is a screen-reader affordance that removes an element from the ' +
|
|
135
|
+
'accessibility tree, not from the page. Stripping it deletes legitimate visible ' +
|
|
136
|
+
'text. It was on the channel list of the version this module replaces, and the ' +
|
|
137
|
+
'per-channel fixtures did not catch it because a fixture written from the same ' +
|
|
138
|
+
'wrong list agrees with it.',
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What this pre-pass does NOT catch.
|
|
144
|
+
*
|
|
145
|
+
* Restricted to uncovered classes on purpose: a register printed beside a
|
|
146
|
+
* working detector invites the reading that the listed gaps are the only gaps.
|
|
147
|
+
* Every entry here is a hiding technique that reaches the same end — content a
|
|
148
|
+
* human never sees and a model does — through a route no per-element test over
|
|
149
|
+
* an inline `style` attribute can reach.
|
|
150
|
+
*/
|
|
151
|
+
export const STRUCTURAL_HIDING_GAPS: readonly string[] = [
|
|
152
|
+
'stylesheet-driven hiding — a rule in a <style> block or an external sheet, where the element carries only a class',
|
|
153
|
+
'class-driven hiding — `class="sr-only"`, `class="hidden"`, any framework utility whose meaning lives elsewhere',
|
|
154
|
+
'off-screen positioning — `position:absolute; left:-9999px`, `transform: translate(-100%, 0)`, `clip-path`',
|
|
155
|
+
'background-coloured text — foreground equal to background, which needs a computed style to detect',
|
|
156
|
+
'fragmentation — a payload split across elements or interleaved with visible text, invisible to any per-element test',
|
|
157
|
+
'homoglyph and confusable obfuscation — a codepoint-layer channel, reported (never rewritten) by `scan_encoding_findings`',
|
|
158
|
+
'image-borne text — an instruction rendered into a picture, unreachable without OCR',
|
|
159
|
+
'script-constructed content — text assembled at runtime by JavaScript this pre-pass never executes',
|
|
160
|
+
'tiny-but-nonzero sizing — `font-size:1px`, `opacity:0.01`; only exact-zero forms are matched, because a threshold would need a corpus to set',
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ *
|
|
164
|
+
* Tokenizer
|
|
165
|
+
* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
/** A token kind. `comment` covers bogus comments and doctypes too. */
|
|
168
|
+
export type TokenKind = 'text' | 'comment' | 'start' | 'end';
|
|
169
|
+
|
|
170
|
+
/** One token, always carrying its exact span in the input. */
|
|
171
|
+
export interface Token {
|
|
172
|
+
readonly kind: TokenKind;
|
|
173
|
+
/** Offset of the token's first character in the input. */
|
|
174
|
+
readonly start: number;
|
|
175
|
+
/** Offset just past the token's last character. */
|
|
176
|
+
readonly end: number;
|
|
177
|
+
/** Lower-cased tag name — `start` and `end` tokens only. */
|
|
178
|
+
readonly name?: string;
|
|
179
|
+
/** Parsed attributes, lower-cased names — `start` tokens only. */
|
|
180
|
+
readonly attrs?: ReadonlyMap<string, string>;
|
|
181
|
+
/** True for `<br/>` and for void elements — `start` tokens only. */
|
|
182
|
+
readonly selfClosing?: boolean;
|
|
183
|
+
/** True when the token ran to EOF without its terminator. */
|
|
184
|
+
readonly unterminated?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Result of one tokenizer pass. */
|
|
188
|
+
export interface TokenizeResult {
|
|
189
|
+
readonly tokens: readonly Token[];
|
|
190
|
+
/** True when any token ran to EOF without its terminator. */
|
|
191
|
+
readonly unterminated: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Void elements: never carry a closing tag, so they never hide a subtree. */
|
|
195
|
+
const VOID_TAGS: ReadonlySet<string> = new Set([
|
|
196
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
197
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Elements whose content is character data, not markup.
|
|
202
|
+
*
|
|
203
|
+
* This is the state a scanner does not have, and finding #2 of the review is
|
|
204
|
+
* exactly its absence: inside `<script>`, the text `</div>` is three characters
|
|
205
|
+
* and a slash, not an end tag.
|
|
206
|
+
*/
|
|
207
|
+
const RAWTEXT_TAGS: ReadonlySet<string> = new Set(['script', 'style', 'textarea', 'title']);
|
|
208
|
+
|
|
209
|
+
const ASCII_ALPHA = /[A-Za-z]/;
|
|
210
|
+
const TAG_NAME_CHAR = /[A-Za-z0-9:_.-]/;
|
|
211
|
+
const WHITESPACE = /[ \t\n\f\r]/;
|
|
212
|
+
|
|
213
|
+
/** Read a tag name starting at `i`; returns the name and the next offset. */
|
|
214
|
+
function readTagName(html: string, i: number): { name: string; next: number } {
|
|
215
|
+
let j = i;
|
|
216
|
+
while (j < html.length && TAG_NAME_CHAR.test(html[j] as string)) j += 1;
|
|
217
|
+
return { name: html.slice(i, j).toLowerCase(), next: j };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse a start tag's attribute list, beginning just past the tag name.
|
|
222
|
+
*
|
|
223
|
+
* Quoting is honoured, so a `>` inside `style="a>b"` does not end the tag —
|
|
224
|
+
* the failure that makes a naive `indexOf('>')` strip the wrong span. Returns
|
|
225
|
+
* `null` when the tag never closes (unterminated markup at EOF).
|
|
226
|
+
*/
|
|
227
|
+
function readAttrs(
|
|
228
|
+
html: string,
|
|
229
|
+
from: number,
|
|
230
|
+
): { attrs: Map<string, string>; selfClosing: boolean; end: number } | null {
|
|
231
|
+
const attrs = new Map<string, string>();
|
|
232
|
+
let i = from;
|
|
233
|
+
let selfClosing = false;
|
|
234
|
+
|
|
235
|
+
for (;;) {
|
|
236
|
+
while (i < html.length && WHITESPACE.test(html[i] as string)) i += 1;
|
|
237
|
+
if (i >= html.length) return null; // EOF inside the tag
|
|
238
|
+
const ch = html[i] as string;
|
|
239
|
+
|
|
240
|
+
if (ch === '>') return { attrs, selfClosing, end: i + 1 };
|
|
241
|
+
if (ch === '/') {
|
|
242
|
+
selfClosing = true;
|
|
243
|
+
i += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Attribute name.
|
|
248
|
+
const nameStart = i;
|
|
249
|
+
while (
|
|
250
|
+
i < html.length &&
|
|
251
|
+
!WHITESPACE.test(html[i] as string) &&
|
|
252
|
+
html[i] !== '=' &&
|
|
253
|
+
html[i] !== '>' &&
|
|
254
|
+
html[i] !== '/'
|
|
255
|
+
) {
|
|
256
|
+
i += 1;
|
|
257
|
+
}
|
|
258
|
+
if (i === nameStart) {
|
|
259
|
+
// A character that starts no name and ends no tag — consume it so
|
|
260
|
+
// the loop always advances. Never spin on malformed input.
|
|
261
|
+
i += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const name = html.slice(nameStart, i).toLowerCase();
|
|
265
|
+
|
|
266
|
+
while (i < html.length && WHITESPACE.test(html[i] as string)) i += 1;
|
|
267
|
+
if (html[i] !== '=') {
|
|
268
|
+
attrs.set(name, ''); // valueless attribute
|
|
269
|
+
selfClosing = false; // a `/` before a real attribute was not a close
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
i += 1; // past '='
|
|
273
|
+
while (i < html.length && WHITESPACE.test(html[i] as string)) i += 1;
|
|
274
|
+
if (i >= html.length) return null;
|
|
275
|
+
|
|
276
|
+
const quote = html[i] as string;
|
|
277
|
+
let value: string;
|
|
278
|
+
if (quote === '"' || quote === "'") {
|
|
279
|
+
const close = html.indexOf(quote, i + 1);
|
|
280
|
+
if (close === -1) return null; // unterminated attribute value
|
|
281
|
+
value = html.slice(i + 1, close);
|
|
282
|
+
i = close + 1;
|
|
283
|
+
} else {
|
|
284
|
+
const vs = i;
|
|
285
|
+
while (i < html.length && !WHITESPACE.test(html[i] as string) && html[i] !== '>') i += 1;
|
|
286
|
+
value = html.slice(vs, i);
|
|
287
|
+
}
|
|
288
|
+
attrs.set(name, value);
|
|
289
|
+
selfClosing = false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Tokenize markup into text / comment / start / end tokens.
|
|
295
|
+
*
|
|
296
|
+
* Deterministic, pure, single pass, no backtracking. Every branch advances the
|
|
297
|
+
* cursor, so the loop terminates on any input including adversarial markup.
|
|
298
|
+
*/
|
|
299
|
+
export function tokenize(html: string): TokenizeResult {
|
|
300
|
+
const tokens: Token[] = [];
|
|
301
|
+
let unterminated = false;
|
|
302
|
+
let i = 0;
|
|
303
|
+
let textStart = 0;
|
|
304
|
+
|
|
305
|
+
const flushText = (upTo: number): void => {
|
|
306
|
+
if (upTo > textStart) tokens.push({ kind: 'text', start: textStart, end: upTo });
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
while (i < html.length) {
|
|
310
|
+
const lt = html.indexOf('<', i);
|
|
311
|
+
if (lt === -1) break;
|
|
312
|
+
|
|
313
|
+
const next = html[lt + 1];
|
|
314
|
+
|
|
315
|
+
// Comment, doctype, CDATA, or bogus comment.
|
|
316
|
+
if (next === '!') {
|
|
317
|
+
flushText(lt);
|
|
318
|
+
if (html.startsWith('<!--', lt)) {
|
|
319
|
+
const close = html.indexOf('-->', lt + 4);
|
|
320
|
+
if (close === -1) {
|
|
321
|
+
tokens.push({ kind: 'comment', start: lt, end: html.length, unterminated: true });
|
|
322
|
+
unterminated = true;
|
|
323
|
+
textStart = html.length;
|
|
324
|
+
i = html.length;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
tokens.push({ kind: 'comment', start: lt, end: close + 3 });
|
|
328
|
+
i = close + 3;
|
|
329
|
+
} else {
|
|
330
|
+
const close = html.indexOf('>', lt + 2);
|
|
331
|
+
if (close === -1) {
|
|
332
|
+
tokens.push({ kind: 'comment', start: lt, end: html.length, unterminated: true });
|
|
333
|
+
unterminated = true;
|
|
334
|
+
textStart = html.length;
|
|
335
|
+
i = html.length;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
tokens.push({ kind: 'comment', start: lt, end: close + 1 });
|
|
339
|
+
i = close + 1;
|
|
340
|
+
}
|
|
341
|
+
textStart = i;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// End tag.
|
|
346
|
+
if (next === '/' && ASCII_ALPHA.test(html[lt + 2] ?? '')) {
|
|
347
|
+
const { name, next: afterName } = readTagName(html, lt + 2);
|
|
348
|
+
const parsed = readAttrs(html, afterName); // end tags may carry (ignored) attrs
|
|
349
|
+
if (parsed === null) {
|
|
350
|
+
flushText(lt);
|
|
351
|
+
unterminated = true;
|
|
352
|
+
textStart = html.length;
|
|
353
|
+
i = html.length;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
flushText(lt);
|
|
357
|
+
tokens.push({ kind: 'end', start: lt, end: parsed.end, name });
|
|
358
|
+
i = parsed.end;
|
|
359
|
+
textStart = i;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Start tag.
|
|
364
|
+
if (ASCII_ALPHA.test(next ?? '')) {
|
|
365
|
+
const { name, next: afterName } = readTagName(html, lt + 1);
|
|
366
|
+
const parsed = readAttrs(html, afterName);
|
|
367
|
+
if (parsed === null) {
|
|
368
|
+
// Unterminated tag at EOF. Fail closed: the tail is dropped.
|
|
369
|
+
flushText(lt);
|
|
370
|
+
unterminated = true;
|
|
371
|
+
textStart = html.length;
|
|
372
|
+
i = html.length;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
flushText(lt);
|
|
376
|
+
const selfClosing = parsed.selfClosing || VOID_TAGS.has(name);
|
|
377
|
+
tokens.push({
|
|
378
|
+
kind: 'start',
|
|
379
|
+
start: lt,
|
|
380
|
+
end: parsed.end,
|
|
381
|
+
name,
|
|
382
|
+
attrs: parsed.attrs,
|
|
383
|
+
selfClosing,
|
|
384
|
+
});
|
|
385
|
+
i = parsed.end;
|
|
386
|
+
textStart = i;
|
|
387
|
+
|
|
388
|
+
// RAWTEXT: everything up to the matching close tag is character data.
|
|
389
|
+
if (!selfClosing && RAWTEXT_TAGS.has(name)) {
|
|
390
|
+
const closeAt = findRawtextClose(html, i, name);
|
|
391
|
+
if (closeAt === -1) {
|
|
392
|
+
tokens.push({ kind: 'text', start: i, end: html.length, unterminated: true });
|
|
393
|
+
unterminated = true;
|
|
394
|
+
textStart = html.length;
|
|
395
|
+
i = html.length;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
if (closeAt > i) tokens.push({ kind: 'text', start: i, end: closeAt });
|
|
399
|
+
const afterClose = html.indexOf('>', closeAt);
|
|
400
|
+
const endOff = afterClose === -1 ? html.length : afterClose + 1;
|
|
401
|
+
tokens.push({ kind: 'end', start: closeAt, end: endOff, name });
|
|
402
|
+
if (afterClose === -1) unterminated = true;
|
|
403
|
+
i = endOff;
|
|
404
|
+
textStart = i;
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// A `<` that starts nothing — ordinary text. Advance past it only.
|
|
410
|
+
i = lt + 1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
flushText(html.length);
|
|
414
|
+
return { tokens, unterminated };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Offset of the `<` of the matching RAWTEXT close tag, or -1. */
|
|
418
|
+
function findRawtextClose(html: string, from: number, name: string): number {
|
|
419
|
+
let i = from;
|
|
420
|
+
for (;;) {
|
|
421
|
+
const lt = html.indexOf('</', i);
|
|
422
|
+
if (lt === -1) return -1;
|
|
423
|
+
const { name: candidate, next } = readTagName(html, lt + 2);
|
|
424
|
+
const after = html[next] ?? '>';
|
|
425
|
+
if (candidate === name && (after === '>' || after === '/' || WHITESPACE.test(after))) return lt;
|
|
426
|
+
i = lt + 2;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* ------------------------------------------------------------------ *
|
|
431
|
+
* Hidden-element predicate
|
|
432
|
+
* ------------------------------------------------------------------ */
|
|
433
|
+
|
|
434
|
+
/** Inline-style declarations that hide an element outright. */
|
|
435
|
+
const HIDING_STYLE_RULES: readonly { readonly channel: string; readonly re: RegExp }[] = [
|
|
436
|
+
{ channel: 'inline-display-none', re: /(?:^|;)\s*display\s*:\s*none\s*(?:!\s*important\s*)?(?:;|$)/i },
|
|
437
|
+
{ channel: 'inline-visibility-hidden', re: /(?:^|;)\s*visibility\s*:\s*hidden\s*(?:!\s*important\s*)?(?:;|$)/i },
|
|
438
|
+
{ channel: 'inline-opacity-zero', re: /(?:^|;)\s*opacity\s*:\s*0(?:\.0+)?\s*(?:!\s*important\s*)?(?:;|$)/i },
|
|
439
|
+
{
|
|
440
|
+
channel: 'inline-font-size-zero',
|
|
441
|
+
re: /(?:^|;)\s*font-size\s*:\s*0(?:\.0+)?(?:px|em|rem|pt|%)?\s*(?:!\s*important\s*)?(?:;|$)/i,
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
channel: 'inline-zero-box',
|
|
445
|
+
re: /(?:^|;)\s*(?:width|height)\s*:\s*0(?:\.0+)?(?:px|em|rem|pt|%)?\s*(?:!\s*important\s*)?(?:;|$)/i,
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The channel that hides this element, or `null` when it is visible.
|
|
451
|
+
*
|
|
452
|
+
* Reads the PARSED attribute map, never the raw tag text — an attribute value
|
|
453
|
+
* is already unquoted here, so no substring match can escape its own attribute.
|
|
454
|
+
*/
|
|
455
|
+
export function hidingChannelFor(name: string, attrs: ReadonlyMap<string, string>): string | null {
|
|
456
|
+
if (name === 'template') return 'template-element';
|
|
457
|
+
if (attrs.has('hidden')) {
|
|
458
|
+
const v = (attrs.get('hidden') ?? '').trim().toLowerCase();
|
|
459
|
+
// `hidden`, `hidden=""`, `hidden="hidden"`, `hidden="true"` all hide.
|
|
460
|
+
// `hidden="until-found"` does NOT — the content is reachable by find-in-page.
|
|
461
|
+
if (v !== 'until-found') return 'hidden-attribute';
|
|
462
|
+
}
|
|
463
|
+
const style = attrs.get('style');
|
|
464
|
+
if (style !== undefined) {
|
|
465
|
+
for (const rule of HIDING_STYLE_RULES) {
|
|
466
|
+
if (rule.re.test(style)) return rule.channel;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ------------------------------------------------------------------ *
|
|
473
|
+
* Stripper
|
|
474
|
+
* ------------------------------------------------------------------ */
|
|
475
|
+
|
|
476
|
+
/** One removed span, reported so a caller can debug a corrupted result. */
|
|
477
|
+
export interface StructuralRemoval {
|
|
478
|
+
/** A {@link STRUCTURAL_HIDING_CHANNELS} id. Never derived from input. */
|
|
479
|
+
readonly channel: string;
|
|
480
|
+
/** Offset of the removed span's first character in the INPUT. */
|
|
481
|
+
readonly start: number;
|
|
482
|
+
/** Offset just past the removed span's last character in the INPUT. */
|
|
483
|
+
readonly end: number;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Result of one strip pass. */
|
|
487
|
+
export interface StructuralStripResult {
|
|
488
|
+
/** The model-facing copy, with hidden spans replaced by sentinels. */
|
|
489
|
+
readonly text: string;
|
|
490
|
+
/** Every removal, in document order. */
|
|
491
|
+
readonly removals: readonly StructuralRemoval[];
|
|
492
|
+
/** True when unterminated markup made the tail unresolvable. */
|
|
493
|
+
readonly truncated: boolean;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The sentinel left where content was removed.
|
|
498
|
+
*
|
|
499
|
+
* `channel` always comes from the closed vocabulary above, so no byte of the
|
|
500
|
+
* input ever reaches the output through this path.
|
|
501
|
+
*/
|
|
502
|
+
export function removalSentinel(channel: string): string {
|
|
503
|
+
return `[removed: structural-hiding/${channel}]`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface OpenElement {
|
|
507
|
+
readonly name: string;
|
|
508
|
+
/** Set when this element is the root of a hidden subtree. */
|
|
509
|
+
readonly hiddenChannel: string | null;
|
|
510
|
+
/** Input offset where the hidden subtree began. */
|
|
511
|
+
readonly hiddenStart: number;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Remove structurally hidden content from a markup string.
|
|
516
|
+
*
|
|
517
|
+
* Covers exactly {@link STRUCTURAL_HIDING_CHANNELS} and deliberately not
|
|
518
|
+
* {@link STRUCTURAL_HIDING_NON_CHANNELS}; the classes it cannot reach at all
|
|
519
|
+
* are {@link STRUCTURAL_HIDING_GAPS}. Visible text is returned untouched,
|
|
520
|
+
* including the markup around it — this is a pre-pass, not a markup-to-text
|
|
521
|
+
* converter, so a caller that also wants the codepoint floor composes the two.
|
|
522
|
+
*
|
|
523
|
+
* Deterministic and pure: no network, no I/O, no model call.
|
|
524
|
+
*/
|
|
525
|
+
export function strip_structural_hiding(html: string): StructuralStripResult {
|
|
526
|
+
const { tokens, unterminated } = tokenize(html);
|
|
527
|
+
const removals: StructuralRemoval[] = [];
|
|
528
|
+
const stack: OpenElement[] = [];
|
|
529
|
+
let out = '';
|
|
530
|
+
/** Index into `stack` of the hidden root, or -1 when nothing is hidden. */
|
|
531
|
+
let hiddenAt = -1;
|
|
532
|
+
|
|
533
|
+
for (const t of tokens) {
|
|
534
|
+
if (t.kind === 'start') {
|
|
535
|
+
const name = t.name as string;
|
|
536
|
+
const attrs = t.attrs as ReadonlyMap<string, string>;
|
|
537
|
+
|
|
538
|
+
if (hiddenAt !== -1) {
|
|
539
|
+
// Inside a hidden subtree: track depth, emit nothing.
|
|
540
|
+
if (!(t.selfClosing ?? false)) {
|
|
541
|
+
stack.push({ name, hiddenChannel: null, hiddenStart: 0 });
|
|
542
|
+
}
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const channel = hidingChannelFor(name, attrs);
|
|
547
|
+
if (channel === null) {
|
|
548
|
+
out += html.slice(t.start, t.end);
|
|
549
|
+
if (!(t.selfClosing ?? false)) {
|
|
550
|
+
stack.push({ name, hiddenChannel: null, hiddenStart: 0 });
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (t.selfClosing ?? false) {
|
|
556
|
+
// No subtree to drop — just the tag itself.
|
|
557
|
+
removals.push({ channel, start: t.start, end: t.end });
|
|
558
|
+
out += removalSentinel(channel);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
stack.push({ name, hiddenChannel: channel, hiddenStart: t.start });
|
|
562
|
+
hiddenAt = stack.length - 1;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (t.kind === 'end') {
|
|
567
|
+
const name = t.name as string;
|
|
568
|
+
// Nearest matching open element. A stray end tag matches nothing
|
|
569
|
+
// and is ignored, exactly as a browser ignores it.
|
|
570
|
+
let idx = -1;
|
|
571
|
+
for (let k = stack.length - 1; k >= 0; k -= 1) {
|
|
572
|
+
if (stack[k]?.name === name) {
|
|
573
|
+
idx = k;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (idx === -1) {
|
|
578
|
+
if (hiddenAt === -1) out += html.slice(t.start, t.end);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const closingHidden = hiddenAt !== -1 && idx <= hiddenAt;
|
|
582
|
+
if (closingHidden) {
|
|
583
|
+
const root = stack[hiddenAt] as OpenElement;
|
|
584
|
+
removals.push({
|
|
585
|
+
channel: root.hiddenChannel as string,
|
|
586
|
+
start: root.hiddenStart,
|
|
587
|
+
end: t.end,
|
|
588
|
+
});
|
|
589
|
+
out += removalSentinel(root.hiddenChannel as string);
|
|
590
|
+
hiddenAt = -1;
|
|
591
|
+
} else if (hiddenAt === -1) {
|
|
592
|
+
out += html.slice(t.start, t.end);
|
|
593
|
+
}
|
|
594
|
+
stack.length = idx;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (t.kind === 'comment') {
|
|
599
|
+
if (hiddenAt !== -1) continue; // already inside a removed span
|
|
600
|
+
removals.push({ channel: 'html-comment', start: t.start, end: t.end });
|
|
601
|
+
out += removalSentinel('html-comment');
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// text
|
|
606
|
+
if (hiddenAt === -1) out += html.slice(t.start, t.end);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// An unclosed hidden element swallows the rest of the document. Fail
|
|
610
|
+
// closed: it is already excluded from `out`; record it and say so.
|
|
611
|
+
if (hiddenAt !== -1) {
|
|
612
|
+
const root = stack[hiddenAt] as OpenElement;
|
|
613
|
+
removals.push({
|
|
614
|
+
channel: root.hiddenChannel as string,
|
|
615
|
+
start: root.hiddenStart,
|
|
616
|
+
end: html.length,
|
|
617
|
+
});
|
|
618
|
+
out += removalSentinel(root.hiddenChannel as string);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return { text: out, removals, truncated: unterminated || hiddenAt !== -1 };
|
|
622
|
+
}
|