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; } }