Skip to content

Commit 02012b5

Browse files
authored
Merge pull request #224 from dotkernel/develop
Fix TLDR stripping issue
2 parents 12b4f5d + 61fb045 commit 02012b5

4 files changed

Lines changed: 213 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ Copy an existing set of these three files in the same category as a starting poi
5353

5454
If the article body uses images (via `asset('uploads/article/' ~ article.id ~ '/filename.png')` in the `.html.twig`), just drop the image file anywhere under `public/uploads` - `bin/create-uploads-dir` (step 4) finds it by filename and copies it to the right place. No manual path/folder creation needed.
5555

56+
Markdown articles reference images with a literal path instead, e.g. `![](/uploads/article/{post-id}/filename.png)` - `{post-id}` there is just a placeholder for whatever UUID the `Post` has when you write the file. `bin/create-uploads-dir` also scans `.md` files: it resolves the real `Post` by slug and, if the UUID hardcoded in the file doesn't match the post's actual current UUID (which happens whenever fixtures assign it a new one, e.g. in a fresh environment), rewrites the file to the real UUID and copies the image into the correct directory.
57+
5658
## 3. At deploy - run in this order
5759

5860
```shell
@@ -61,7 +63,7 @@ php bin/create-uploads-dir
6163
```
6264

6365
- `bin/doctrine-fixtures` loads `articles_cleaned.json` into the database, creating the `Post` entity (with its database-generated UUID) for the new article.
64-
- `bin/create-uploads-dir` must run *after* it - it resolves the post by slug to get that UUID, creates `public/uploads/article/{post-id}/`, and copies each image referenced in the `.html.twig` there from wherever it already lives under `public/uploads`.
66+
- `bin/create-uploads-dir` must run *after* it - it resolves the post by slug to get that UUID, creates `public/uploads/article/{post-id}/`, and copies each image referenced in the `.html.twig` there from wherever it already lives under `public/uploads`. It does the same for `.md` files, additionally correcting the UUID hardcoded in the file if it no longer matches the post's real one.
6567

6668
## 4. Regenerate the public artifacts - any order
6769

@@ -91,7 +93,7 @@ Steps to edit an existing article (change its status, text, or both) and get the
9193
- `public/md-articles/{category-slug}/{article-slug}.md`
9294
- `src/Blog/templates/page/blog-resource/{category-slug}/{article-slug}.html.twig`
9395
- `src/Blog/templates/page/JSON-LD/{category-slug}/{article-slug}.jsonld.twig` (only if it has hardcoded text outside of `article.*`/`meta.*` variables — most of its fields pull straight from the database and update automatically)
94-
3. **Re-run the same commands as step 3 and step 4 above** (`bin/doctrine-fixtures`, then `bin/generate-feed` / `bin/sitemap` / `bin/generate-llms-full`) so the database and the generated artifacts reflect the change. `bin/create-uploads-dir` only needs to run again if you added a new image.
96+
3. **Re-run the same commands as step 3 and step 4 above** (`bin/doctrine-fixtures`, then `bin/generate-feed` / `bin/sitemap` / `bin/generate-llms-full`) so the database and the generated artifacts reflect the change. `bin/create-uploads-dir` only needs to run again if you added a new image - it's also safe (and cheap) to run any time you suspect a `.md` file's hardcoded UUID has drifted from the post's real one.
9597

9698
## How to move an article to a different category
9799

‎bin/create-uploads-dir‎

Lines changed: 184 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ chdir(__DIR__ . '/../');
1111
require 'vendor/autoload.php';
1212

1313
$templatesDir = 'src/Blog/templates/page/blog-resource';
14-
$uploadsDir = 'public/uploads';
14+
$publicDir = 'public';
15+
$uploadsDir = $publicDir . '/uploads';
1516
$articleDir = $uploadsDir . '/article';
1617
$limit = null;
1718

@@ -33,40 +34,70 @@ $entityManager = $container->get(EntityManager::class);
3334
$postRepository = $entityManager->getRepository(Post::class);
3435

3536
/**
36-
* Index every file currently under public/uploads (excluding the
37-
* uploads/article destination tree) by basename, so we can locate the
38-
* source file for each image referenced from a template.
37+
* Index every file anywhere under public/ by basename, so we can locate the
38+
* source file for each image referenced from a template or markdown
39+
* article - including images already sitting in some other article's
40+
* uploads/article/{uuid}/ directory (e.g. left over from before a post's
41+
* UUID changed), not just loose files dropped outside uploads/article/.
3942
*
4043
* @return array<string, string>
4144
*/
42-
function indexUploadSources(string $uploadsDir, string $articleDir): array
45+
function indexUploadSources(string $publicDir): array
4346
{
4447
$index = [];
4548

4649
$iterator = new RecursiveIteratorIterator(
47-
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS)
50+
new RecursiveDirectoryIterator($publicDir, FilesystemIterator::SKIP_DOTS)
4851
);
4952

