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
19 changes: 4 additions & 15 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
run: php artisan assets:generate --check

- name: Tests
run: ./vendor/bin/pest
run: ./vendor/bin/pest --exclude-group=ssr

frontend:
runs-on: ubuntu-26.04
Expand Down Expand Up @@ -95,11 +95,8 @@ jobs:
- name: Build
run: npm run build

# Every case behind `requireSsr()` (tests/Support/ssr.php) needs a built SSR
# bundle and a running SSR service, and skips itself when either is missing.
# The `php` job above provides neither, so those cases skip there. This job
# is the one place they execute; REQUIRE_SSR turns those skips into failures
# so they cannot go quiet again.
# The ssr group: tests that need the built bundles, node_modules or a live SSR
# service. REQUIRE_SSR turns their skips into failures here.
ssr:
runs-on: ubuntu-26.04

Expand Down Expand Up @@ -168,18 +165,10 @@ jobs:
cat /tmp/ssr.log >&2 || true
exit 1

# The whole suite, not one file.
#
# This once ran only HomepageRenderTest.php, which left every other
# SSR-rendered assertion skipping on every push: the `php` job above
# builds no bundles, so every `ssrHtml()` / `requireSsr()` assertion
# short-circuits there, and this job is the one place they execute.
#
# REQUIRE_SSR turns a skip into a failure, so this cannot go quiet again.
- name: SSR render tests
env:
REQUIRE_SSR: 1
run: ./vendor/bin/pest
run: ./vendor/bin/pest --group=ssr

- name: SSR service log
if: always()
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ Every change needs a test. Feature tests live in `tests/Feature`; there is no
`RefreshDatabase` anywhere and there must not be — there is no database.

No test reaches the network: `tests/Pest.php` prevents stray HTTP requests, so
fake GitHub with `Http::fake()`. Tests that assert on rendered markup go behind
`requireSsr()` and skip locally without a built SSR bundle.
fake GitHub with `Http::fake()`. SSR is off in tests: a test that asserts on
rendered markup calls `requireSsr()` and sits in `->group('ssr')`, which CI runs
in the job with a live SSR service. It skips locally without one.

## Content

Expand Down
85 changes: 44 additions & 41 deletions app/Support/Seo/LastModified.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,16 @@
use Symfony\Component\Process\Process;
use Throwable;

/**
* When the files behind a page last changed, for the sitemap's `lastmod`.
*
* Git first: `git log -1 --format=%cI -- <paths>` is the last commit that
* touched any of them. File mtimes are no good on their own, because every
* `git pull` on the server resets them to the pull time, and a sitemap whose
* every `lastmod` moves on each deploy teaches crawlers to ignore the field.
* The newest mtime is the fallback for a checkout without history or files not
* committed yet, and a page with neither gets no `lastmod` at all rather than
* a made-up "now".
*
* A page's sources can include other locales' copy. `forEntry()` keeps only
* what drives the page in the locale asked for: the shared data files plus
* that locale's own content, legal or blog file.
*/
// The sitemap's lastmod: the last commit touching a page's sources. Mtimes are
// only a fallback, since every `git pull` on the server resets them.
final class LastModified
{
/**
* Seconds `git log` may take before the mtime fallback is used.
*/
private const GIT_TIMEOUT = 2.0;
private const GIT_TIMEOUT = 10.0;

/**
* @var array<string, CarbonImmutable|null>
* @var array<string, array{0: int, 1: string}>|null path => [unix time, ISO 8601] of its last commit
*/
private array $memo = [];
private ?array $commits = null;

public function __construct(
private readonly string $root,
Expand All @@ -48,29 +32,28 @@ public function forEntry(PageEntry $entry, string $locale): ?CarbonImmutable
*/
public function forPaths(array $paths): ?CarbonImmutable
{
$paths = array_values(array_unique($paths));
sort($paths);

if ($paths === []) {
return null;
}

$key = implode("\0", $paths);
$this->commits ??= $this->readGitLog();
$newest = null;

foreach (array_unique($paths) as $path) {
$commit = $this->commits[$path] ?? null;

if (array_key_exists($key, $this->memo)) {
return $this->memo[$key];
if ($commit !== null && ($newest === null || $commit[0] > $newest[0])) {
$newest = $commit;
}
}

return $this->memo[$key] = $this->fromGit($paths) ?? $this->fromMtime($paths);
return $newest !== null ? CarbonImmutable::parse($newest[1]) : $this->fromMtime($paths);
}

/**
* The sources that drive a page in one locale.
*
* A source belongs to a locale when one of its directories is named after
* that locale (`content/vi/…`, `legal/en/…`, `blog/vi/…`). A post directly
* under `resources/blog/` belongs to the default locale. Anything else is
* shared data and drives every locale.
* The sources that drive a page in one locale: shared data, plus the files
* under a directory named after that locale. A post directly under
* `resources/blog/` belongs to the default locale.
*
* @param list<string> $sources
* @return list<string>
Expand All @@ -97,20 +80,40 @@ private static function localeOf(string $source): ?string
}

/**
* @param list<string> $paths
* @return array<string, array{0: int, 1: string}>
*/
private function fromGit(array $paths): ?CarbonImmutable
private function readGitLog(): array
{
try {
$process = new Process(['git', 'log', '-1', '--format=%cI', '--', ...$paths], $this->root, null, null, self::GIT_TIMEOUT);
$process = new Process(
['git', '-c', 'core.quotePath=false', 'log', '--format=%x00%ct %cI', '--name-only', '--relative'],
$this->root,
null,
null,
self::GIT_TIMEOUT,
);
$process->run();
} catch (Throwable) {
return [];
}

$output = trim($process->getOutput());
if (! $process->isSuccessful()) {
return [];
}

return $process->isSuccessful() && $output !== '' ? CarbonImmutable::parse($output) : null;
} catch (Throwable) {
return null;
$commits = [];
$commit = null;

foreach (explode("\n", $process->getOutput()) as $line) {
if (str_starts_with($line, "\0")) {
[$time, $iso] = explode(' ', substr($line, 1), 2);
$commit = [(int) $time, $iso];
} elseif ($line !== '' && $commit !== null && (! isset($commits[$line]) || $commit[0] > $commits[$line][0])) {
$commits[$line] = $commit;
}
}

return $commits;
}

/**
Expand Down
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="CACHE_STORE" value="array"/>
<env name="INERTIA_SSR_ENABLED" value="false" force="true"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="WEB_DOMAIN" value="localhost"/>
Expand Down
Loading