Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/review-3-2026-10-01.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions src/WordPress/Content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -46,15 +47,16 @@ public static function excerpt(\WP_Post $post): string
* @param array{blockName?: ?string, attrs?: array<string, mixed>} $block
* @param array<int, true> $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'
Expand Down
16 changes: 16 additions & 0 deletions tests/site/review-regressions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<!-- wp:paragraph --><p>PRIVATE_PATTERN_SECRET</p><!-- /wp:paragraph -->']);
$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' => '<!-- wp:paragraph --><p>Budget text</p><!-- /wp:paragraph -->']);
wp_update_post(['ID' => $excerptPost, 'post_content' => str_repeat('<!-- wp:block {"ref":' . $reusable . '} /-->', 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' => '<!-- wp:review/count /-->']);
$assert(str_contains(Content::excerpt(get_post($excerptPost)), 'Dynamic block output'), 'Dynamic blocks allowed by WordPress contribute excerpt text.');
remove_filter('excerpt_length', $lengthFilter);
Expand Down
Loading