5053
foreach ($iterator as $file) {
5154
if (! $file->isFile()) {
5255
continue;
5356
}
5457

55-
$path = $file->getPathname();
56-
if (str_starts_with($path, $articleDir . '/')) {
57-
continue;
58-
}
59-
6058
$basename = $file->getFilename();
6159
if (! isset($index[$basename])) {
62-
$index[$basename] = $path;
60+
$index[$basename] = $file->getPathname();
6361
}
6462
}
6563

6664
return $index;
6765
}
6866

69-
$sourceIndex = indexUploadSources($uploadsDir, $articleDir);
67+
/**
68+
* Extract every (uuid, filename) pair referenced via an
69+
* "uploads/article/{uuid}/{filename}" path in Markdown article content -
70+
* these are hardcoded literally in .md files, unlike the dynamic
71+
* `~ article.id ~` expression used in .html.twig templates.
72+
*
73+
* @return list<array{uuid: string, filename: string}>
74+
*/
75+
function extractArticleImageRefs(string $contents): array
76+
{
77+
$pattern = '/uploads\/article\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/([^)\]\s"\']+)/i';
78+
79+
preg_match_all($pattern, $contents, $matches, PREG_SET_ORDER);
80+
81+
$seen = [];
82+
$refs = [];
83+
84+
foreach ($matches as $match) {
85+
$uuid = strtolower($match[1]);
86+
$filename = $match[2];
87+
$key = $uuid . '/' . $filename;
88+
89+
if (isset($seen[$key])) {
90+
continue;
91+
}
92+
93+
$seen[$key] = true;
94+
$refs[] = ['uuid' => $uuid, 'filename' => $filename];
95+
}
96+
97+
return $refs;
98+
}
99+
100+
$sourceIndex = indexUploadSources($publicDir);
70101

71102
// Matches the filename in: asset('uploads/article/' ~ article.id ~ '/filename.ext')
72103
$pattern = '/~\s*article\.id\s*~\s*\'\/([^\']+)\'/';
@@ -140,14 +171,153 @@ foreach ($templateIterator as $file) {
140171
}
141172
}
142173

174+
/**
175+
* Markdown articles (public/md-articles/{category-slug}/{article-slug}.md)
176+
* embed uploads/article/{uuid}/{filename} paths as literal text, unlike
177+
* .html.twig templates whose `~ article.id ~` is resolved dynamically. Since
178+
* a Post's UUID (uuid7) is generated fresh the first time its slug is
179+
* fixture-inserted in a given environment, a UUID hardcoded in a .md file
180+
* drifts from the Post's real UUID as soon as that slug is (re)seeded
181+
* elsewhere. This pass detects that drift, rewrites the file to the real
182+
* UUID, and makes sure the image is available under the corrected path.
183+
*/
184+
$mdArticlesDir = 'public/md-articles';
185+
186+
$mdFilesProcessed = 0;
187+
$uuidsFixed = 0;
188+
$mdDirsCreated = 0;
189+
$mdImagesCopied = 0;
190+
$mdImagesMissing = 0;
191+
192+
if (is_dir($mdArticlesDir)) {
193+
$mdIterator = new RecursiveIteratorIterator(
194+
new RecursiveDirectoryIterator($mdArticlesDir, FilesystemIterator::SKIP_DOTS)
195+
);
196+
197+
foreach ($mdIterator as $file) {
198+
if (! $file->isFile() || $file->getExtension() !== 'md') {
199+
continue;
200+
}
201+
202+
$path = $file->getPathname();
203+
204+
// Only handle files one level under a category directory, i.e.
205+
// public/md-articles/{category-slug}/{article-slug}.md - skips
206+
// stray top-level files such as md-articles/index.md.
207+
$relative = ltrim(substr($path, strlen($mdArticlesDir)), '/');
208+
if (substr_count($relative, '/') !== 1) {
209+
continue;
210+
}
211+
212+
$slug = preg_replace('/\.md$/', '', $file->getFilename());
213+
$contents = file_get_contents($path);
214+
if ($contents === false) {
215+
continue;
216+
}
217+
218+
$refs = extractArticleImageRefs($contents);
219+
if ($refs === []) {
220+
continue;
221+
}
222+
223+
$post = $postRepository->findOneBy(['slug' => $slug]);
224+
if ($post === null) {
225+
printf("No Post found for slug '%s' (%s), skipping%s", $slug, $path, PHP_EOL);
226+
continue;
227+
}
228+
229+
$mdFilesProcessed++;
230+
231+
$realUuid = $post->getId()->toString();
232+
$staleUuids = [];
233+
234+
foreach ($refs as $ref) {
235+
$refUuid = $ref['uuid'];
236+
$filename = $ref['filename'];
237+
238+
if ($refUuid !== $realUuid) {
239+
$staleUuids[$refUuid] = true;
240+
}
241+
242+
$targetDir = $articleDir . '/' . $realUuid;
243+
$targetPath = $targetDir . '/' . $filename;
244+
245+
if (file_exists($targetPath)) {
246+
continue;
247+
}
248+
249+
if (! is_dir($targetDir)) {
250+
if (! mkdir($targetDir, 0775, true) && ! is_dir($targetDir)) {
251+
fwrite(STDERR, sprintf("Failed to create directory '%s'%s", $targetDir, PHP_EOL));
252+
continue;
253+
}
254+
$mdDirsCreated++;
255+
}
256+
257+
$sourcePath = null;
258+
if ($refUuid !== $realUuid) {
259+
$stalePath = $articleDir . '/' . $refUuid . '/' . $filename;
260+
if (file_exists($stalePath)) {
261+
$sourcePath = $stalePath;
262+
}
263+
}
264+
265+
if ($sourcePath === null && isset($sourceIndex[$filename])) {
266+
$sourcePath = $sourceIndex[$filename];
267+
}
268+
269+
if ($sourcePath === null) {
270+
printf("Source image '%s' not found for %s%s", $filename, $path, PHP_EOL);
271+
$mdImagesMissing++;
272+
continue;
273+
}
274+
275+
if (! copy($sourcePath, $targetPath)) {
276+
fwrite(STDERR, sprintf("Failed to copy '%s' to '%s'%s", $sourcePath, $targetPath, PHP_EOL));
277+
continue;
278+
}
279+
280+
$mdImagesCopied++;
281+
}
282+
283+
if ($staleUuids !== []) {
284+
$updated = $contents;
285+
foreach (array_keys($staleUuids) as $staleUuid) {
286+
$updated = str_replace($staleUuid, $realUuid, $updated);
287+
}
288+
289+
if ($updated !== $contents) {
290+
if (file_put_contents($path, $updated) === false) {
291+
fwrite(STDERR, sprintf("Failed to update '%s'%s", $path, PHP_EOL));
292+
} else {
293+
$uuidsFixed++;
294+
printf("Corrected UUID in '%s'%s", $path, PHP_EOL);
295+
}
296+
}
297+
}
298+
}
299+
}
300+
143301
printf(
144-
"Done. %d template%s processed, %d director%s created, %d image%s copied, %d missing.%s",
302+
"Done. %d twig template%s processed, %d director%s created, %d image%s copied, %d missing.%s"
303+
. " %d markdown file%s processed, %d UUID%s corrected, %d director%s created,"
304+
. " %d image%s copied, %d missing.%s",
145305
$filesProcessed,
146306
$filesProcessed === 1 ? '' : 's',
147307
$dirsCreated,
148308
$dirsCreated === 1 ? 'y' : 'ies',
149309
$imagesCopied,
150310
$imagesCopied === 1 ? '' : 's',
151311
$imagesMissing,
312+
PHP_EOL,
313+
$mdFilesProcessed,
314+
$mdFilesProcessed === 1 ? '' : 's',
315+
$uuidsFixed,
316+
$uuidsFixed === 1 ? '' : 's',
317+
$mdDirsCreated,
318+
$mdDirsCreated === 1 ? 'y' : 'ies',
319+
$mdImagesCopied,
320+
$mdImagesCopied === 1 ? '' : 's',
321+
$mdImagesMissing,
152322
PHP_EOL
153323
);

