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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

## [1.7.1] — 2026-09-14

### Fixed

- **An activated theme's assets now reach a split docroot** (found by the first one-click cPanel
install: Grey Mist rendered unstyled because `/_greymist/*` 404'd). `Tiger_Install::linkPublicAssets()`
mirrors every `_*` entry Tiger publishes under `<root>/public` — a theme's asset base, a module's own
publish — into the docroot when the docroot is not `<root>/public`; `Tiger_Theme::activate()` also
publishes into the request's document root directly. Co-located installs are unchanged.
- **Theme assets on a `symlink()`-disabled host are copied to the theme's asset base** (`public/_greymist`),
not to `_modules/<slug>` where nothing references them. New `Tiger_Install::publishOne()` is the
one link-or-copy unit both paths use.

## [1.7.0] — 2026-09-14

**One theme-activation authority, and the headless install path documented.**
Expand Down
40 changes: 40 additions & 0 deletions library/Tiger/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ public static function linkPublicAssets($webroot, $root, $theme = 'puma')
if (is_dir($modulesDir)) { $links['_modules'] = $modulesDir; }
}

// Everything ELSE Tiger publishes under <root>/public — a theme's asset base (`_greymist`,
// linked there by Tiger_Theme::activate()), or any module that publishes its own `_<x>` — must
// reach the docroot too, or the site references assets the web server cannot see. On the
// split layout the docroot mirrors every `_*` entry of <root>/public; co-located it already is.
if (realpath($webroot) !== realpath($root . '/public')) {
foreach (glob($root . '/public/_*') ?: [] as $entry) {
$name = basename($entry);
if (isset($links[$name]) || !is_dir($entry)) { continue; }
$links[$name] = is_link($entry) ? (string) readlink($entry) : $entry;
}
}

$made = [];
foreach ($links as $name => $target) {
if (!is_dir($target)) {
Expand Down Expand Up @@ -266,6 +278,34 @@ public static function linkPublicAssets($webroot, $root, $theme = 'puma')
return $made;
}

/**
* Publish ONE asset directory at <webroot>/<name>: a symlink when the host allows it, else a
* marked copy (refreshed on republish). The unit linkPublicAssets() applies to each entry; a theme
* activation uses it for the theme's own asset base.
*
* @param string $webroot the dir the entry is created in
* @param string $name the entry name (e.g. `_greymist`)
* @param string $target the real asset directory
* @return bool true if linked, false if copied
* @throws RuntimeException when the entry is a user's real directory, or neither link nor copy worked
*/
public static function publishOne($webroot, $name, $target)
{
$link = rtrim((string) $webroot, '/') . '/' . $name;
if (!is_dir($target)) { throw new RuntimeException("publishOne: asset target not found: {$target}"); }
if (is_link($link)) { @unlink($link); }
elseif (is_dir($link)) {
if (!is_file($link . '/' . self::ASSET_COPY_MARKER)) { throw new RuntimeException("publishOne: refusing to replace a real directory: {$link}"); }
self::_rrmdir($link);
} elseif (file_exists($link)) { @unlink($link); }
if (static::_canSymlink() && @symlink($target, $link)) { return true; }
self::_rcopy($target, $link);
if (!is_dir($link)) { throw new RuntimeException("publishOne: could neither link nor copy {$target} -> {$link}"); }
@file_put_contents($link . '/' . self::ASSET_COPY_MARKER,
"Published by Tiger because symlink() is unavailable on this host.\n" . "Managed automatically — do not edit; it is replaced on update.\n");
return false;
}

/**
* Is this install serving COPIED assets rather than symlinks? True when either published dir is a
* real directory carrying our marker — i.e. the host blocked `symlink()` at publish time.
Expand Down
17 changes: 12 additions & 5 deletions library/Tiger/Theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,16 +279,23 @@ protected static function publicDir()
return $base . '/public';
}

/** Symlink a theme's assets/ to public/<assetBase> (copy fallback where symlinks are blocked). */
/**
* Publish a theme's assets/ at public/<assetBase> — a symlink, or a marked COPY where symlink()
* is disabled (the copy used to land under _modules/<slug>, which is not where the theme's HTML
* points). On the split layout, where the web server's docroot is not <root>/public, the same
* entry is published into the docroot too, or the theme's CSS 404s the moment it is activated.
*/
protected static function _linkAssets($slug, $base, $area)
{
$root = ($area === 'app' && defined('APPLICATION_PATH')) ? APPLICATION_PATH : TIGER_CORE_PATH;
$assets = $root . '/modules/' . $slug . '/assets';
if (!is_dir($assets)) { return; }
$link = self::publicDir() . '/' . ltrim((string) $base, '/');
if (is_link($link)) { @unlink($link); }
if (!(function_exists('symlink') && @symlink($assets, $link)) && !is_dir($link)) {
Tiger_Module_Installer::publishAssets($slug); // best-effort; symlink is the norm on cPanel
$name = ltrim((string) $base, '/');
if ($name === '' || strpos($name, '/') !== false || $name[0] !== '_') { return; } // an asset base is one `_x` segment
try { Tiger_Install::publishOne(self::publicDir(), $name, $assets); } catch (Throwable $e) { /* a user's dir of that name wins */ }
$doc = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim((string) $_SERVER['DOCUMENT_ROOT'], '/') : '';
if ($doc !== '' && is_dir($doc) && realpath($doc) !== realpath(self::publicDir())) {
try { Tiger_Install::publishOne($doc, $name, $assets); } catch (Throwable $e) { /* same */ }
}
}

Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
class Tiger_Version
{
/** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */
const VERSION = '1.7.0';
const VERSION = '1.7.1';
}
52 changes: 52 additions & 0 deletions tests/Unit/InstallAssetsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,58 @@ public function it_refuses_to_replace_a_real_directory_it_did_not_publish(): voi
$this->assertSame('user content', file_get_contents($this->webroot . '/_theme/mine.txt'));
}
}

