199 lines
6.8 KiB
PHP
199 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Help;
|
|
|
|
use Illuminate\Filesystem\Filesystem;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use League\CommonMark\Environment\Environment;
|
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
|
use League\CommonMark\Extension\Table\TableExtension;
|
|
use League\CommonMark\MarkdownConverter;
|
|
use RuntimeException;
|
|
use Symfony\Component\Finder\SplFileInfo;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
class HelpSyncService
|
|
{
|
|
private MarkdownConverter $converter;
|
|
|
|
public function __construct(private readonly Filesystem $files)
|
|
{
|
|
$environment = new Environment;
|
|
$environment->addExtension(new CommonMarkCoreExtension);
|
|
$environment->addExtension(new TableExtension);
|
|
$this->converter = new MarkdownConverter($environment);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<string, int>>
|
|
*/
|
|
public function sync(): array
|
|
{
|
|
$sourcePath = base_path(config('help.source_path'));
|
|
|
|
if (! $this->files->exists($sourcePath)) {
|
|
throw new RuntimeException('Help source directory not found: '.$sourcePath);
|
|
}
|
|
|
|
$articles = collect();
|
|
|
|
$audiences = config('help.audiences', []);
|
|
$localeDirectories = $this->files->directories($sourcePath);
|
|
$useLocaleDirectories = ! empty($localeDirectories);
|
|
|
|
$audienceRoots = $useLocaleDirectories
|
|
? array_map(static fn (string $path) => $path.DIRECTORY_SEPARATOR, $localeDirectories)
|
|
: [$sourcePath.DIRECTORY_SEPARATOR];
|
|
|
|
foreach ($audienceRoots as $root) {
|
|
foreach ($audiences as $audience) {
|
|
$audiencePath = $root.$audience;
|
|
|
|
if (! $this->files->isDirectory($audiencePath)) {
|
|
continue;
|
|
}
|
|
|
|
$files = $this->files->allFiles($audiencePath);
|
|
|
|
/** @var SplFileInfo $file */
|
|
foreach ($files as $file) {
|
|
if ($file->getExtension() !== 'md') {
|
|
continue;
|
|
}
|
|
|
|
$parsed = $this->parseFile($file);
|
|
$articles->push($parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
$articles = $this->hydrateRelatedTitles($articles);
|
|
|
|
$disk = config('help.disk');
|
|
$compiledPath = trim(config('help.compiled_path'), '/');
|
|
$written = [];
|
|
|
|
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
|
[$audience, $locale] = explode('::', $key);
|
|
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
|
$directory = sprintf('%s/%s/%s', $compiledPath, $audience, $locale);
|
|
Storage::disk($disk)->makeDirectory($directory);
|
|
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
Cache::forget($this->cacheKey($audience, $locale));
|
|
$written[$audience][$locale] = $group->count();
|
|
}
|
|
|
|
return $written;
|
|
}
|
|
|
|
private function hydrateRelatedTitles(Collection $articles): Collection
|
|
{
|
|
$titleIndex = $articles->mapWithKeys(function (array $article) {
|
|
$audience = Arr::get($article, 'audience');
|
|
$locale = Arr::get($article, 'locale');
|
|
$slug = Arr::get($article, 'slug');
|
|
|
|
if (! $audience || ! $locale || ! $slug) {
|
|
return [];
|
|
}
|
|
|
|
return [$this->articleKey((string) $audience, (string) $locale, (string) $slug) => Arr::get($article, 'title')];
|
|
});
|
|
|
|
return $articles->map(function (array $article) use ($titleIndex) {
|
|
$related = Arr::get($article, 'related', []);
|
|
|
|
if (empty($related)) {
|
|
return $article;
|
|
}
|
|
|
|
$audience = (string) Arr::get($article, 'audience');
|
|
$locale = (string) Arr::get($article, 'locale');
|
|
|
|
$article['related'] = collect($related)
|
|
->map(function ($item) use ($titleIndex, $audience, $locale) {
|
|
$slug = is_array($item) ? Arr::get($item, 'slug') : (is_string($item) ? $item : null);
|
|
|
|
if (! $slug) {
|
|
return null;
|
|
}
|
|
|
|
$title = $titleIndex->get($this->articleKey($audience, $locale, $slug));
|
|
|
|
return array_filter([
|
|
'slug' => $slug,
|
|
'title' => $title,
|
|
], static fn ($value) => $value !== null && $value !== '');
|
|
})
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
return $article;
|
|
});
|
|
}
|
|
|
|
private function articleKey(string $audience, string $locale, string $slug): string
|
|
{
|
|
return sprintf('%s::%s::%s', $audience, $locale, $slug);
|
|
}
|
|
|
|
private function parseFile(SplFileInfo $file): array
|
|
{
|
|
$contents = $this->files->get($file->getPathname());
|
|
|
|
if (! Str::startsWith($contents, "---\n")) {
|
|
throw new RuntimeException('Missing front matter in '.$file->getPathname());
|
|
}
|
|
|
|
$pattern = '/^---\s*\n(?P<yaml>.*?)-{3}\s*\n(?P<body>.*)$/s';
|
|
|
|
if (! preg_match($pattern, $contents, $matches)) {
|
|
throw new RuntimeException('Unable to parse front matter for '.$file->getPathname());
|
|
}
|
|
|
|
$frontMatter = Yaml::parse(trim($matches['yaml'] ?? '')) ?? [];
|
|
$frontMatter = array_map(static fn ($value) => $value ?? null, $frontMatter);
|
|
|
|
$this->validateFrontMatter($frontMatter, $file->getPathname());
|
|
|
|
$body = trim($matches['body'] ?? '');
|
|
$html = trim($this->converter->convert($body)->getContent());
|
|
$updatedAt = now()->setTimestamp($file->getMTime())->toISOString();
|
|
|
|
return array_merge($frontMatter, [
|
|
'body_markdown' => $body,
|
|
'body_html' => $html,
|
|
'source_path' => $file->getRelativePathname(),
|
|
'updated_at' => $updatedAt,
|
|
]);
|
|
}
|
|
|
|
private function validateFrontMatter(array $frontMatter, string $path): void
|
|
{
|
|
$required = config('help.required_front_matter', []);
|
|
|
|
foreach ($required as $key) {
|
|
if (! Arr::exists($frontMatter, $key)) {
|
|
throw new RuntimeException(sprintf('Missing front matter key "%s" in %s', $key, $path));
|
|
}
|
|
}
|
|
|
|
$audiences = config('help.audiences', []);
|
|
$audience = $frontMatter['audience'];
|
|
|
|
if (! in_array($audience, $audiences, true)) {
|
|
throw new RuntimeException(sprintf('Invalid audience "%s" in %s', $audience, $path));
|
|
}
|
|
}
|
|
|
|
private function cacheKey(string $audience, string $locale): string
|
|
{
|
|
return sprintf('help.%s.%s', $audience, $locale);
|
|
}
|
|
}
|