‎src/App/src/Service/ArticleBodyCleaner.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ final class ArticleBodyCleaner
1717
public static function clean(string $body): string
1818
{
1919
$body = (string) preg_replace('/\A#[ \t][^\n]*\n/', '', $body, 1);
20-
$body = (string) preg_replace('/^## TL;DR\s*$.*?(?=^## |\z)/ms', '', $body, 1);
20+
$body = (string) preg_replace('/^## TL;DR\s*$\n\s*\n.*?(?=\n\s*\n|^## |\z)/ms', '', $body, 1);
2121

2222
return trim($body);
2323
}

‎test/Unit/App/Service/ArticleBodyCleanerTest.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,28 @@ public function testCleanOnlyStripsTheTitleWhenThereIsNoTlDrSection(): void
2222

2323
$this->assertSame("## First Section\n\nThe real content.", ArticleBodyCleaner::clean($body));
2424
}
25+
26+
public function testCleanStripsTlDrWithNoLeadingTitleWhenFollowedBySection(): void
27+
{
28+
$body = "## TL;DR\n\nA short summary.\n\n## First Section\n\nThe real content.";
29+
30+
$this->assertSame("## First Section\n\nThe real content.", ArticleBodyCleaner::clean($body));
31+
}
32+
33+
public function testCleanStripsTlDrWithNoLeadingTitleAndNoFollowingHeading(): void
34+
{
35+
$body = "## TL;DR\n\nA short summary.\n\nJust plain continuing content with no further heading.";
36+
37+
$this->assertSame(
38+
'Just plain continuing content with no further heading.',
39+
ArticleBodyCleaner::clean($body)
40+
);
41+
}
42+
43+
public function testCleanReturnsEmptyStringWhenTlDrIsTheEntireBody(): void
44+
{
45+
$body = "## TL;DR\n\nOnly a summary, nothing else.";
46+
47+
$this->assertSame('', ArticleBodyCleaner::clean($body));
48+
}
2549
}

0 commit comments

Comments
 (0)