diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..17aea91 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,70 @@ +name: QA + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: qa-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + workflows: + uses: sympress/workflows/.github/workflows/lint-workflows.yml@v1 + + php: + name: PHP QA and translations + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.5' + extensions: dom, mbstring, xml, xmlwriter + tools: composer:v2 + coverage: none + - name: Install locked dependencies + run: composer install --prefer-dist --no-interaction --no-progress + - name: Package QA + run: composer qa + - name: Check translation catalog + run: php scripts/extract-translations.php --check + - name: Audit PHP dependencies + run: composer audit --locked --no-interaction + + assets: + name: Assets and lazy chunks + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Install Chromium for the chunk test + run: npx playwright install --only-shell chromium + - name: Production build + run: npm run build + - name: Check generated design tokens + run: git diff --exit-code -- resources/css/tokens.css + - name: Test lazy chunks on nested routes + run: npm run test:chunks + - name: Audit JavaScript dependencies + run: npm audit --audit-level=moderate diff --git a/README.md b/README.md index 0901772..cd6d69e 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,12 @@ The [integration fixture](tests/site/README.md) tests a separate WordPress datab `npm run test:browser` requires that fixture running, or `THEME_TEST_URL` pointing at an equivalent seeded **test** site. Never point the fixture setup at a live site. +GitHub Actions runs `composer qa`, the translation catalog check, a production +build, a generated-token consistency check, `test:chunks` in Chromium and dependency +audits on pull requests and pushes to `main`. Workflow linting uses SymPress's +shared workflow. The full WordPress/browser fixture remains a separate local +integration check; the CI chunk test does not boot WordPress. + ## Make it yours | Concern | Source | @@ -179,9 +185,17 @@ already have view data can bypass the WordPress main loop explicitly: $renderer->render(['partials/post-summary'], ['post' => $postViewData]); ``` +The `sympress_starter/template_candidates` filter also applies to explicitly +supplied candidates. Query-based rendering calls core template getters again +after WordPress's template loader; callbacks on `{type}_template` should tolerate +multiple calls. Explicit candidates avoid that second hierarchy lookup. + Card excerpts use native `get_the_excerpt`, `excerpt_length` and `excerpt_more` filters. Public reusable blocks are expanded through WordPress's excerpt block -allowlist with cycle/depth protection. A custom dynamic block must be opted in +allowlist with cycle protection, a maximum nesting depth of 20 and a total budget +of 100 reusable references per excerpt. Further references are omitted; an +authored excerpt avoids truncation for unusually large reusable layouts. +A custom dynamic block must be opted in via `excerpt_allowed_blocks`; its renderer must not recursively request excerpts. Singular pages do not compute unused card excerpts. Metadata continues to use stored plain text and never renders blocks or shortcodes. @@ -220,7 +234,9 @@ Build output is ignored in source control and must be included in release artifa Missing or invalid asset manifests show an admin notice and a readable CSS fallback. The frontend entry is validated independently of the optional editor entry. Build assets may use relative subdirectories such as `js/` and `css/`; paths -outside `build/` are rejected. A broken editor entry does not disable frontend +outside `build/` are rejected, including symlinks resolving outside that directory. +Deploy actual files within `build/` or use symlinks whose targets remain inside it. +A broken editor entry does not disable frontend assets. Script-only frontend entries are supported. Configure gzip/Brotli on the hosting webserver for HTML, CSS and JavaScript. Give diff --git a/docs/review-3-2026-10-01.md b/docs/review-3-2026-10-01.md new file mode 100644 index 0000000..ef5785b --- /dev/null +++ b/docs/review-3-2026-10-01.md @@ -0,0 +1,24 @@ +# Review 3 follow-up — 2026-10-01 + +- M1 was already fixed in `25aa8a5`, included in merged PR #1 (`80ec27f`). +- S-1: PR/main CI now runs canonical PHP QA, catalog freshness, production build, + generated-token consistency, Chromium lazy-chunk checks and dependency audits. + The shared SymPress workflow lints workflow definitions. Full WordPress fixture + tests remain a separate integration gate. +- S-2: `EncoreEntrypointsLoader::loadFromArray()` is prepared upstream in Assets. + Theme adoption is pending an Assets release; the current stable dependency and + protected-method adapter remain until that public API is available to consumers. +- README documents that symlinks resolving outside `build/` are rejected and that + the candidate filter applies to explicit REST/AJAX/CLI candidates too. +- README documents the repeated native template-getter calls during Twig fallback + selection; explicit candidates avoid that lookup. The resolver behavior is unchanged. +- Reusable excerpts now have a total budget of 100 references per excerpt, in + addition to cycle detection and maximum depth 20. Tests cover wide input and + budget reset between calls. + +Local validation: `composer qa` passes with 14 PHPUnit tests / 41 assertions and +48 template assertions; build, nested-route chunks and catalog freshness pass. +The native WordPress regression script passes 31 checks. All 11 browser tests +pass when run after the regression script. These suites must run sequentially: +the regression script temporarily inserts posts into the shared disposable DB, +which changes pagination while it is running. diff --git a/src/WordPress/Content.php b/src/WordPress/Content.php index d7f653a..7020a77 100644 --- a/src/WordPress/Content.php +++ b/src/WordPress/Content.php @@ -25,9 +25,10 @@ public static function excerpt(\WP_Post $post): string return ''; } $seen = []; + $remaining = 100; $allowReusable = static fn (array $allowed): array => array_values(array_unique([...$allowed, 'core/block'])); - $renderReusable = static function (?string $rendered, array $block) use (&$seen): ?string { - return self::reusableExcerpt($rendered, $block, $seen); + $renderReusable = static function (?string $rendered, array $block) use (&$seen, &$remaining): ?string { + return self::reusableExcerpt($rendered, $block, $seen, $remaining); }; add_filter('excerpt_allowed_blocks', $allowReusable); add_filter('pre_render_block', $renderReusable, 10, 2); @@ -46,15 +47,16 @@ public static function excerpt(\WP_Post $post): string * @param array{blockName?: ?string, attrs?: array} $block * @param array $seen */ - private static function reusableExcerpt(?string $rendered, array $block, array &$seen): ?string + private static function reusableExcerpt(?string $rendered, array $block, array &$seen, int &$remaining): ?string { if ($rendered !== null || ($block['blockName'] ?? '') !== 'core/block') { return $rendered; } $reference = (int) ($block['attrs']['ref'] ?? 0); - if ($reference === 0 || isset($seen[$reference]) || count($seen) >= 20) { + if ($reference === 0 || isset($seen[$reference]) || count($seen) >= 20 || $remaining <= 0) { return ''; } + --$remaining; $reusable = get_post($reference); if ( !$reusable instanceof \WP_Post || $reusable->post_type !== 'wp_block' diff --git a/tests/site/review-regressions.php b/tests/site/review-regressions.php index c5ea4a9..6776abb 100644 --- a/tests/site/review-regressions.php +++ b/tests/site/review-regressions.php @@ -88,6 +88,22 @@ $assert(str_contains(Content::excerpt(get_post($excerptPost)), 'Safe cyclic ending'), 'Cyclic reusable references terminate and preserve remaining text.'); wp_update_post(['ID' => $reusable, 'post_status' => 'private', 'post_content' => '

