diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..31b7203 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "env": { + "browser": true, + "es2022": true, + "node": true + }, + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "ignorePatterns": ["node_modules/", "playwright-report/", "test-results/"] +} diff --git a/.github/ISSUE_TEMPLATE/add-repo.md b/.github/ISSUE_TEMPLATE/add-repo.md new file mode 100644 index 0000000..5d42323 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/add-repo.md @@ -0,0 +1,14 @@ +--- +name: Add repository +about: Request that a repository is added to the RefactorFirst listing +title: "Add repository: owner/repo" +labels: repository-submission +--- + + + + + +The repository must already publish a `.refactorfirst/refactor-first.json` +report on its `main`, default or `master` branch — see the site's +**Getting Started** page for how to generate it with RefactorFirst. diff --git a/.github/workflows/add-repository.yml b/.github/workflows/add-repository.yml new file mode 100644 index 0000000..8a48144 --- /dev/null +++ b/.github/workflows/add-repository.yml @@ -0,0 +1,36 @@ +name: Add Repository +on: + issues: + types: [opened] + # Manual re-run for an existing issue number (e.g. after a transient failure). + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to process' + required: true + type: string + +# Serialize listing updates: concurrent runs would otherwise race at commit +# time and reject valid submissions with non-fast-forward errors. +concurrency: + group: add-repository + cancel-in-progress: false + +permissions: + contents: write + issues: write + +jobs: + add-repository: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Process submission issue + run: bash ci/process-submissions.sh github + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The submitter identity is github.event.issue.user.login, resolved + # from the issue number inside the script (never interpolated here). + ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} diff --git a/.github/workflows/redeploy.yml b/.github/workflows/redeploy.yml new file mode 100644 index 0000000..ae3ea51 --- /dev/null +++ b/.github/workflows/redeploy.yml @@ -0,0 +1,52 @@ +name: Scheduled Redeploy +on: + schedule: + - cron: '*/10 * * * *' # Every 10 minutes + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + check-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for repositories.txt changes + id: check-changes + run: | + LAST_COMMIT=$(git log -1 --format=%ct -- repositories.txt || echo 0) + CURRENT_TIME=$(date +%s) + TIME_DIFF=$((CURRENT_TIME - LAST_COMMIT)) + + echo "Last commit: $LAST_COMMIT" + echo "Current time: $CURRENT_TIME" + echo "Time difference: $TIME_DIFF seconds" + + # Only deploy if changed in the last 15 minutes + if [ "$TIME_DIFF" -lt 900 ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "repositories.txt has recent changes, deploying" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No recent changes, skipping deployment" + fi + + - name: Setup Pages + if: steps.check-changes.outputs.changed == 'true' + uses: actions/configure-pages@v4 + + - name: Upload artifact + if: steps.check-changes.outputs.changed == 'true' + uses: actions/upload-pages-artifact@v3 + with: + path: '.' + + - name: Deploy to GitHub Pages + if: steps.check-changes.outputs.changed == 'true' + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3006cef --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test Suite +on: [pull_request] + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun install + - run: bun test tests/unit tests/integration + - run: bunx eslint "js/**/*.js" "tests/**/*.js" + - run: shellcheck ci/process-submissions.sh + + e2e-tests: + # Matrix execution: one runner per browser so the suites run in parallel. + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun install + - run: bunx playwright install --with-deps ${{ matrix.browser }} + - run: bunx playwright test --project=${{ matrix.browser }} + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-${{ matrix.browser }} + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54faf00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +bun.lockb +package-lock.json +yarn.lock +pnpm-lock.yaml + +# IDE +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.DS_Store + +# Build and distribution +dist/ +build/ +out/ +*.tsbuildinfo + +# Test coverage and reports +coverage/ +.nyc_output/ +test-results/ +playwright-report/ +playwright/.cache/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +bun-debug.log* + +# OS files +Thumbs.db +*.swp +*.swo +*~ + +# Temporary files +tmp/ +temp/ +.cache/ +.parcel-cache/ + +# Lock files (keep bun.lock, ignore others) +# Note: We keep bun.lock since this project uses Bun diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a978c2b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,49 @@ +# GitLab CI configuration for this RefactorFirst Pages site. +# +# Deploys the static files to GitLab Pages. A copy of index.html is published as +# 404.html so client-side routes like /user/repo serve the application instead of +# GitLab's generic 404 page. + +stages: + - test + - process + - deploy + +validate-site: + stage: test + image: alpine:3.20 + script: + - test -f index.html + - test -f repositories.txt + - sort -c repositories.txt + - '! grep -Ev "^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$" repositories.txt' + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +# Processes open "Add repository: owner/repo" issues: validates the issue +# author has write access to the project, checks the report file exists, +# commits repositories.txt and closes the issue with the outcome. +# GitLab has no issue-triggered pipelines, so create a pipeline schedule +# (e.g. every 10 minutes) in Settings -> CI/CD -> Schedules. +process-submissions: + stage: process + image: alpine:3.20 + before_script: + - apk add --no-cache curl jq + script: + - sh ci/process-submissions.sh gitlab + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + +pages: + stage: deploy + image: alpine:3.20 + script: + - mkdir -p public + - cp -r index.html repositories.txt css js templates assets public/ + - cp index.html public/404.html # client-side routing for deep links + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/.gitlab/issue_templates/Add repository.md b/.gitlab/issue_templates/Add repository.md new file mode 100644 index 0000000..b515730 --- /dev/null +++ b/.gitlab/issue_templates/Add repository.md @@ -0,0 +1,7 @@ + + + + +The project must already publish a `.refactorfirst/refactor-first.json` +report on its `main`, default or `master` branch — see the site's +**Getting Started** page for how to generate it with RefactorFirst. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7865d32 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,136 @@ +# RefactorFirst GitHub Pages Application - Agent Guide + +## Project Overview + +A purely client-side static web application that renders RefactorFirst reports by fetching `.refactorfirst/refactor-first.json` data directly from GitHub repositories. No server-side code, no database, no build step — HTML, CSS and ES6 JavaScript modules served as static files. + +**Key Features:** +- Search over curated repository listing (`repositories.txt`) +- Reports rendered with Mustache.js from raw platform content +- Repository submission via pre-filled platform issues — no login, apps or + tokens; identity is captured as the issue author and validated in CI +- Works with plain static file server + +## Development Setup + +```bash +bun install # install devDependencies +python3 -m http.server 8000 # run locally at http://localhost:8000 +``` + +## Testing Commands + +**Unit + Integration Tests (Bun):** +```bash +bun test tests/unit tests/integration # run all unit/integration tests +bun test --watch tests/unit # watch mode +bun test --coverage tests/unit tests/integration # coverage report +``` + +**E2E Tests (Playwright):** +```bash +npx playwright install # one-time: download browsers +npx playwright test # full E2E suite (chromium, firefox, webkit) +npx playwright test --ui # interactive mode +``` + +**Linting:** +```bash +npx eslint js/**/*.js tests/**/*.js # lint +npx eslint js/**/*.js tests/**/*.js --fix # auto-fix +``` + +## Project Structure + +``` +index.html # Single-page app shell (top menu + #app container) +repositories.txt # Listed repositories, one "user/repo" per line +js/ # ES6 modules: router, fetcher, renderer, search, + # repo-submission, error-handler, + # rate-limiter, cache-manager, utils, main +ci/process-submissions.sh # Shared submission validator for GitHub Actions, + # GitLab CI and Bitbucket Pipelines +css/ # main.css + components.css +templates/ # Static page templates (about, faq, errors, ...) + # + user CI templates for GitHub/GitLab/Bitbucket +assets/ # Fallback Mustache template, logo, Sentry config +tests/ # unit/ (Bun), integration/ (Bun), e2e/ (Playwright) +.github/workflows/ # add-repository.yml, redeploy.yml, test.yml +``` + +## Development Workflow + +**TDD is mandatory** — write failing tests before production code: + +1. Write a failing test in `tests/unit/` (pure module logic) or `tests/integration/` (DOM + routing flows) +2. Run `bun test tests/unit tests/integration` and watch it fail +3. Write the minimal implementation in `js/` to make it pass +4. Refactor while keeping tests green + +## Key Module Responsibilities + +| Module | Responsibility | +|--------|---------------| +| `js/router.js` | URL routes and routing logic | +| `js/fetcher.js` | Platform-aware raw fetching / branch fallback | +| `js/renderer.js` | Mustache rendering | +| `js/search.js` | Search / type-ahead functionality | +| `js/repo-submission.js` | Submission flow: validation, report check, per-platform issue URLs | +| `js/report-view.js` | Interactive report widgets: DOT popups (Sigma/3D), Chart.js bubbles, vizdom WASM graphs | +| `js/error-handler.js` | Error page rendering | +| `js/utils.js` | Utility functions, environment detection | +| `js/main.js` | Application entry point | +| `ci/process-submissions.sh` | CI-side submission validation + write-back for all platforms | + +## Testing Requirements + +- **Unit tests**: Pure module logic (router, fetcher, renderer, search, etc.) +- **Integration tests**: DOM + routing flows (search flow, submission flow incl. per-platform issue redirect) +- **E2E tests**: User journeys (incl. submission → pre-filled issue hand-off), cross-browser smoke tests, mobile responsiveness +- **Coverage target**: 80%+ on core modules +- **Current suite**: 165 tests + +## CI/CD + +- `.github/workflows/test.yml` runs Bun unit/integration tests and Playwright E2E suite on every push and PR +- Keep tests green before merging +- GitHub Actions used for scheduled redeployment and repository submission validation + +## Environment-Aware Documentation + +The Getting Started page shows only the CI sample matching the hosting environment, detected from hostname: +- `*.github.io` → GitHub Actions +- `*.gitlab.io` → GitLab CI +- `*.bitbucket.io` → Bitbucket Pipelines +- Anything else → defaults to GitHub + +Detection logic in `js/utils.js` → `detectHostingEnvironment()` + +## Deployment Targets + +This project supports deployment to: +- GitHub Pages (organization or personal account) +- GitHub Enterprise Server +- GitLab Pages +- Bitbucket static hosting + +See README.md for detailed deployment instructions for each platform. + +## Code Conventions + +- ES6 modules throughout +- No build step required +- Client-side routing from single `index.html` +- Mustache.js for templating +- No client-side authentication — submission identity comes from the platform issue author +- Static file serving (no server-side code) + +## Important Notes + +- The `` tag in `index.html` points submissions at the listing project; self-managed GitLab deployments add `` +- Deployments must extend the CSP `connect-src` with the platform endpoints they use (`api.gitlab.com`/custom base, `api.bitbucket.org`, ...) +- Report rendering loads CDN libs (Chart.js, sigma/graphology, graphlib-dot, svg-pan-zoom, 3d-force-graph, vizdom WASM) — keep CSP `script-src`/`connect-src` entries (`cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `esm.sh`, `buttons.github.io`, `wasm-unsafe-eval`) when tightening the policy +- `assets/refactor-first-report.mustache` is a port of the RefactorFirst viewer template — keep it in sync with upstream +- For GitHub Enterprise Server, update API/raw endpoints in `js/repo-submission.js`, `js/fetcher.js`, and `ci/process-submissions.sh` +- Deep links require `404.html` copy of `index.html` for proper client-side routing on some platforms +- Reports are fetched client-side — end users' browsers must reach GitHub/raw endpoints diff --git a/README.md b/README.md new file mode 100644 index 0000000..30feff5 --- /dev/null +++ b/README.md @@ -0,0 +1,426 @@ +# RefactorFirst GitHub Pages Application + +A purely client-side static web application that renders +[RefactorFirst](https://github.com/refactorfirst/refactorfirst) reports by fetching +`.refactorfirst/refactor-first.json` data directly from repositories. No server-side +code, no database, no build step — HTML, CSS and ES6 JavaScript modules served as +static files. + +- **Search** over a curated listing of repositories (`repositories.txt`) +- **Reports** rendered with Mustache.js from raw platform content, with `main` → `master` + branch fallback — the same report the + [RefactorFirst report viewer](https://github.com/RefactorFirst/RefactorFirst) produces: + class/package maps (vizdom WASM SVGs with pan/zoom, plus Sigma 2D and 3D force-graph + popups), relationship-removal priority tables, Chart.js disharmony bubble charts and + class cycle summaries +- **Repository submission** via a pre-filled issue on the hosting platform + (no login, apps or tokens on this site): your platform account is captured as + the issue author and validated server-side by the platform's CI +- Reports and submissions work for repositories hosted on the same platform as + the deployment (GitHub, GitLab or Bitbucket) +- Works with a plain static file server: `python3 -m http.server 8000` + +--- + +## Table of Contents + +- [Project Layout](#project-layout) +- [Deploying to GitHub Pages (organization or personal account)](#deploying-to-github-pages-organization-or-personal-account) +- [Deploying to GitHub Enterprise Server](#deploying-to-github-enterprise-server) +- [Deploying to Bitbucket](#deploying-to-bitbucket) +- [Deploying to GitLab](#deploying-to-gitlab) +- [How repository submission works](#how-repository-submission-works) +- [Making Changes (Developer Guide)](#making-changes-developer-guide) +- [Testing](#testing) + +--- + +## Project Layout + +``` +index.html # Single-page app shell (top menu + #app container) +repositories.txt # Listed repositories, one "user/repo" per line +js/ # ES6 modules: router, fetcher, renderer, search, + # repo-submission, error-handler, + # rate-limiter, cache-manager, utils, main +ci/process-submissions.sh # Shared submission validator used by GitHub + # Actions, GitLab CI and Bitbucket Pipelines +css/ # main.css + components.css +templates/ # Static page templates (about, faq, errors, ...) + # + user CI templates: user-refactorfirst-workflow.yml (GitHub), + # user-refactorfirst-gitlab-ci.yml, user-refactorfirst-bitbucket-pipeline.yml + # + workflow-sample-{github,gitlab,bitbucket}.html shown on the + # Getting Started page based on the detected hosting environment +.gitlab-ci.yml # Deploys this site to GitLab Pages +bitbucket-pipelines.yml # Validates this site's files on Bitbucket +assets/ # Fallback Mustache template, logo, Sentry config +tests/ # unit/ (Bun), integration/ (Bun), e2e/ (Playwright) +.github/workflows/ # add-repository.yml, redeploy.yml, test.yml +``` + +--- + +## Deploying to GitHub Pages (organization or personal account) + +### 1. Fork or create the repository + +- **Personal account**: create a repository named `.github.io`. +- **Organization**: create a repository named `.github.io` in the org, + or any project repository if you want a project page + (`https://.github.io//`). + +### 2. Push this project's files + +Everything in this directory is the site — push it to the default branch: + +```bash +git init +git add . +git commit -m "RefactorFirst Pages site" +git remote add origin https://github.com//.git +git push -u origin main +``` + +### 3. Enable GitHub Pages + +Go to **Settings → Pages**: + +- **Source**: *GitHub Actions* (required for the scheduled redeployment workflow in + `.github/workflows/redeploy.yml`). +- Alternatively choose *Deploy from a branch* (main, `/ (root)`) if you don't need + scheduled redeploys — the site is fully static. + +The included `redeploy.yml` workflow redeploys every 10 minutes, but only when +`repositories.txt` changed in the last 15 minutes. The `add-repository.yml` workflow +reacts to newly opened submission issues, validates the submitter and commits new +entries to `repositories.txt`. + +### 4. Configure the submission target + +"Add Your Repo" submissions are pre-filled issues created in the listing +repository. Point the site at your repository via the meta tag in `index.html`: + +```html + +``` + +No GitHub Apps, OAuth apps, client IDs or secrets are needed — identity is +captured by GitHub as the issue author. See +[How repository submission works](#how-repository-submission-works). + +### 5. (Optional) Custom domain + +Add a `CNAME` file containing your domain (e.g. `reports.example.com`), configure +your DNS (CNAME record pointing to `.github.io`), and enable **Enforce HTTPS** +in Settings → Pages. + +--- + +## Deploying to GitHub Enterprise Server + +The application is fully static, so it works on any GitHub Enterprise Server (GHES) +instance with Pages enabled. + +### 1. Enable Pages on the appliance + +A site admin must enable GitHub Pages for the instance +(**Management Console → Pages → Enable**), then create the repository +(`.` or a project repo) and push this project as described above. + +### 2. Point the app at your enterprise endpoints + +Raw content and API calls default to `github.com` / `raw.githubusercontent.com` +/ `api.github.com`. For a self-hosted instance, update the URL builders: + +- `js/fetcher.js` — the `github` entry of `PLATFORM_BUILDERS` should build + URLs like + `https://github.example.com/raw////.refactorfirst/refactor-first.json`. +- `js/repo-submission.js` — `repositoryInfoUrl()` and `buildSubmissionIssueUrl()` + github branches must target your instance (`https://github.example.com/...`). +- `ci/process-submissions.sh` — set `GH_API` (and raw URL handling) to your + instance endpoints (`GH_HOST` is respected by `gh`-style tooling). +- `index.html` — extend the CSP `connect-src` directive with your instance + host and set `submission-target` to your listing repository. + +(Tip: keep these behind a single `config` module such as `enterprise-config.json` +if you need to support multiple deployments from one codebase.) + +### 3. Workflows + +`add-repository.yml` and `redeploy.yml` use the built-in `GITHUB_TOKEN`; +`ci/process-submissions.sh` needs only `curl` and `jq` (preinstalled on +Actions runners). If your instance lacks internet access, ensure raw/API +endpoints are reachable from the browser — reports and submission pre-checks +are **client-side**, so *end users'* browsers (not the server) must be able to +reach your GHES host. + +--- + +## Deploying to Bitbucket + +A Bitbucket deployment lists Bitbucket-hosted repositories: report fetching and +submission use `bitbucket.org/.../raw/...` and the Bitbucket REST API. + +### 1. Create the site repository + +- **Personal account**: create a repository named `.bitbucket.io`. +- **Workspace/team**: static sites are per-workspace: `.bitbucket.io`. + +### 2. Push the files + +```bash +git init +git add . +git commit -m "RefactorFirst Pages site" +git remote add origin git@bitbucket.org:/.bitbucket.io.git +git push -u origin main +``` + +The site goes live at `https://.bitbucket.io`. Note that Bitbucket static +sites serve all paths from one `index.html`-style tree — since this app routes +client-side from a single `index.html`, request every path as `/index.html`-relative +links, or accept that deep links (e.g. `/user/repo`) return 404 unless Bitbucket +serves `index.html` for unknown paths (it does not by default — consider using the +query-style links or hosting deep routes via a redirect service). + +### 3. Enable submission processing + +The site is detected as `bitbucket` from the `.bitbucket.io` +hostname; set `submission-target` in `index.html` to +`/.bitbucket.io`, enable the issue tracker on that +repository and extend the CSP `connect-src` with `https://api.bitbucket.org` +and `https://bitbucket.org`. + +Bitbucket has no issue-triggered pipelines, so submissions are processed by the +custom `process-submissions` pipeline in `bitbucket-pipelines.yml`: + +1. In the repository go to **Pipelines → Schedules** and schedule + `custom: process-submissions` (e.g. every 10 minutes). +2. Create a workspace **OAuth consumer** with `issues:write` and + `repositories:write` scopes and store its credentials as the **secured** + repository variables `BITBUCKET_CLIENT_ID` / `BITBUCKET_CLIENT_SECRET` + (server-side CI secrets only — the site itself never sees them). + +The pipeline polls open issues titled `Add repository: owner/repo`, checks the +author has `write`/`admin` permission on the repository, verifies the report +file exists, commits `repositories.txt` and closes the issue with the outcome. +The manual `sort-repos` pipeline from the shipped `bitbucket-pipelines.yml` +also normalizes the listing on demand. + +> **Users generating reports on Bitbucket**: point them at +> `templates/user-refactorfirst-bitbucket-pipeline.yml` — a copy-paste pipeline that +> runs `mvn refactorfirst:jsonReport` and commits `.refactorfirst/refactor-first.json` +> on every push to `main`/`master`. + +--- + +## Deploying to GitLab + +### 1. Create the project + +- **Personal account**: create a project named `.gitlab.io`. +- **Group**: create a project named `.gitlab.io`, or any project for a + project page at `https://.gitlab.io//`. + +### 2. Add a Pages pipeline + +This repository already ships a ready-to-use `.gitlab-ci.yml` (validates the site and +deploys `public/` via a `pages` job, including a `404.html` copy of `index.html` for +client-side routing). It looks like this: + +```yaml +pages: + stage: deploy + script: + - mkdir -p public + # Publish everything except VCS metadata, tests and tooling + - | + for f in index.html repositories.txt css js templates assets; do + cp -r "$f" public/ + done + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +### 3. Push + +```bash +git init +git add . +git commit -m "RefactorFirst Pages site" +git remote add origin https://gitlab.com//.git +git push -u origin main +``` + +GitLab Pages deploys from the `pages` job and serves +`https://.gitlab.io//`. + +### 4. GitLab-specific considerations + +- **Client-side routing**: GitLab Pages serves `404.html` for unknown paths; keep a + copy of `index.html` as `public/404.html` in the pipeline (`cp index.html public/404.html`) + so deep links like `/user/repo` load the app. +- **Submission processing**: set `submission-target` in `index.html` to your + `/`, extend the CSP `connect-src` with your GitLab base + (`https://gitlab.com` or your self-managed host), and create a pipeline + schedule (**CI/CD → Schedules**, e.g. every 10 minutes) — GitLab has no + issue-triggered pipelines, so the `process-submissions` job in + `.gitlab-ci.yml` polls open submission issues. For **self-managed GitLab** + also add ``. + The job uses `CI_JOB_TOKEN` by default; if your GitLab version/instance + restricts its API scope, set a masked `GITLAB_TOKEN` CI variable with a + project access token (`api` scope) instead. +- **Listing redeploys**: schedule another pipeline (or extend the same one) to + re-run `pages` when `repositories.txt` changed. +- **Custom domains**: set up under **Settings → Pages** with automatic Let's Encrypt + certificates. Note: the hostname-based environment detection only recognises + `*.gitlab.io`; on a custom domain pass `hostEnvironment: 'gitlab'` to + `createApp()` in `js/main.js`. + +> **Users generating reports on GitLab**: point them at +> `templates/user-refactorfirst-gitlab-ci.yml` — a copy-paste pipeline that runs +> `mvn refactorfirst:jsonReport` on the default branch and commits +> `.refactorfirst/refactor-first.json` back using the built-in `CI_JOB_TOKEN`. + +--- + +## How repository submission works + +No OAuth app, client ID, token or secret is involved on the client side — forks +need **zero auth setup**. The flow on every supported platform: + +1. The user fills in *owner* and *repository* on `/add-repo` (no login on this + site — identity is captured later, by the platform itself). +2. The app verifies client-side (unauthenticated) that the repository exists + and publishes `.refactorfirst/refactor-first.json` on its `main`, default or + `master` branch, then opens a **pre-filled issue** + (`Add repository: owner/repo`) in the listing project in a new tab. +3. The user — now on GitHub/GitLab/Bitbucket, logged in there — creates the + issue. The platform-verified **issue author** is the captured submitter + identity; it cannot be spoofed. +4. The platform's CI (GitHub Actions `add-repository.yml`, GitLab scheduled + `process-submissions` pipeline, Bitbucket scheduled `process-submissions` + pipeline; all driving `ci/process-submissions.sh`) validates: + - the issue title matches the exact submission format, + - the issue author has write access to the submitted repository + (GitHub collaborator permission, GitLab Developer+ membership, Bitbucket + `write`/`admin` permission), + - the report file exists and the repository is not already listed. +5. Valid submissions are committed to `repositories.txt` and the issue receives + a comment with the outcome and is closed; rejected submissions are commented + with the reason and closed. + +| | GitHub | GitLab | Bitbucket | +|---|---|---|---| +| Trigger | instant (`issues: opened` event) | scheduled pipeline (10 min) | scheduled pipeline (10 min) | +| CI credentials | built-in `GITHUB_TOKEN` | `CI_JOB_TOKEN` (or `GITLAB_TOKEN` project token) | workspace OAuth consumer (secured variables) | +| Access check | collaborator `permission` | member `access_level >= 30` (Developer) | permissions `write`/`admin` | + +Limitations: only **public** repositories can be submitted (the report checks +are unauthenticated), and each deployment serves exactly one platform — the +one it is hosted on. + +--- + +## Making Changes (Developer Guide) + +### Prerequisites + +- [Bun](https://bun.sh) ≥ 1.0 (unit/integration tests) — or run it via `npx bun` +- Node.js ≥ 18 (Playwright E2E tests only) +- Python 3 (local static server) + +### Setup + +```bash +bun install # install devDependencies (mustache, jsdom, playwright, eslint) +``` + +### Run locally + +```bash +python3 -m http.server 8000 # then open http://localhost:8000 +``` +When making changes to the site locally, you may need to clear the cache to see your changes. Alternatively, you can disable caching in your browser. + +### Test-driven development (mandatory) + +This project follows strict TDD — write the failing test **before** production code: + +1. Write a failing test in `tests/unit/` (pure module logic) or + `tests/integration/` (DOM + routing flows). +2. Run `bun test tests/unit tests/integration` and watch it fail. +3. Write the minimal implementation in `js/` to make it pass. +4. Refactor while keeping tests green. + +```bash +bun test tests/unit tests/integration # unit + integration (jsdom) +bun test --watch tests/unit # watch mode +bun test --coverage tests/unit tests/integration +``` + +E2E tests run under Node.js/Playwright with a live local server: + +```bash +npx playwright install # one-time: download browsers +npx playwright test # full E2E suite (chromium, firefox, webkit) +npx playwright test --ui # interactive mode +``` + +### Lint + +```bash +npx eslint js/**/*.js tests/unit/**/*.js tests/integration/**/*.js +``` + +### Environment-aware documentation + +The **Getting Started** page shows only the CI sample matching the hosting +environment, detected from the hostname (`*.github.io` → GitHub Actions, +`*.gitlab.io` → GitLab CI, `*.bitbucket.io` → Bitbucket Pipelines; anything else +defaults to GitHub). The samples live in +`templates/workflow-sample-{github,gitlab,bitbucket}.html`, and detection lives in +`detectHostingEnvironment()` in `js/utils.js`. To override detection (e.g. a custom +domain hosting the GitLab variant), pass `hostEnvironment: 'gitlab'` to +`createApp()` in `js/main.js`. + +### Where things live + +| Change | Files | +|---|---| +| URL routes | `js/router.js` (+ `tests/unit/router-ext.test.js`) | +| Raw fetching / branch fallback (platform-aware) | `js/fetcher.js` | +| Mustache rendering | `js/renderer.js`, `assets/refactor-first-report.mustache` (port of the RefactorFirst viewer template) | +| Interactive report widgets | `js/report-view.js` (+ CDN libs declared in `index.html`: Chart.js, sigma/graphology, graphlib-dot, svg-pan-zoom, 3d-force-graph, vizdom WASM) | +| Search / type-ahead | `js/search.js` | +| Submission flow | `js/repo-submission.js`, `js/main.js` (`renderAddRepo`) | +| Submission validation (CI) | `ci/process-submissions.sh`, `.github/workflows/add-repository.yml`, `.gitlab-ci.yml`, `bitbucket-pipelines.yml` | +| Error pages | `js/error-handler.js`, `templates/error-*.html` | +| Page content | `templates/*.html` | +| Styling | `css/main.css`, `css/components.css` | +| Listing data | `repositories.txt` (one `user/repo` per line) | +| Scheduled redeploy | `.github/workflows/redeploy.yml` | + +### CI/CD + +`.github/workflows/test.yml` runs Bun unit/integration tests and the Playwright suite +on every push and pull request. Keep it green before merging. + +--- + +## Testing + +- **Unit** (`tests/unit/`): router, fetcher (incl. branch fallback, retry and + per-platform URL construction), renderer, report-view (charts/graphs/popups), + search, repo-submission (incl. report-file existence check and per-platform + issue URLs), error-handler, rate-limiter, cache-manager, utils. +- **Integration** (`tests/integration/`): search flow, submission flow (missing + report, unknown repo, per-platform issue redirect), report rendering. +- **E2E** (`tests/e2e/`): user journeys (incl. the submission → pre-filled + issue hand-off), cross-browser smoke tests, mobile responsiveness + (hamburger menu, single-column grid). + +Coverage target: 80%+ on core modules. Current suite: 165 tests. diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..100daf7 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/refactor-first-report.mustache b/assets/refactor-first-report.mustache new file mode 100644 index 0000000..799f595 --- /dev/null +++ b/assets/refactor-first-report.mustache @@ -0,0 +1,434 @@ + + + +
+
+ + +
+

