diff --git a/Modules/Documentation/Console/ImportDocumentationCommand.php b/Modules/Documentation/Console/ImportDocumentationCommand.php new file mode 100644 index 0000000..2f0d594 --- /dev/null +++ b/Modules/Documentation/Console/ImportDocumentationCommand.php @@ -0,0 +1,569 @@ + +resolvePath(); + + $categoryName = trim((string) $this->option('category')); + + if ($categoryName === '') { + + $this->components->error('The documentation category name cannot be empty.'); + + return self::FAILURE; + + } + + if (! is_dir($path)) { + + $this->components->error("Documentation directory does not exist: {$path}"); + + return self::FAILURE; + + } + + if (! is_readable($path)) { + + $this->components->error("Documentation directory is not readable: {$path}"); + + return self::FAILURE; + + } + + $files = $this->markdownFiles($path); + + if ($files === []) { + + $this->components->warn("No Markdown files were found in: {$path}"); + + return self::SUCCESS; + + } + + try { + + $category = DB::transaction(function () use ($categoryName): DocumentationCategory { + + $category = DocumentationCategory::query()->firstOrCreate( + + ['slug' => Str::slug($categoryName)], + + [ + + 'name' => $categoryName, + + 'description' => 'Imported documentation.', + + 'sort_order' => 0, + + 'is_active' => true, + + ], + + ); + + if ($category->name !== $categoryName) { + + $category->name = $categoryName; + + $category->save(); + + } + + if ($this->option('fresh')) { + + /* + + * Force-delete because DocumentationDocument uses SoftDeletes. + + * Otherwise the unique slug constraint can prevent the same + + * documentation set being imported again. + + */ + + $category->documents() + + ->withTrashed() + + ->get() + + ->each(static function (DocumentationDocument $document): void { + + $document->forceDelete(); + + }); + + } + + return $category; + + }); + + $progress = $this->output->createProgressBar(count($files)); + + $progress->start(); + + $imported = 0; + + foreach ($files as $file) { + + $this->importFile($category, $file, $path); + + ++$imported; + + $progress->advance(); + + } + + $progress->finish(); + + $this->newLine(2); + + $this->components->info( + + sprintf( + + 'Imported %d documentation %s into "%s".', + + $imported, + + $imported === 1 ? 'page' : 'pages', + + $category->name, + + ), + + ); + + return self::SUCCESS; + + } catch (Throwable $exception) { + + $this->newLine(); + + $this->components->error( + + 'Documentation import failed: ' . $exception->getMessage(), + + ); + + if ($this->output->isVerbose()) { + + $this->newLine(); + + $this->line($exception->getTraceAsString()); + + } + + return self::FAILURE; + + } + + } + + /** + + * Resolve the requested documentation source directory. + + */ + + private function resolvePath(): string + + { + + $argument = $this->argument('path'); + + if (is_string($argument) && trim($argument) !== '') { + + $path = trim($argument); + + if (! str_starts_with($path, DIRECTORY_SEPARATOR)) { + + $path = base_path($path); + + } + + return rtrim($path, DIRECTORY_SEPARATOR); + + } + + return module_path('Documentation', 'ImportData/docs'); + + } + + /** + + * Find Markdown files recursively. + + * + + * @return list + + */ + + private function markdownFiles(string $path): array + + { + + $finder = Finder::create() + + ->files() + + ->in($path) + + ->name('*.md') + + ->name('*.markdown') + + ->sortByName(); + + /** @var list $files */ + + $files = iterator_to_array($finder, false); + + return $files; + + } + + /** + + * Import one Markdown document. + + */ + + private function importFile( + + DocumentationCategory $category, + + SplFileInfo $file, + + string $rootPath, + + ): void { + + $realPath = $file->getRealPath(); + + if ($realPath === false || ! is_readable($realPath)) { + + throw new \RuntimeException( + + sprintf('Unable to read documentation file: %s', $file->getPathname()), + + ); + + } + + $body = file_get_contents($realPath); + + if ($body === false) { + + throw new \RuntimeException( + + sprintf('Unable to read documentation file: %s', $realPath), + + ); + + } + + $body = $this->stripBom($body); + + $relativePath = ltrim( + + str_replace( + + DIRECTORY_SEPARATOR, + + '/', + + substr($realPath, strlen(rtrim($rootPath, DIRECTORY_SEPARATOR))), + + ), + + '/', + + ); + + $title = $this->extractTitle($body, $file); + + $summary = $this->extractSummary($body); + + $slug = $this->makeSlug($relativePath, $title); + + DocumentationDocument::withTrashed()->updateOrCreate( + + ['slug' => $slug], + + [ + + 'documentation_category_id' => $category->id, + + 'title' => $title, + + 'summary' => $summary, + + 'body' => $body, + + 'status' => DocumentationDocument::STATUS_PUBLISHED, + + 'is_featured' => strtolower($file->getBasename('.' . $file->getExtension())) === 'index', + + 'version' => '1.0.0', + + 'reference' => Str::limit($relativePath, 100, ''), + + 'published_at' => now(), + + 'deleted_at' => null, + + ], + + ); + + } + + /** + + * Extract the first Markdown H1 heading. + + */ + + private function extractTitle(string $body, SplFileInfo $file): string + + { + + if (preg_match('/^\s*#\s+(.+?)\s*$/m', $body, $matches) === 1) { + + $title = trim($matches[1]); + + $title = preg_replace('/\s+#+\s*$/', '', $title) ?? $title; + + $title = trim($title); + + if ($title !== '') { + + return Str::limit($title, 220, ''); + + } + + } + + $filename = pathinfo($file->getFilename(), PATHINFO_FILENAME); + + return Str::limit( + + Str::headline(str_replace(['-', '_'], ' ', $filename)), + + 220, + + '', + + ); + + } + + /** + + * Extract a useful summary from the first normal Markdown paragraph. + + */ + + private function extractSummary(string $body): ?string + + { + + $lines = preg_split('/\R/', $body); + + if (! is_array($lines)) { + + return null; + + } + + $paragraph = []; + + foreach ($lines as $line) { + + $line = trim($line); + + if ($line === '') { + + if ($paragraph !== []) { + + break; + + } + + continue; + + } + + if ( + + str_starts_with($line, '#') + + || str_starts_with($line, '```') + + || str_starts_with($line, '~~~') + + || str_starts_with($line, '>') + + || preg_match('/^[-*+]\s+/', $line) === 1 + + || preg_match('/^\d+\.\s+/', $line) === 1 + + ) { + + if ($paragraph !== []) { + + break; + + } + + continue; + + } + + $paragraph[] = $line; + + } + + if ($paragraph === []) { + + return null; + + } + + $summary = implode(' ', $paragraph); + + $summary = preg_replace('/!$begin:math:display$\(\[\^$end:math:display$]*)\]$begin:math:text$\[\^\)\]\+$end:math:text$/', '$1', $summary) ?? $summary; + + $summary = preg_replace('/$begin:math:display$\(\[\^$end:math:display$]+)\]$begin:math:text$\[\^\)\]\+$end:math:text$/', '$1', $summary) ?? $summary; + + $summary = str_replace(['**', '__', '`', '*', '_'], '', $summary); + + $summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary); + + return $summary === '' + + ? null + + : Str::limit($summary, 500, ''); + + } + + /** + + * Generate a stable globally unique slug from the source path. + + */ + + private function makeSlug(string $relativePath, string $title): string + + { + + $withoutExtension = preg_replace( + + '/\.(md|markdown)$/i', + + '', + + $relativePath, + + ) ?? $relativePath; + + $slug = Str::slug(str_replace('/', '-', $withoutExtension)); + + if ($slug === '') { + + $slug = Str::slug($title); + + } + + if ($slug === '') { + + $slug = 'document-' . substr(sha1($relativePath), 0, 12); + + } + + return Str::limit($slug, 240, ''); + + } + + /** + + * Remove a UTF-8 byte-order mark. + + */ + + private function stripBom(string $contents): string + + { + + if (str_starts_with($contents, "\xEF\xBB\xBF")) { + + return substr($contents, 3); + + } + + return $contents; + + } + +} +