Skip to content

Commit 3ab60c2

Browse files
committed
Fix #102: Adjust source files layout for translated content
1 parent 9a5676a commit 3ab60c2

8 files changed

Lines changed: 175 additions & 11 deletions

File tree

docs/architecture.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ User content lives under `content/`:
2626
- `config.yaml` — site-wide settings.
2727
- `navigation.yaml` — one or more navigation menus.
2828
- `<collection>/_collection.yaml` — collection settings.
29-
- `<collection>/*.md` — entries in a collection.
29+
- `<collection>/*.md` — default-language entries in a collection.
30+
- `<collection>/<locale>/*.md` — localized collection entries, for example `blog/ru/post.md`.
3031
- `authors/*.md` — author profiles.
3132
- `assets/` and `<collection>/assets/` — copied static files.
3233

docs/configuration.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,12 @@ Declare site languages in `content/config.yaml`. The first language is the defau
150150
languages: [en, ru]
151151
```
152152

153-
Entries are tagged with the `language` front matter field. Entries whose language
154-
differs from the first configured site language get their permalink prefixed automatically (e.g.,
155-
`/ru/blog/hello/`); default-language entries keep the plain URL (`/blog/hello/`).
153+
Put translations in a locale directory (`content/blog/ru/hello.md` for a collection entry
154+
or `content/ru/about.md` for a standalone page). The directory sets the entry language;
155+
the `language` front matter field remains available as an explicit override. Entries whose
156+
language differs from the first configured site language get their permalink prefixed
157+
automatically (e.g., `/ru/blog/hello/`); default-language entries keep the plain URL
158+
(`/blog/hello/`).
156159
Explicit `permalink:` overrides in front matter bypass the prefix. `languages` is required
157160
and must contain at least one language code.
158161

docs/content.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,15 @@ Translations of the same entry share the same slug but differ in language:
390390

391391
```
392392
content/blog/
393-
├── hello-world.md # language: en (default)
394-
└── hello-world.ru.md # language: ru
393+
├── hello-world.md # default language
394+
├── cs/
395+
│ └── hello-world.md # language: cs
396+
└── ru/
397+
└── hello-world.md # language: ru
395398
```
396399

397-
The language suffix in the filename (`.ru.md`) is a shorthand for setting `language: ru` in front matter.
400+
The directory name sets the entry language, so localized files do not need a `language`
401+
front matter field. An explicit `language` field takes precedence when present. Standalone
402+
pages use the same convention (`content/about.md`, `content/ru/about.md`). This follows
403+
VitePress's locale-directory layout. Locale directory names use lowercase ISO language
404+
codes and may include an uppercase region, for example `pt-BR`.

src/Console/BuildCommand.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1861,13 +1861,44 @@ private function collectSourceInventory(
18611861
$collectionIterator = new FilesystemIterator($item->getPathname(), BaseFilesystemIterator::SKIP_DOTS);
18621862
foreach ($collectionIterator as $collectionItem) {
18631863
/** @var SplFileInfo $collectionItem */
1864-
if ($collectionItem->isDir() || strtolower($collectionItem->getExtension()) !== 'md') {
1864+
if ($collectionItem->isDir()) {
1865+
if (preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/D', $collectionItem->getFilename()) === 1) {
1866+
$languageIterator = new FilesystemIterator(
1867+
$collectionItem->getPathname(),
1868+
BaseFilesystemIterator::SKIP_DOTS,
1869+
);
1870+
foreach ($languageIterator as $languageItem) {
1871+
/** @var SplFileInfo $languageItem */
1872+
if (
1873+
$languageItem->isFile()
1874+
&& strtolower($languageItem->getExtension()) === 'md'
1875+
) {
1876+
$contentFiles[] = $languageItem->getPathname();
1877+
}
1878+
}
1879+
}
1880+
continue;
1881+
}
1882+
if (strtolower($collectionItem->getExtension()) !== 'md') {
18651883
continue;
18661884
}
18671885
$contentFiles[] = $collectionItem->getPathname();
18681886
}
18691887
}
18701888

1889+
if (preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/D', $name) === 1) {
1890+
$languageIterator = new FilesystemIterator(
1891+
$item->getPathname(),
1892+
BaseFilesystemIterator::SKIP_DOTS,
1893+
);
1894+
foreach ($languageIterator as $languageItem) {
1895+
/** @var SplFileInfo $languageItem */
1896+
if ($languageItem->isFile() && strtolower($languageItem->getExtension()) === 'md') {
1897+
$contentFiles[] = $languageItem->getPathname();
1898+
}
1899+
}
1900+
}
1901+
18711902
continue;
18721903
}
18731904

src/Content/Parser/ContentParser.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ public function parseStandalonePages(string $contentDir): Generator
129129
foreach ($iterator as $item) {
130130
/** @var SplFileInfo $item */
131131
if ($item->isDir()) {
132+
if ($this->isLanguageDirectory($item)) {
133+
yield from $this->parseMarkdownFiles($item->getPathname(), '', $item->getFilename());
134+
}
132135
continue;
133136
}
134137

@@ -177,6 +180,13 @@ public function parseEntries(string $contentDir, string $collectionName): Genera
177180
foreach ($iterator as $item) {
178181
/** @var SplFileInfo $item */
179182
if ($item->isDir()) {
183+
if ($this->isLanguageDirectory($item)) {
184+
yield from $this->parseMarkdownFiles(
185+
$item->getPathname(),
186+
$collectionName,
187+
$item->getFilename(),
188+
);
189+
}
180190
continue;
181191
}
182192

@@ -188,6 +198,25 @@ public function parseEntries(string $contentDir, string $collectionName): Genera
188198
}
189199
}
190200

201+
/**
202+
* @return Generator<Entry>
203+
*/
204+
private function parseMarkdownFiles(string $directory, string $collectionName, string $language): Generator
205+
{
206+
$iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
207+
foreach ($iterator as $item) {
208+
/** @var SplFileInfo $item */
209+
if ($item->isFile() && $item->getExtension() === 'md') {
210+
yield $this->entryParser->parse($item->getPathname(), $collectionName, $language);
211+
}
212+
}
213+
}
214+
215+
private function isLanguageDirectory(SplFileInfo $item): bool
216+
{
217+
return preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/D', $item->getFilename()) === 1;
218+
}
219+
191220
/**
192221
* @return Generator<string, Author>
193222
*/

src/Content/Parser/EntryParser.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public function __construct(
1818
private array $authors = [],
1919
) {}
2020

21-
public function parse(string $filePath, string $collectionName): Entry
21+
public function parse(string $filePath, string $collectionName, string $language = ''): Entry
2222
{
2323
$result = $this->frontMatterParser->parse($filePath);
2424
$fields = $result['frontMatter'];
@@ -93,7 +93,7 @@ public function parse(string $filePath, string $collectionName): Entry
9393
layout: (string) ($fields['layout'] ?? ''),
9494
theme: (string) ($fields['theme'] ?? ''),
9595
weight: (int) ($fields['weight'] ?? 0),
96-
language: (string) ($fields['language'] ?? ''),
96+
language: (string) ($fields['language'] ?? $language),
9797
redirectTo: (string) ($fields['redirect_to'] ?? ''),
9898
extra: isset($fields['extra']) && is_array($fields['extra'])
9999
? $fields['extra']

tests/Unit/Console/BuildCommandTest.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@ public function testBuildCanDisableAuthorPages(): void
10601060
$contentDir = $this->copyContentFixture();
10611061
file_put_contents($contentDir . '/config.yaml', "\nauthor_pages: false\n", FILE_APPEND);
10621062

1063-
$this->runBuild($contentDir);
1063+
$this->runBuild($contentDir, '--no-cache');
10641064

10651065
assertFalse(is_dir($this->outputDir . '/authors'));
10661066

@@ -1498,6 +1498,38 @@ public function testI18nEntryPermalinksAreConsistentAcrossGeneratedIndexes(): vo
14981498
assertStringContainsString('https://test.example.com/ru/blog/test-post/', $sitemap);
14991499
}
15001500

1501+
public function testLocalizedEntryDirectorySetsLanguageAndParticipatesInIncrementalBuilds(): void
1502+
{
1503+
$contentDir = $this->copyContentFixture();
1504+
$config = file_get_contents($contentDir . '/config.yaml');
1505+
assertNotFalse($config);
1506+
file_put_contents(
1507+
$contentDir . '/config.yaml',
1508+
str_replace('languages: ["en"]', 'languages: ["en", "ru"]', $config),
1509+
);
1510+
1511+
mkdir($contentDir . '/blog/ru');
1512+
$entryPath = $contentDir . '/blog/ru/2024-03-15-test-post.md';
1513+
rename($contentDir . '/blog/2024-03-15-test-post.md', $entryPath);
1514+
1515+
$this->runBuild($contentDir);
1516+
1517+
$outputPath = $this->outputDir . '/ru/blog/test-post/index.html';
1518+
assertFileExists($outputPath);
1519+
1520+
$entry = file_get_contents($entryPath);
1521+
assertNotFalse($entry);
1522+
file_put_contents($entryPath, str_replace('Test Post', 'Updated Russian Post', $entry));
1523+
1524+
$result = $this->runBuildResult($contentDir);
1525+
$html = file_get_contents($outputPath);
1526+
1527+
assertSame(0, $result['exitCode'], $result['output']);
1528+
assertStringContainsString('Incremental build', $result['output']);
1529+
assertNotFalse($html);
1530+
assertStringContainsString('Updated Russian Post', $html);
1531+
}
1532+
15011533
public function testBuildReportsInvalidEntryDateWithFilePath(): void
15021534
{
15031535
$contentDir = $this->createMinimalContent([

tests/Unit/Content/Parser/ContentParserTest.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,67 @@ public function testParseEntries(): void
9090
assertCount(7, $entries);
9191
}
9292

93+
public function testParseLocalizedCollectionEntriesFromLanguageDirectories(): void
94+
{
95+
$dir = sys_get_temp_dir() . '/yiipress-localized-collection-' . uniqid();
96+
mkdir($dir . '/blog/ru', 0o755, true);
97+
file_put_contents($dir . '/blog/_collection.yaml', "title: Blog\n");
98+
file_put_contents($dir . '/blog/post.md', "---\ntitle: Hello\n---\n");
99+
file_put_contents($dir . '/blog/ru/post.md', "---\ntitle: Привет\n---\n");
100+
file_put_contents(
101+
$dir . '/blog/ru/override.md',
102+
"---\ntitle: Override\nlanguage: uk\n---\n",
103+
);
104+
105+
try {
106+
$entries = iterator_to_array($this->parser->parseEntries($dir, 'blog'), false);
107+
$byTitle = [];
108+
foreach ($entries as $entry) {
109+
$byTitle[$entry->title] = $entry;
110+
}
111+
112+
assertCount(3, $entries);
113+
assertSame('', $byTitle['Hello']->language);
114+
assertSame('ru', $byTitle['Привет']->language);
115+
assertSame('uk', $byTitle['Override']->language);
116+
assertSame('post', $byTitle['Привет']->slug);
117+
} finally {
118+
unlink($dir . '/blog/ru/override.md');
119+
unlink($dir . '/blog/ru/post.md');
120+
unlink($dir . '/blog/post.md');
121+
unlink($dir . '/blog/_collection.yaml');
122+
rmdir($dir . '/blog/ru');
123+
rmdir($dir . '/blog');
124+
rmdir($dir);
125+
}
126+
}
127+
128+
public function testParseLocalizedStandalonePagesFromLanguageDirectories(): void
129+
{
130+
$dir = sys_get_temp_dir() . '/yiipress-localized-pages-' . uniqid();
131+
mkdir($dir . '/cs', 0o755, true);
132+
file_put_contents($dir . '/about.md', "---\ntitle: About\n---\n");
133+
file_put_contents($dir . '/cs/about.md', "---\ntitle: O nás\n---\n");
134+
135+
try {
136+
$entries = iterator_to_array($this->parser->parseStandalonePages($dir), false);
137+
$byTitle = [];
138+
foreach ($entries as $entry) {
139+
$byTitle[$entry->title] = $entry;
140+
}
141+
142+
assertCount(2, $entries);
143+
assertSame('cs', $byTitle['O nás']->language);
144+
assertSame('', $byTitle['About']->language);
145+
assertSame('about', $byTitle['O nás']->slug);
146+
} finally {
147+
unlink($dir . '/cs/about.md');
148+
unlink($dir . '/about.md');
149+
rmdir($dir . '/cs');
150+
rmdir($dir);
151+
}
152+
}
153+
93154
public function testParseAuthors(): void
94155
{
95156
$authors = iterator_to_array($this->parser->parseAuthors($this->dataDir));

0 commit comments

Comments
 (0)