+ RefactorFirst Report for + {{project.name}} {{project.version}} +

+ + +
+

Show RefactorFirst some ❤️

+ Star + Fork + Watch + Issue + Sponsor +
+ + +
+ + {{#project.analysisFailed}} +
+ Analysis incomplete: RefactorFirst could not finish scanning this project. + The absence of findings below does not mean the project is clean. +
+ {{/project.analysisFailed}} + + +

Class Map

+ + + + + +
+ Red lines represent relationships to remove.
+ Red nodes represent classes to remove.
+ Zoom in / out with your mouse wheel and click/move to drag the image.
+ Number of classes: {{classMap.classCount}} Number of relationships: {{classMap.relationshipCount}}
+
+ + {{#classMap.dotThresholdExceeded}} +
SVG is too big to render quickly
+ {{/classMap.dotThresholdExceeded}} + {{^classMap.dotThresholdExceeded}} +
+ {{/classMap.dotThresholdExceeded}} + +
+
+
+
+ + + {{#classRelationshipsToRemove.hasRelationships}} + +

Refactor Starting with Priority 1

+
+ Current Class Cycle Count: {{classRelationshipsToRemove.cycleCount}}
+ Number of Class Relationships to Remove: {{classRelationshipsToRemove.relationshipsToRemoveCount}}
+ Classes with * should be broken apart
+ Removing class relationships below will eliminate class cycles +
+
+ + + + + + + + + + + + + {{#classRelationshipsToRemove.relationships}} + + + + + + + + + {{/classRelationshipsToRemove.relationships}} + +
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
{{renderedLabel}}{{priority}}{{cycleCount}}{{effortRank}}{{#alsoRemovesPackageRelationship}} + true{{/alsoRemovesPackageRelationship}}{{^alsoRemovesPackageRelationship}} + false{{/alsoRemovesPackageRelationship}}{{packageCycleCount}}
+
+ {{/classRelationshipsToRemove.hasRelationships}} + +
+
+
+
+ + + {{#packageMap.hasEdges}} +

Package Map

+ + + + + +
+ Red lines represent relationships to remove.
+ Red nodes represent packages to remove.
+ Zoom in / out with your mouse wheel and click/move to drag the image.
+ Number of packages: {{packageMap.classCount}} Number of relationships: {{packageMap.relationshipCount}}
+
+ + {{#packageMap.dotThresholdExceeded}} +
SVG is too big to render quickly
+ {{/packageMap.dotThresholdExceeded}} + {{^packageMap.dotThresholdExceeded}} +
+ {{/packageMap.dotThresholdExceeded}} + {{/packageMap.hasEdges}} + +
+
+
+
+ + + {{#packageRelationshipsToRemove.hasRelationships}} + +

Refactor Starting with Priority 1

+
+ Current Package Cycle Count: {{packageRelationshipsToRemove.cycleCount}}
+ Number of Package Relationships to Remove: {{packageRelationshipsToRemove.relationshipsToRemoveCount}}
+ Packages and classes with * should be broken apart
+ Removing package relationships below will eliminate package cycles +
+
+ + + + + + + + + + + + {{#packageRelationshipsToRemove.relationships}} + + + + + + + + {{/packageRelationshipsToRemove.relationships}} + +
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
{{{renderedLabel}}}{{priority}}{{cycleCount}}{{effortRank}}{{#classRelationshipsToBreakPackage}} + {{{.}}}
{{/classRelationshipsToBreakPackage}}
+
+ {{/packageRelationshipsToRemove.hasRelationships}} + + + {{#disharmonies}} +
+
+
+
+ +
+ + + + + + + + + +
Problem:{{problem}}
Solution:{{{solution}}}
+
+ + +
+
+ +
+
+ +
+ +

{{title}} by the numbers: (Refactor Starting with Priority 1)

+
+ + + + {{#table.headers}} + + {{/table.headers}} + + + + {{#table.rows}} + + {{#cells}} + + {{/cells}} + + {{/table.rows}} + +
{{.}}
{{{content}}}
+
+ {{/disharmonies}} + +
+
+
+
+ + + {{#classCycles.hasCycles}} + +

Class Cycles by the numbers:

+
+ + + + + + + + + + + {{#classCycles.summary}} + + + + + + + {{/classCycles.summary}} + +
Cycle NamePriorityClass CountRelationship Count
{{cycleName}}{{priority}}{{classCount}}{{relationshipCount}}
+
+ + {{#classCycles.largestCycle.hasCycleMap}} +

Largest Class Cycle : {{classCycles.largestCycle.cycleName}}

+

Limiting number of cycles displayed to 1 to keep page load time fast

+ + + + + + +
+ + +
+ + + + + + + + + {{#classCycles.largestCycle.breakdown}} + + + + + {{/classCycles.largestCycle.breakdown}} + +
ClassesRelationships
{{{className}}}{{{edgesHtml}}}
+
+ {{/classCycles.largestCycle.hasCycleMap}} + {{/classCycles.hasCycles}} + +
+
+
+
+ Last Published: {{project.scanTimestamp}} +
+
diff --git a/assets/sentry-config.js b/assets/sentry-config.js new file mode 100644 index 0000000..6d4a852 --- /dev/null +++ b/assets/sentry-config.js @@ -0,0 +1,17 @@ +// Optional Sentry error tracking. Loaded lazily by index.html when a +// DSN is configured. Keep the DSN empty to disable error reporting. +export const SENTRY_DSN = ''; + +export function initSentry() { + if (!SENTRY_DSN || typeof window === 'undefined') return; + import('https://cdn.jsdelivr.net/npm/@sentry/browser@7/+esm') + .then(Sentry => { + Sentry.init({ + dsn: SENTRY_DSN, + tracesSampleRate: 0.1, + replaysSessionSampleRate: 0 + }); + window.Sentry = Sentry; + }) + .catch(() => { /* Sentry unavailable - errors fall back to console */ }); +} diff --git a/assets/vendor/mustache.mjs b/assets/vendor/mustache.mjs new file mode 100644 index 0000000..ed0cd6d --- /dev/null +++ b/assets/vendor/mustache.mjs @@ -0,0 +1,764 @@ +/*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ + +var objectToString = Object.prototype.toString; +var isArray = Array.isArray || function isArrayPolyfill (object) { + return objectToString.call(object) === '[object Array]'; +}; + +function isFunction (object) { + return typeof object === 'function'; +} + +/** + * More correct typeof string handling array + * which normally returns typeof 'object' + */ +function typeStr (obj) { + return isArray(obj) ? 'array' : typeof obj; +} + +function escapeRegExp (string) { + return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); +} + +/** + * Null safe way of checking whether or not an object, + * including its prototype, has a given property + */ +function hasProperty (obj, propName) { + return obj != null && typeof obj === 'object' && (propName in obj); +} + +/** + * Safe way of detecting whether or not the given thing is a primitive and + * whether it has the given property + */ +function primitiveHasOwnProperty (primitive, propName) { + return ( + primitive != null + && typeof primitive !== 'object' + && primitive.hasOwnProperty + && primitive.hasOwnProperty(propName) + ); +} + +// Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 +// See https://github.com/janl/mustache.js/issues/189 +var regExpTest = RegExp.prototype.test; +function testRegExp (re, string) { + return regExpTest.call(re, string); +} + +var nonSpaceRe = /\S/; +function isWhitespace (string) { + return !testRegExp(nonSpaceRe, string); +} + +var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' +}; + +function escapeHtml (string) { + return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); +} + +var whiteRe = /\s*/; +var spaceRe = /\s+/; +var equalsRe = /\s*=/; +var curlyRe = /\s*\}/; +var tagRe = /#|\^|\/|>|\{|&|=|!/; + +/** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices, + * respectively, of the token in the original template. + * + * Tokens that are the root node of a subtree contain two more elements: 1) an + * array of tokens in the subtree and 2) the index in the original template at + * which the closing tag for that section begins. + * + * Tokens for partials also contain two more elements: 1) a string value of + * indendation prior to that tag and 2) the index of that tag on that line - + * eg a value of 2 indicates the partial is the third tag on this line. + */ +function parseTemplate (template, tags) { + if (!template) + return []; + var lineHasNonSpace = false; + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + var indentation = ''; // Tracks indentation for tags that use it + var tagIndex = 0; // Stores a count of number of tags encountered on a line + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace () { + if (hasTag && !nonSpace) { + while (spaces.length) + delete tokens[spaces.pop()]; + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var openingTagRe, closingTagRe, closingCurlyRe; + function compileTags (tagsToCompile) { + if (typeof tagsToCompile === 'string') + tagsToCompile = tagsToCompile.split(spaceRe, 2); + + if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) + throw new Error('Invalid tags: ' + tagsToCompile); + + openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); + closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); + closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); + } + + compileTags(tags || mustache.tags); + + var scanner = new Scanner(template); + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(openingTagRe); + + if (value) { + for (var i = 0, valueLength = value.length; i < valueLength; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + indentation += chr; + } else { + nonSpace = true; + lineHasNonSpace = true; + indentation += ' '; + } + + tokens.push([ 'text', chr, start, start + 1 ]); + start += 1; + + // Check for whitespace on the current line. + if (chr === '\n') { + stripSpace(); + indentation = ''; + tagIndex = 0; + lineHasNonSpace = false; + } + } + } + + // Match the opening tag. + if (!scanner.scan(openingTagRe)) + break; + + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(equalsRe); + scanner.scan(equalsRe); + scanner.scanUntil(closingTagRe); + } else if (type === '{') { + value = scanner.scanUntil(closingCurlyRe); + scanner.scan(curlyRe); + scanner.scanUntil(closingTagRe); + type = '&'; + } else { + value = scanner.scanUntil(closingTagRe); + } + + // Match the closing tag. + if (!scanner.scan(closingTagRe)) + throw new Error('Unclosed tag at ' + scanner.pos); + + if (type == '>') { + token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ]; + } else { + token = [ type, value, start, scanner.pos ]; + } + tagIndex++; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) + throw new Error('Unopened section "' + value + '" at ' + start); + + if (openSection[1] !== value) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + compileTags(value); + } + } + + stripSpace(); + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + + if (openSection) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + + return nestTokens(squashTokens(tokens)); +} + +/** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ +function squashTokens (tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + squashedTokens.push(token); + lastToken = token; + } + } + } + + return squashedTokens; +} + +/** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ +function nestTokens (tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token, section; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + collector.push(token); + sections.push(token); + collector = token[4] = []; + break; + case '/': + section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; +} + +/** + * A simple string scanner that is used by the template parser to find + * tokens in template strings. + */ +function Scanner (string) { + this.string = string; + this.tail = string; + this.pos = 0; +} + +/** + * Returns `true` if the tail is empty (end of string). + */ +Scanner.prototype.eos = function eos () { + return this.tail === ''; +}; + +/** + * Tries to match the given regular expression at the current position. + * Returns the matched text if it can match, the empty string otherwise. + */ +Scanner.prototype.scan = function scan (re) { + var match = this.tail.match(re); + + if (!match || match.index !== 0) + return ''; + + var string = match[0]; + + this.tail = this.tail.substring(string.length); + this.pos += string.length; + + return string; +}; + +/** + * Skips all text until the given regular expression can be matched. Returns + * the skipped string, which is the entire tail if no match can be made. + */ +Scanner.prototype.scanUntil = function scanUntil (re) { + var index = this.tail.search(re), match; + + switch (index) { + case -1: + match = this.tail; + this.tail = ''; + break; + case 0: + match = ''; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + + this.pos += match.length; + + return match; +}; + +/** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ +function Context (view, parentContext) { + this.view = view; + this.cache = { '.': this.view }; + this.parent = parentContext; +} + +/** + * Creates a new context using the given view with this context + * as the parent. + */ +Context.prototype.push = function push (view) { + return new Context(view, this); +}; + +/** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ +Context.prototype.lookup = function lookup (name) { + var cache = this.cache; + + var value; + if (cache.hasOwnProperty(name)) { + value = cache[name]; + } else { + var context = this, intermediateValue, names, index, lookupHit = false; + + while (context) { + if (name.indexOf('.') > 0) { + intermediateValue = context.view; + names = name.split('.'); + index = 0; + + /** + * Using the dot notion path in `name`, we descend through the + * nested objects. + * + * To be certain that the lookup has been successful, we have to + * check if the last object in the path actually has the property + * we are looking for. We store the result in `lookupHit`. + * + * This is specially necessary for when the value has been set to + * `undefined` and we want to avoid looking up parent contexts. + * + * In the case where dot notation is used, we consider the lookup + * to be successful even if the last "object" in the path is + * not actually an object but a primitive (e.g., a string, or an + * integer), because it is sometimes useful to access a property + * of an autoboxed primitive, such as the length of a string. + **/ + while (intermediateValue != null && index < names.length) { + if (index === names.length - 1) + lookupHit = ( + hasProperty(intermediateValue, names[index]) + || primitiveHasOwnProperty(intermediateValue, names[index]) + ); + + intermediateValue = intermediateValue[names[index++]]; + } + } else { + intermediateValue = context.view[name]; + + /** + * Only checking against `hasProperty`, which always returns `false` if + * `context.view` is not an object. Deliberately omitting the check + * against `primitiveHasOwnProperty` if dot notation is not used. + * + * Consider this example: + * ``` + * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"}) + * ``` + * + * If we were to check also against `primitiveHasOwnProperty`, as we do + * in the dot notation case, then render call would return: + * + * "The length of a football field is 9." + * + * rather than the expected: + * + * "The length of a football field is 100 yards." + **/ + lookupHit = hasProperty(context.view, name); + } + + if (lookupHit) { + value = intermediateValue; + break; + } + + context = context.parent; + } + + cache[name] = value; + } + + if (isFunction(value)) + value = value.call(this.view); + + return value; +}; + +/** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates to + * avoid the need to parse the same template twice. + */ +function Writer () { + this.templateCache = { + _cache: {}, + set: function set (key, value) { + this._cache[key] = value; + }, + get: function get (key) { + return this._cache[key]; + }, + clear: function clear () { + this._cache = {}; + } + }; +} + +/** + * Clears all cached templates in this writer. + */ +Writer.prototype.clearCache = function clearCache () { + if (typeof this.templateCache !== 'undefined') { + this.templateCache.clear(); + } +}; + +/** + * Parses and caches the given `template` according to the given `tags` or + * `mustache.tags` if `tags` is omitted, and returns the array of tokens + * that is generated from the parse. + */ +Writer.prototype.parse = function parse (template, tags) { + var cache = this.templateCache; + var cacheKey = template + ':' + (tags || mustache.tags).join(':'); + var isCacheEnabled = typeof cache !== 'undefined'; + var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined; + + if (tokens == undefined) { + tokens = parseTemplate(template, tags); + isCacheEnabled && cache.set(cacheKey, tokens); + } + return tokens; +}; + +/** + * High-level method that is used to render the given `template` with + * the given `view`. + * + * The optional `partials` argument may be an object that contains the + * names and templates of partials that are used in the template. It may + * also be a function that is used to load partial templates on the fly + * that takes a single argument: the name of the partial. + * + * If the optional `config` argument is given here, then it should be an + * object with a `tags` attribute or an `escape` attribute or both. + * If an array is passed, then it will be interpreted the same way as + * a `tags` attribute on a `config` object. + * + * The `tags` attribute of a `config` object must be an array with two + * string values: the opening and closing tags used in the template (e.g. + * [ "<%", "%>" ]). The default is to mustache.tags. + * + * The `escape` attribute of a `config` object must be a function which + * accepts a string as input and outputs a safely escaped string. + * If an `escape` function is not provided, then an HTML-safe string + * escaping function is used as the default. + */ +Writer.prototype.render = function render (template, view, partials, config) { + var tags = this.getConfigTags(config); + var tokens = this.parse(template, tags); + var context = (view instanceof Context) ? view : new Context(view, undefined); + return this.renderTokens(tokens, context, partials, template, config); +}; + +/** + * Low-level method that renders the given array of `tokens` using + * the given `context` and `partials`. + * + * Note: The `originalTemplate` is only ever used to extract the portion + * of the original template that was contained in a higher-order section. + * If the template doesn't use higher-order sections, this argument may + * be omitted. + */ +Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) { + var buffer = ''; + + var token, symbol, value; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + value = undefined; + token = tokens[i]; + symbol = token[0]; + + if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config); + else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config); + else if (symbol === '>') value = this.renderPartial(token, context, partials, config); + else if (symbol === '&') value = this.unescapedValue(token, context); + else if (symbol === 'name') value = this.escapedValue(token, context, config); + else if (symbol === 'text') value = this.rawValue(token); + + if (value !== undefined) + buffer += value; + } + + return buffer; +}; + +Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) { + var self = this; + var buffer = ''; + var value = context.lookup(token[1]); + + // This function is used to render an arbitrary template + // in the current context by higher-order sections. + function subRender (template) { + return self.render(template, context, partials, config); + } + + if (!value) return; + + if (isArray(value)) { + for (var j = 0, valueLength = value.length; j < valueLength; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config); + } + } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { + buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config); + } else if (isFunction(value)) { + if (typeof originalTemplate !== 'string') + throw new Error('Cannot use higher-order sections without the original template'); + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender); + + if (value != null) + buffer += value; + } else { + buffer += this.renderTokens(token[4], context, partials, originalTemplate, config); + } + return buffer; +}; + +Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) { + var value = context.lookup(token[1]); + + // Use JavaScript's definition of falsy. Include empty arrays. + // See https://github.com/janl/mustache.js/issues/186 + if (!value || (isArray(value) && value.length === 0)) + return this.renderTokens(token[4], context, partials, originalTemplate, config); +}; + +Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) { + var filteredIndentation = indentation.replace(/[^ \t]/g, ''); + var partialByNl = partial.split('\n'); + for (var i = 0; i < partialByNl.length; i++) { + if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) { + partialByNl[i] = filteredIndentation + partialByNl[i]; + } + } + return partialByNl.join('\n'); +}; + +Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) { + if (!partials) return; + var tags = this.getConfigTags(config); + + var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; + if (value != null) { + var lineHasNonSpace = token[6]; + var tagIndex = token[5]; + var indentation = token[4]; + var indentedValue = value; + if (tagIndex == 0 && indentation) { + indentedValue = this.indentPartial(value, indentation, lineHasNonSpace); + } + var tokens = this.parse(indentedValue, tags); + return this.renderTokens(tokens, context, partials, indentedValue, config); + } +}; + +Writer.prototype.unescapedValue = function unescapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return value; +}; + +Writer.prototype.escapedValue = function escapedValue (token, context, config) { + var escape = this.getConfigEscape(config) || mustache.escape; + var value = context.lookup(token[1]); + if (value != null) + return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value); +}; + +Writer.prototype.rawValue = function rawValue (token) { + return token[1]; +}; + +Writer.prototype.getConfigTags = function getConfigTags (config) { + if (isArray(config)) { + return config; + } + else if (config && typeof config === 'object') { + return config.tags; + } + else { + return undefined; + } +}; + +Writer.prototype.getConfigEscape = function getConfigEscape (config) { + if (config && typeof config === 'object' && !isArray(config)) { + return config.escape; + } + else { + return undefined; + } +}; + +var mustache = { + name: 'mustache.js', + version: '4.2.0', + tags: [ '{{', '}}' ], + clearCache: undefined, + escape: undefined, + parse: undefined, + render: undefined, + Scanner: undefined, + Context: undefined, + Writer: undefined, + /** + * Allows a user to override the default caching strategy, by providing an + * object with set, get and clear methods. This can also be used to disable + * the cache by setting it to the literal `undefined`. + */ + set templateCache (cache) { + defaultWriter.templateCache = cache; + }, + /** + * Gets the default or overridden caching object from the default writer. + */ + get templateCache () { + return defaultWriter.templateCache; + } +}; + +// All high-level mustache.* functions use this writer. +var defaultWriter = new Writer(); + +/** + * Clears all cached templates in the default writer. + */ +mustache.clearCache = function clearCache () { + return defaultWriter.clearCache(); +}; + +/** + * Parses and caches the given template in the default writer and returns the + * array of tokens it contains. Doing this ahead of time avoids the need to + * parse templates on the fly as they are rendered. + */ +mustache.parse = function parse (template, tags) { + return defaultWriter.parse(template, tags); +}; + +/** + * Renders the `template` with the given `view`, `partials`, and `config` + * using the default writer. + */ +mustache.render = function render (template, view, partials, config) { + if (typeof template !== 'string') { + throw new TypeError('Invalid template! Template should be a "string" ' + + 'but "' + typeStr(template) + '" was given as the first ' + + 'argument for mustache#render(template, view, partials)'); + } + + return defaultWriter.render(template, view, partials, config); +}; + +// Export the escaping function so that the user may override it. +// See https://github.com/janl/mustache.js/issues/244 +mustache.escape = escapeHtml; + +// Export these mainly for testing, but also for advanced usage. +mustache.Scanner = Scanner; +mustache.Context = Context; +mustache.Writer = Writer; + +export default mustache; diff --git a/assets/vendor/purify.es.mjs b/assets/vendor/purify.es.mjs new file mode 100644 index 0000000..a27d776 --- /dev/null +++ b/assets/vendor/purify.es.mjs @@ -0,0 +1,2722 @@ +/*! @license DOMPurify 3.4.15 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.15/LICENSE */ + +function _arrayLikeToArray(r, a) { + (null == a || a > r.length) && (a = r.length); + for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; + return n; +} +function _arrayWithHoles(r) { + if (Array.isArray(r)) return r; +} +function _iterableToArrayLimit(r, l) { + var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; + if (null != t) { + var e, + n, + i, + u, + a = [], + f = true, + o = false; + try { + if (i = (t = t.call(r)).next, 0 === l) ; else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); + } catch (r) { + o = true, n = r; + } finally { + try { + if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; + } finally { + if (o) throw n; + } + } + return a; + } +} +function _nonIterableRest() { + throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); +} +function _slicedToArray(r, e) { + return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); +} +function _unsupportedIterableToArray(r, a) { + if (r) { + if ("string" == typeof r) return _arrayLikeToArray(r, a); + var t = {}.toString.call(r).slice(8, -1); + return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; + } +} + +const entries = Object.entries, + setPrototypeOf = Object.setPrototypeOf, + isFrozen = Object.isFrozen, + getPrototypeOf = Object.getPrototypeOf, + getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +let freeze = Object.freeze, + seal = Object.seal, + create = Object.create; // eslint-disable-line import/no-mutable-exports +let _ref = typeof Reflect !== 'undefined' && Reflect, + apply = _ref.apply, + construct = _ref.construct; +if (!freeze) { + freeze = function freeze(x) { + return x; + }; +} +if (!seal) { + seal = function seal(x) { + return x; + }; +} +if (!apply) { + apply = function apply(func, thisArg) { + for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { + args[_key - 2] = arguments[_key]; + } + return func.apply(thisArg, args); + }; +} +if (!construct) { + construct = function construct(Func) { + for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + args[_key2 - 1] = arguments[_key2]; + } + return new Func(...args); + }; +} +const arrayForEach = unapply(Array.prototype.forEach); +const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf); +const arrayPop = unapply(Array.prototype.pop); +const arrayPush = unapply(Array.prototype.push); +const arraySplice = unapply(Array.prototype.splice); +const arrayIsArray = Array.isArray; +const stringToLowerCase = unapply(String.prototype.toLowerCase); +const stringToString = unapply(String.prototype.toString); +const stringMatch = unapply(String.prototype.match); +const stringReplace = unapply(String.prototype.replace); +const stringIndexOf = unapply(String.prototype.indexOf); +const stringTrim = unapply(String.prototype.trim); +const numberToString = unapply(Number.prototype.toString); +const booleanToString = unapply(Boolean.prototype.toString); +const bigintToString = typeof BigInt === 'undefined' ? null : unapply(BigInt.prototype.toString); +const symbolToString = typeof Symbol === 'undefined' ? null : unapply(Symbol.prototype.toString); +const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); +const objectToString = unapply(Object.prototype.toString); +const regExpTest = unapply(RegExp.prototype.test); +const typeErrorCreate = unconstruct(TypeError); +/** + * Creates a new function that calls the given function with a specified thisArg and arguments. + * + * @param func - The function to be wrapped and called. + * @returns A new function that calls the given function with a specified thisArg and arguments. + */ +function unapply(func) { + return function (thisArg) { + if (thisArg instanceof RegExp) { + thisArg.lastIndex = 0; + } + for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { + args[_key3 - 1] = arguments[_key3]; + } + return apply(func, thisArg, args); + }; +} +/** + * Creates a new function that constructs an instance of the given constructor function with the provided arguments. + * + * @param func - The constructor function to be wrapped and called. + * @returns A new function that constructs an instance of the given constructor function with the provided arguments. + */ +function unconstruct(Func) { + return function () { + for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { + args[_key4] = arguments[_key4]; + } + return construct(Func, args); + }; +} +/** + * Add properties to a lookup table + * + * @param set - The set to which elements will be added. + * @param array - The array containing elements to be added to the set. + * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. + * @returns The modified set with added elements. + */ +function addToSet(set, array) { + let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; + if (setPrototypeOf) { + // Make 'in' and truthy checks like Boolean(set.constructor) + // independent of any properties defined on Object.prototype. + // Prevent prototype setters from intercepting set as a this value. + setPrototypeOf(set, null); + } + if (!arrayIsArray(array)) { + return set; + } + let l = array.length; + while (l--) { + let element = array[l]; + if (typeof element === 'string') { + const lcElement = transformCaseFunc(element); + if (lcElement !== element) { + // Config presets (e.g. tags.js, attrs.js) are immutable. + if (!isFrozen(array)) { + array[l] = lcElement; + } + element = lcElement; + } + } + set[element] = true; + } + return set; +} +/** + * Clean up an array to harden against CSPP + * + * @param array - The array to be cleaned. + * @returns The cleaned version of the array + */ +function cleanArray(array) { + for (let index = 0; index < array.length; index++) { + const isPropertyExist = objectHasOwnProperty(array, index); + if (!isPropertyExist) { + array[index] = null; + } + } + return array; +} +/** + * Shallow clone an object + * + * @param object - The object to be cloned. + * @returns A new object that copies the original. + */ +function clone(object) { + const newObject = create(null); + for (const _ref2 of entries(object)) { + var _ref3 = _slicedToArray(_ref2, 2); + const property = _ref3[0]; + const value = _ref3[1]; + const isPropertyExist = objectHasOwnProperty(object, property); + if (isPropertyExist) { + if (arrayIsArray(value)) { + newObject[property] = cleanArray(value); + } else if (value && typeof value === 'object' && value.constructor === Object) { + newObject[property] = clone(value); + } else { + newObject[property] = value; + } + } + } + return newObject; +} +/** + * Convert non-node values into strings without depending on direct property access. + * + * @param value - The value to stringify. + * @returns A string representation of the provided value. + */ +function stringifyValue(value) { + switch (typeof value) { + case 'string': + { + return value; + } + case 'number': + { + return numberToString(value); + } + case 'boolean': + { + return booleanToString(value); + } + case 'bigint': + { + return bigintToString ? bigintToString(value) : '0'; + } + case 'symbol': + { + return symbolToString ? symbolToString(value) : 'Symbol()'; + } + case 'undefined': + { + return objectToString(value); + } + case 'function': + case 'object': + { + if (value === null) { + return objectToString(value); + } + const valueAsRecord = value; + const valueToString = lookupGetter(valueAsRecord, 'toString'); + if (typeof valueToString === 'function') { + const stringified = valueToString(valueAsRecord); + return typeof stringified === 'string' ? stringified : objectToString(stringified); + } + return objectToString(value); + } + default: + { + return objectToString(value); + } + } +} +/** + * This method automatically checks if the prop is function or getter and behaves accordingly. + * + * @param object - The object to look up the getter function in its prototype chain. + * @param prop - The property name for which to find the getter function. + * @returns The getter function found in the prototype chain or a fallback function. + */ +function lookupGetter(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) { + return unapply(desc.get); + } + if (typeof desc.value === 'function') { + return unapply(desc.value); + } + } + object = getPrototypeOf(object); + } + function fallbackValue() { + return null; + } + return fallbackValue; +} +function isRegex(value) { + try { + regExpTest(value, ''); + return true; + } catch (_unused) { + return false; + } +} + +const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'search', 'section', 'select', 'shadow', 'slot', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); +const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'enterkeyhint', 'exportparts', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'inputmode', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'part', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); +const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); +// List of SVG elements that are disallowed by default. +// We still need to know them so that we can do namespace +// checks properly in case one wants to add them to +// allow-list. +const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); +const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); +// Similarly to SVG, we want to know all MathML elements, +// even those that we disallow by default. +const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); +const text = freeze(['#text']); + +const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'command', 'commandfor', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'exportparts', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inert', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'part', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'slot', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns']); +const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dominant-baseline', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'mask-type', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'pointer-events', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-orientation', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'vector-effect', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); +const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnalign', 'columnlines', 'columnspacing', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lquote', 'lspace', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); +const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + +const MUSTACHE_EXPR = seal(/{{[\w\W]*|^[\w\W]*}}/g); +const ERB_EXPR = seal(/<%[\w\W]*|^[\w\W]*%>/g); +const TMPLIT_EXPR = seal(/\${[\w\W]*/g); +const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape +const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape +const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape +); +const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); +const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex +); +const DOCTYPE_NAME = seal(/^html$/i); +const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); +// Markup-significant character probes used by _sanitizeElements. +// Shared module-level instances are safe despite the sticky /g flags: +// unapply() resets lastIndex for RegExp receivers before every call. +const ELEMENT_MARKUP_PROBE = seal(/<[/\w!]/g); +const COMMENT_MARKUP_PROBE = seal(/<[/\w]/g); +const FALLBACK_TAG_CLOSE = seal(/<\/no(script|embed|frames)/i); +const SELF_CLOSING_TAG = seal(/\/>/i); + +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType +const NODE_TYPE = { + element: 1, + attribute: 2, + text: 3, + cdataSection: 4, + entityReference: 5, + // Deprecated + entityNode: 6, + // Deprecated + processingInstruction: 7, + comment: 8, + document: 9, + documentType: 10, + documentFragment: 11, + notation: 12 // Deprecated +}; +/* HTML-namespace elements whose child text nodes are serialized *literally* + (unescaped) by the HTML fragment-serialization algorithm. Two reparse-mXSS + shapes ride on that literal serialization: + (a) an element child - a tree the HTML parser can never build, but the DOM + API and an XML/XHTML parse can - after which a ``-bearing text + sibling breaks the element open on reparse; and + (b) text-only content that already carries the element's OWN end tag, e.g. + `` built as a node, which the literal + serializer emits verbatim for the HTML parser to re-open. + Shape (a) is handled by the firstElementChild branch in _isUnsafeNode; shape + (b) by the LITERAL_TEXT_CLOSE probe. Both read textContent (the raw-serialized + form for these elements) rather than innerHTML, because an XML/XHTML working + document serializes innerHTML with `<` escaped, which silently blinds the + innerHTML-based probes (rule 1's second probe and FALLBACK_TAG_CLOSE) there. + `script` is never allow-listed, but is kept here so the guard matches the + serializer's own literal-text list exactly. */ +const LITERAL_TEXT_ELEMENT_NAMES = ['style', 'script', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext', 'noscript']; +const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES)); +/* Per-element end-tag matcher. On an HTML reparse the ONLY token that + terminates a literal-text element's raw content is its own end tag; a foreign + literal-text close (e.g. `` sitting inside `

ok

', {}); + expect(out).toContain('