defaultSlug(); $locale = $this->locale(); $page = $this->page($slug, $locale); $markdown = File::get($page['file']); return Inertia::render('documentation/show', [ 'document' => [ 'slug' => $slug, 'locale' => $locale, 'title' => $page['title'], 'description' => $page['description'], 'html' => $this->html($markdown, $locale), ], 'navigation' => $this->navigation($slug, $locale), 'languages' => $this->languageLinks($slug, $locale), ]); } private function defaultSlug(): string { $slug = config('documentation.default'); if (! is_string($slug) || $slug === '') { throw new NotFoundHttpException; } return $slug; } private function locale(): string { $locale = App::currentLocale(); if (array_key_exists($locale, $this->supportedLocales())) { return $locale; } return $this->fallbackLocale(); } private function fallbackLocale(): string { $locale = config('documentation.fallback_locale', 'en'); return is_string($locale) && $locale !== '' ? $locale : 'en'; } /** * @return array */ private function supportedLocales(): array { $locales = config('documentation.locales', []); if (! is_array($locales)) { return []; } return collect($locales) ->mapWithKeys(fn (mixed $label, string $locale): array => [$locale => (string) $label]) ->all(); } /** * @return array{title: string, description: string, file: string} */ private function page(string $slug, string $locale): array { $page = config("documentation.pages.{$slug}"); if (! is_array($page) || ! isset($page['title'], $page['description'], $page['file'])) { throw new NotFoundHttpException; } $file = $this->localizedValue($page['file'], $locale); if (! File::exists($file)) { throw new NotFoundHttpException; } return [ 'title' => $this->localizedValue($page['title'], $locale), 'description' => $this->localizedValue($page['description'], $locale), 'file' => $file, ]; } private function localizedValue(mixed $value, string $locale): string { if (is_array($value)) { $fallbackLocale = $this->fallbackLocale(); $localized = $value[$locale] ?? $value[$fallbackLocale] ?? null; if (is_string($localized)) { return $localized; } throw new NotFoundHttpException; } if (! is_string($value)) { throw new NotFoundHttpException; } return $value; } private function html(string $markdown, string $locale): string { $cardBlocks = $this->extractCardBlocks($markdown); $headings = $this->headings($markdown); $html = (string) Str::of($cardBlocks['markdown'])->markdown([ 'html_input' => 'strip', 'allow_unsafe_links' => false, ]); $html = $this->replaceTocPlaceholder($html, $headings, $locale); $html = $this->replaceCardPlaceholders($html, $cardBlocks['html']); return $this->addHeadingIds($html, $headings); } /** * @return array{markdown: string, html: array} */ private function extractCardBlocks(string $markdown): array { $cardBlocks = []; $output = []; $lines = preg_split('/\R/', $markdown) ?: []; $index = 0; $insideWrapper = false; $insideCard = false; $wrapperCards = []; $cardLines = []; foreach ($lines as $line) { if (! $insideWrapper && trim($line) === '
') { $insideWrapper = true; $wrapperCards = []; continue; } if (! $insideWrapper) { $output[] = $line; continue; } if (! $insideCard && trim($line) === '
') { $insideCard = true; $cardLines = []; continue; } if (trim($line) === '
') { if ($insideCard) { $insideCard = false; $wrapperCards[] = trim(implode("\n", $cardLines)); $cardLines = []; continue; } $placeholder = "DOCUMENTATION_CARDS_{$index}"; $cardBlocks[$placeholder] = $this->cardsHtml($wrapperCards); $output[] = $placeholder; $insideWrapper = false; $wrapperCards = []; $index++; continue; } if ($insideCard) { $cardLines[] = $line; } } return [ 'markdown' => implode("\n", $output), 'html' => $cardBlocks, ]; } /** * @param array $cards */ private function cardsHtml(array $cards): string { $items = collect($cards) ->filter() ->map(fn (string $card): string => '
'.(string) Str::of($card)->markdown([ 'html_input' => 'strip', 'allow_unsafe_links' => false, ]).'
') ->implode(''); if ($items === '') { return ''; } return '
'.$items.'
'; } /** * @param array $cardBlocks */ private function replaceCardPlaceholders(string $html, array $cardBlocks): string { foreach ($cardBlocks as $placeholder => $cardHtml) { $html = str_replace(["

{$placeholder}

", $placeholder], $cardHtml, $html); } return $html; } /** * @return array */ private function headings(string $markdown): array { preg_match_all('/^(#{1,6})\s+(.+?)\s*#*\s*$/m', $markdown, $matches, PREG_SET_ORDER); $headings = []; $usedSlugs = []; $levels = $this->tocLevels(); foreach ($matches as $match) { $level = strlen($match[1]); if (! in_array($level, $levels, true)) { continue; } $title = $this->plainHeadingText($match[2]); $headings[] = [ 'level' => $level, 'title' => $title, 'id' => $this->uniqueHeadingId($title, $usedSlugs), ]; } return $headings; } /** * @return array */ private function tocLevels(): array { $levels = config('documentation.toc.levels', [2, 3]); if (! is_array($levels)) { return [2, 3]; } return collect($levels) ->map(fn (mixed $level): int => (int) $level) ->filter(fn (int $level): bool => $level >= 1 && $level <= 6) ->unique() ->sort() ->values() ->all(); } private function plainHeadingText(string $heading): string { $html = (string) Str::of($heading)->inlineMarkdown([ 'html_input' => 'strip', 'allow_unsafe_links' => false, ]); return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } /** * @param array $usedSlugs */ private function uniqueHeadingId(string $title, array &$usedSlugs): string { $base = Str::slug($title); if ($base === '') { $base = 'section'; } $usedSlugs[$base] = ($usedSlugs[$base] ?? 0) + 1; if ($usedSlugs[$base] === 1) { return $base; } return "{$base}-{$usedSlugs[$base]}"; } /** * @param array $headings */ private function replaceTocPlaceholder(string $html, array $headings, string $locale): string { $placeholder = config('documentation.toc.placeholder', '{{TOC}}'); if (! is_string($placeholder) || $placeholder === '') { return $html; } return str_replace( ["

{$placeholder}

", $placeholder], $this->tocHtml($headings, $locale), $html, ); } /** * @param array $headings */ private function tocHtml(array $headings, string $locale): string { if ($headings === []) { return ''; } $items = collect($this->numberedHeadings($headings)) ->map(fn (array $heading): string => sprintf( '
  • %s %s
  • ', $heading['level'], e($heading['id']), e($heading['number']), e($heading['title']), )) ->implode(''); return ''; } private function tocTitle(string $locale): string { return e($this->localizedValue(config('documentation.toc.title', 'On this page'), $locale)); } /** * @param array $headings * @return array */ private function numberedHeadings(array $headings): array { $levels = $this->tocLevels(); $counts = []; return collect($headings) ->map(function (array $heading) use ($levels, &$counts): array { foreach ($levels as $level) { if ($level > $heading['level']) { unset($counts[$level]); } } foreach ($levels as $level) { if ($level < $heading['level'] && ! isset($counts[$level])) { $counts[$level] = 1; } } $counts[$heading['level']] = ($counts[$heading['level']] ?? 0) + 1; $number = collect($levels) ->filter(fn (int $level): bool => $level <= $heading['level'] && isset($counts[$level])) ->map(fn (int $level): int => $counts[$level]) ->implode('.'); return [...$heading, 'number' => $number]; }) ->all(); } /** * @param array $headings */ private function addHeadingIds(string $html, array $headings): string { $levels = $this->tocLevels(); if ($headings === [] || $levels === []) { return $html; } $levelPattern = implode('', $levels); $headingIndex = 0; return (string) preg_replace_callback( "/(.*?)<\/h\\1>/s", function (array $match) use ($headings, &$headingIndex): string { $heading = $headings[$headingIndex] ?? null; $headingIndex++; if ($heading === null) { return $match[0]; } return sprintf( '%s', (int) $match[1], e($heading['id']), $match[2], (int) $match[1], ); }, $html, ); } /** * @return array */ private function navigation(string $activeSlug, string $locale): array { $pages = config('documentation.pages', []); if (! is_array($pages)) { return []; } return collect($pages) ->map(fn (array $page, string $slug): array => [ 'slug' => $slug, 'title' => $this->localizedValue($page['title'] ?? '', $locale), 'url' => route('documentation.show', ['slug' => $slug, 'lang' => $locale], false), 'active' => $slug === $activeSlug, ]) ->values() ->all(); } /** * @return array */ private function languageLinks(string $slug, string $activeLocale): array { return collect($this->supportedLocales()) ->map(fn (string $label, string $locale): array => [ 'locale' => $locale, 'label' => $label, 'url' => route('documentation.show', ['slug' => $slug, 'lang' => $locale], false), 'active' => $locale === $activeLocale, ]) ->values() ->all(); } }