Software Enshittification

By Neels Kriek — 12th April, 2026

Origin

The term was coined by Canadian-British writer and digital rights activist Cory Doctorow, who first used it in a January 2023 post on his blog Pluralistic to describe the decline of online platforms. It gained wider traction after he used it in a piece for Wired magazine shortly after, and it spread rapidly through tech media and mainstream discourse. The American Dialect Society named it their Word of the Year for 2023.

Description

Enshittification describes the predictable, cyclical decay of online platforms and tech products. Doctorow outlined a three-stage pattern:

  1. Good to users A platform starts by being genuinely useful and user-friendly to attract an audience and build a network effect.
  2. Good to business customers Once users are locked in, the platform shifts to favouring advertisers, sellers, or business partners at users' expense, extracting value from the user base to reward those paying customers.
  3. Good to shareholders only Finally, the platform squeezes both users and business customers to maximise returns for investors and executives, until the product becomes so degraded that it collapses or loses relevance.

Classic examples cited include Google Search (increasingly bloated with ads and SEO spam), Facebook (throttling organic reach to push paid promotion), Amazon (burying genuine results under sponsored listings), and TikTok's feed becoming progressively more aggressive.

Why it resonated

The term filled a gap — it named something people were experiencing but lacked a precise word for. It also implies agency and inevitability: platforms don't decay by accident, but because the structural incentives of shareholder capitalism and platform lock-in make this trajectory almost inescapable. Doctorow argues that weak competition law and the erosion of interoperability are the root causes, and that regulatory intervention is the only reliable cure.

Realmac Elements

Which brings us neatly to a local example. RealMac Software, publisher of the long-running RapidWeaver web design tool and its successor Elements, has been aggressively nudging its user base toward the new product. After holding out for a while, I finally relented.

The friction revealed itself quickly. In RapidWeaver, suppressing the "Made with RapidWeaver" marketing badge injected into your published pages was a simple setting — available to any paying customer. In Elements, that same setting has been quietly moved behind the PRO subscription tier. You pay for the product, but RealMac still gets to advertise on your website unless you pay more.

Textbook enshittification: a feature that existed as a basic courtesy to customers has been repackaged as a premium unlock. The users who built loyalty over years are now the ones being squeezed.

Fortunately, the solution is straightforward. Whether you publish plain HTML or PHP, a short Python script can scan your output files, locate the injected JavaScript snippet, and strip it cleanly. No subscription required.

#!/usr/bin/env python3
"""
strip_elements_badge.py  (v3)
-----------------------
Scans all index.htm / index.html / index.php files in the given directory
(recursively) for the RapidWeaver Elements "Made in Elements" badge block,
then optionally strips it from each affected file.

Usage:
    python strip_elements_badge.py                        # scans current directory
    python strip_elements_badge.py /path/to/site          # scans given directory
    python strip_elements_badge.py /path/to/site --strip  # scan + strip
    python strip_elements_badge.py /path/to/site --strip --no-backup
    python strip_elements_badge.py /path/to/site --dry-run  # preview without writing
"""

import argparse
import re
import shutil
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Detection signatures
# ---------------------------------------------------------------------------
SIGNATURE = "elementsapp.io"
CONFIRM_SIGNATURES = ["enforceVisible", "Made in Elements"]


# ---------------------------------------------------------------------------
# Removal strategies (tried in order until one fully removes the badge)
# ---------------------------------------------------------------------------

def strategy_regex_doublequote(text: str) -> tuple[str, int]:
    """x-data attribute with double quotes."""
    pat = re.compile(
        r'[ \t]*<div\b[^>]*x-data="[^"]*enforceVisible[^"]*"[^>]*>.*?</div>[ \t]*\r?\n?',
        re.DOTALL,
    )
    return pat.subn("", text)