/**
* A theme's asset base (`public/_greymist`, put there by Tiger_Theme::activate()) — and anything
* else Tiger publishes under <root>/public — must reach a SPLIT docroot, or the activated theme's
* CSS 404s. Found on a real cPanel account: the theme rendered unstyled after a one-click install.
*/
#[Test]
public function it_mirrors_every_published_public_entry_into_a_split_docroot(): void
{
@mkdir($this->root . '/application/modules/theme-grey-mist/assets/css', 0775, true);
file_put_contents($this->root . '/application/modules/theme-grey-mist/assets/css/grey.css', '.grey{}');
@mkdir($this->root . '/public', 0775, true);
symlink($this->root . '/application/modules/theme-grey-mist/assets', $this->root . '/public/_greymist');
@mkdir($this->root . '/public/_other/x', 0775, true); // a module's own publish, a real dir
file_put_contents($this->root . '/public/_other/x/o.js', '1');
file_put_contents($this->root . '/public/notes.txt', 'ignored'); // not an _entry

$made = Tiger_Install::linkPublicAssets($this->webroot, $this->root, 'puma');
$this->assertArrayHasKey('_greymist', $made);
$this->assertArrayHasKey('_other', $made);
$this->assertFileExists($this->webroot . '/_greymist/css/grey.css');
$this->assertFileExists($this->webroot . '/_other/x/o.js');
$this->assertFileDoesNotExist($this->webroot . '/notes.txt');

// Copy mode too — a real directory with the marker, refreshed on a second run.
$copied = NoSymlinkInstall::linkPublicAssets($this->webroot, $this->root, 'puma');
$this->assertArrayHasKey('_greymist', $copied);
$this->assertFalse(is_link($this->webroot . '/_greymist'));
$this->assertFileExists($this->webroot . '/_greymist/' . Tiger_Install::ASSET_COPY_MARKER);
}

#[Test]
public function it_does_not_mirror_when_the_docroot_is_public_itself(): void
{
@mkdir($this->root . '/public/_greymist', 0775, true);
$made = Tiger_Install::linkPublicAssets($this->root . '/public', $this->root, 'puma');
$this->assertArrayNotHasKey('_greymist', $made, 'co-located: a link onto itself would loop');
}

#[Test]
public function publish_one_links_or_copies_a_single_entry_and_respects_a_users_directory(): void
{
$target = $this->root . '/vendor/webtigers/tiger-core/themes/puma/assets';
$this->assertTrue(Tiger_Install::publishOne($this->webroot, '_x', $target));
$this->assertTrue(is_link($this->webroot . '/_x'));
$this->assertFalse(NoSymlinkInstall::publishOne($this->webroot, '_x', $target), 'copied');
$this->assertFileExists($this->webroot . '/_x/css/default.css');
$this->assertFileExists($this->webroot . '/_x/' . Tiger_Install::ASSET_COPY_MARKER);
@mkdir($this->webroot . '/_mine', 0775, true); file_put_contents($this->webroot . '/_mine/keep', '1');
$this->expectException(RuntimeException::class);
Tiger_Install::publishOne($this->webroot, '_mine', $target);
}
}

/** Forces the copy path — the only way to reach it on a dev machine that allows symlinks. */
Expand Down
Loading