PRIVATE_PATTERN_SECRET

']); $assert(!str_contains(Content::excerpt(get_post($excerptPost)), 'PRIVATE_PATTERN_SECRET'), 'Private reusable content does not leak into card excerpts.'); + wp_update_post(['ID' => $reusable, 'post_status' => 'publish', 'post_content' => '

Budget text

']); + wp_update_post(['ID' => $excerptPost, 'post_content' => str_repeat('', 105)]); + $reusableRenders = 0; + $countReusable = static function (string $html) use (&$reusableRenders): string { + ++$reusableRenders; + return $html; + }; + add_filter('render_block_core/paragraph', $countReusable); + try { + Content::excerpt(get_post($excerptPost)); + $assert($reusableRenders === 100, 'Wide reusable references have a total expansion budget.'); + Content::excerpt(get_post($excerptPost)); + $assert($reusableRenders === 200, 'The expansion budget resets for each excerpt.'); + } finally { + remove_filter('render_block_core/paragraph', $countReusable); + } wp_update_post(['ID' => $excerptPost, 'post_content' => '']); $assert(str_contains(Content::excerpt(get_post($excerptPost)), 'Dynamic block output'), 'Dynamic blocks allowed by WordPress contribute excerpt text.'); remove_filter('excerpt_length', $lengthFilter);