def strategy_regex_singlequote(text: str) -> tuple[str, int]:
    """x-data attribute with single quotes."""
    pat = re.compile(
        r"[ \t]*<div\b[^>]*x-data='[^']*enforceVisible[^']*'[^>]*>.*?</div>[ \t]*\r?\n?",
        re.DOTALL,
    )
    return pat.subn("", text)


def strategy_regex_multiline_attr(text: str) -> tuple[str, int]:
    """
    x-data spans multiple lines (the attribute value contains newlines),
    so [^>]* fails. Match by finding enforceVisible anywhere between
    the opening <div and the matching </div>.
    """
    pat = re.compile(
        r'[ \t]*<div\b(?:(?!<div\b).)*?enforceVisible(?:(?!<div\b).)*?>.*?</div>[ \t]*\r?\n?',
        re.DOTALL,
    )
    return pat.subn("", text)


def strategy_line_based(text: str) -> tuple[str, int]:
    """
    Line-based depth-tracking fallback.
    Finds the line containing 'enforceVisible', then tracks <div> nesting
    depth to find the matching closing </div>.
    """
    lines = text.splitlines(keepends=True)
    result = []
    depth = 0
    in_block = False
    removed = 0

    for line in lines:
        if not in_block:
            if "enforceVisible" in line:
                in_block = True
                depth = line.count("<div") - line.count("</div")
                if depth <= 0:
                    in_block = False
                    removed += 1
                # Don't append — this line is part of the block
            else:
                result.append(line)
        else:
            depth += line.count("<div") - line.count("</div")
            if depth <= 0:
                in_block = False
                removed += 1
            # Don't append lines inside the block

    return "".join(result), removed


def strategy_between_markers(text: str) -> tuple[str, int]:
    """
    Last resort: find lines containing known badge strings and remove
    the surrounding block by tracking <div> depth from the first hit.
    """
    lines = text.splitlines(keepends=True)
    result = []
    i = 0
    removed = 0

    while i < len(lines):
        line = lines[i]
        if SIGNATURE in line or "enforceVisible" in line or "Made in Elements" in line:
            depth = line.count("<div") - line.count("</div")
            j = i + 1
            while j < len(lines) and depth > 0:
                depth += lines[j].count("<div") - lines[j].count("</div")
                j += 1
            i = j
            removed += 1
        else:
            result.append(line)
            i += 1

    return "".join(result), removed


STRATEGIES = [
    ("regex double-quote attr",       strategy_regex_doublequote),
    ("regex single-quote attr",       strategy_regex_singlequote),
    ("regex multiline attr",          strategy_regex_multiline_attr),
    ("line-based depth-tracking",     strategy_line_based),
    ("between-markers",               strategy_between_markers),
]


# ---------------------------------------------------------------------------
# Core helpers
# ---------------------------------------------------------------------------

def find_html_files(root: Path) -> list[Path]:
    patterns = ["index.htm", "index.html", "index.php"]
    seen: set[Path] = set()
    unique: list[Path] = []
    for pattern in patterns:
        for f in sorted(root.rglob(pattern)):
            if f not in seen:
                seen.add(f)
                unique.append(f)
    return sorted(unique)


def read_file(path: Path) -> str | None:
    try:
        return path.read_text(encoding="utf-8", errors="replace")
    except OSError as e:
        print(f"  [WARN] Cannot read {path}: {e}")
        return None


def file_contains_badge(text: str) -> bool:
    if SIGNATURE not in text:
        return False
    return any(sig in text for sig in CONFIRM_SIGNATURES)


