diff --git a/app/Filament/Resources/Pages/Schemas/PageForm.php b/app/Filament/Resources/Pages/Schemas/PageForm.php
index d185143..357801b 100644
--- a/app/Filament/Resources/Pages/Schemas/PageForm.php
+++ b/app/Filament/Resources/Pages/Schemas/PageForm.php
@@ -75,6 +75,15 @@ class PageForm
RichEditor::make("body.{$locale}")
->label(__('panel.pages.fields.body'))
+ // Images in the text go where every other upload
+ // does, public: the HTML travels to the frontend
+ // as it is, with each image's URL written in, and
+ // a private file would be a broken image there.
+ // Media::referencedPaths() finds them in the body,
+ // so pruning never takes one a page still shows.
+ ->fileAttachmentsDisk(config('media.disk'))
+ ->fileAttachmentsDirectory('pages/content')
+ ->fileAttachmentsVisibility('public')
->columnSpanFull(),
]),
]),
diff --git a/app/Models/Media.php b/app/Models/Media.php
index 7277bda..adfe458 100644
--- a/app/Models/Media.php
+++ b/app/Models/Media.php
@@ -183,6 +183,7 @@ class Media extends Model
[Member::class, 'avatar'],
[Client::class, 'logo'],
[Testimonial::class, 'avatar'],
+ [Technology::class, 'logo'],
[ImageAttachment::class, 'path'],
];
@@ -198,9 +199,52 @@ class Media extends Model
$paths = [...$paths, ...$query->pluck($column)->all()];
}
+ // Images placed inside rich text — attached in the panel's editor, or
+ // uploaded through the API and written into a body as ``. No
+ // column holds their path, so without this they would look orphaned
+ // and `content:prune --media` would delete them from under the page.
+ $richText = [
+ [Page::class, ['body']],
+ [Project::class, ['description', 'decisions', 'postmortem']],
+ ];
+
+ foreach ($richText as [$model, $columns]) {
+ $model::query()
+ ->withTrashed()
+ ->select(['id', ...$columns])
+ ->chunkById(200, function ($records) use ($columns, &$paths): void {
+ foreach ($records as $record) {
+ foreach ($columns as $column) {
+ foreach ($record->getTranslations($column) as $html) {
+ $paths = [...$paths, ...static::pathsEmbeddedIn((string) $html)];
+ }
+ }
+ }
+ });
+ }
+
return array_values(array_unique(array_filter($paths)));
}
+ /**
+ * The stored paths of every image on the media disk a piece of HTML links
+ * to, whether written with the disk's absolute URL or its path alone.
+ *
+ * @return array
+ */
+ public static function pathsEmbeddedIn(string $html): array
+ {
+ $base = (string) parse_url(Storage::disk((string) config('media.disk', 'public'))->url(''), PHP_URL_PATH);
+
+ if ($base === '' || ! str_contains($html, $base)) {
+ return [];
+ }
+
+ preg_match_all('#'.preg_quote(rtrim($base, '/'), '#').'/([^"\'\s?\#<>]+)#', $html, $matches);
+
+ return array_map('rawurldecode', $matches[1]);
+ }
+
/**
* Whether any record still points at this file.
*
diff --git a/tests/Feature/Media/ReferencedPathsTest.php b/tests/Feature/Media/ReferencedPathsTest.php
new file mode 100644
index 0000000..9271a75
--- /dev/null
+++ b/tests/Feature/Media/ReferencedPathsTest.php
@@ -0,0 +1,92 @@
+create(['logo' => 'technologies/angular.svg']);
+
+ $this->assertContains('technologies/angular.svg', Media::referencedPaths());
+ }
+
+ #[Test]
+ public function an_image_inside_a_page_body_is_in_use_in_any_language(): void
+ {
+ $url = Storage::disk((string) config('media.disk'))->url('pages/content/diagrama.png');
+
+ Page::factory()->create([
+ 'body' => [
+ 'es' => '