def strip_badge(path: Path, backup: bool = True, dry_run: bool = False) -> tuple[bool, str]:
    original = read_file(path)
    if original is None:
        return False, "Cannot read file"

    if not file_contains_badge(original):
        return False, "Signature no longer found — skipped"

    cleaned = original
    used_strategy = None

    for name, strategy in STRATEGIES:
        candidate, count = strategy(cleaned)
        if count > 0 and SIGNATURE not in candidate:
            cleaned = candidate
            used_strategy = name
            break

    if used_strategy is None or SIGNATURE in cleaned:
        return False, (
            "All strategies failed — please share this file for analysis.\n"
            f"         Hint: check if x-data attribute uses a different quote style or\n"
            f"         if the badge HTML has changed significantly."
        )

    if dry_run:
        before_lines = original.count("\n")
        after_lines = cleaned.count("\n")
        return True, f"[DRY RUN] Would remove ~{before_lines - after_lines} line(s) via '{used_strategy}'"

    if backup:
        backup_path = path.with_suffix(path.suffix + ".bak")
        try:
            shutil.copy2(path, backup_path)
        except OSError as e:
            return False, f"Could not create backup: {e}"

    try:
        path.write_text(cleaned, encoding="utf-8")
    except OSError as e:
        return False, f"Could not write file: {e}"

    return True, f"Removed via '{used_strategy}'"


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Find and optionally strip the RapidWeaver Elements badge from HTML files."
    )
    parser.add_argument(
        "directory",
        nargs="?",
        default=".",
        help="Root directory to scan (default: current directory)",
    )
    parser.add_argument(
        "--strip",
        action="store_true",
        help="Strip the badge from all affected files",
    )
    parser.add_argument(
        "--no-backup",
        action="store_true",
        help="Skip creating .bak backup files before stripping",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview what would be removed without writing any files",
    )
    args = parser.parse_args()

    root = Path(args.directory).resolve()
    if not root.is_dir():
        print(f"Error: '{root}' is not a directory.")
        sys.exit(1)

    make_backup = not args.no_backup
    do_strip = args.strip or args.dry_run

    print(f"\n{'='*62}")
    print(f"  RapidWeaver Elements Badge Stripper  (v3)")
    print(f"{'='*62}")
    print(f"  Root   : {root}")
    if args.dry_run:
        print(f"  Mode   : DRY RUN — no files will be changed")
    elif args.strip:
        print(f"  Mode   : STRIP")
        print(f"  Backup : {'YES (.bak files created)' if make_backup else 'NO'}")
    else:
        print(f"  Mode   : SCAN ONLY")
    print(f"{'='*62}\n")

    all_files = find_html_files(root)
    print(f"Scanning {len(all_files)} index file(s) (htm/html/php)...\n")

    affected: list[Path] = []
    for path in all_files:
        text = read_file(path)
        if text and file_contains_badge(text):
            print(f"  [FOUND] {path.relative_to(root)}")
            affected.append(path)

    print(f"\n{'─'*62}")
    print(f"  Badge found in {len(affected)} of {len(all_files)} file(s).")
    print(f"{'─'*62}")

    if not affected:
        print("\nNothing to do. Exiting.\n")
        sys.exit(0)

    if not do_strip:
        print(f"\nRun with --strip to remove, or --dry-run to preview.\n")
        print(f"  python {Path(__file__).name} \"{args.directory}\" --strip\n")
        sys.exit(0)

    verb = "Previewing" if args.dry_run else "Processing"
    print(f"\n{verb} {len(affected)} file(s)...\n")

    ok_count = 0
    fail_count = 0
    for path in affected:
        rel = path.relative_to(root)
        success, msg = strip_badge(path, backup=make_backup, dry_run=args.dry_run)
        tag = "OK  " if success else "FAIL"
        print(f"  [{tag}] {rel} — {msg}")
        if success:
            ok_count += 1
        else:
            fail_count += 1

    print(f"\n{'='*62}")
    if args.dry_run:
        print(f"  Dry run done. {ok_count} file(s) would be modified.")
    else:
        print(f"  Done.  Stripped: {ok_count}  |  Failed: {fail_count}")
        if make_backup and ok_count > 0:
            print(f"  Originals saved as <filename>.bak alongside each file.")
    print(f"{'='*62}\n")

    sys.exit(1 if fail_count > 0 else 0)


if __name__ == "__main__":
    main()