config = $config ?? config(ManualConfig::class); $this->converter = new GithubFlavoredMarkdownConverter([ // 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다. 'html_input' => 'escape', 'allow_unsafe_links' => false, 'max_nesting_level' => 50, ]); } /** * 목차(slug → title/file). 배열 순서가 노출 순서. * * @return array */ public function pages(): array { return $this->config->pages; } /** 목차의 첫 번째 slug (기본 진입 페이지). */ public function firstSlug(): string { return (string) (array_key_first($this->config->pages) ?? ''); } /** * slug 메타 조회. 화이트리스트에 없으면 null. * * @return array{title: string, file: string}|null */ public function find(string $slug): ?array { return $this->config->pages[$slug] ?? null; } /** * 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''. */ public function plainText(string $slug): string { $html = $this->render($slug); if ($html === null || $html === '') { return ''; } $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim((string) preg_replace('/\s+/u', ' ', $text)); } /** * 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순). * * @return list */ public function search(string $q): array { $q = trim($q); if ($q === '' || mb_strlen($q) < 1) { return []; } $needle = mb_strtolower($q); $out = []; foreach ($this->pages() as $slug => $page) { $text = $this->plainText((string) $slug); if ($text === '') { continue; } $hay = mb_strtolower($text); $pos = mb_strpos($hay, $needle); if ($pos === false) { continue; } $hits = mb_substr_count($hay, $needle); $start = max(0, $pos - 30); $snippet = mb_substr($text, $start, 100); if ($start > 0) { $snippet = '…' . $snippet; } $out[] = [ 'slug' => (string) $slug, 'title' => (string) $page['title'], 'snippet' => trim($snippet), 'hits' => $hits, ]; } usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']); return $out; } /** * slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null. */ public function render(string $slug): ?string { $page = $this->find($slug); if ($page === null) { return null; } $path = $this->resolvePath($page['file']); if ($path === null) { return null; } $markdown = @file_get_contents($path); if ($markdown === false) { return null; } try { return (string) $this->converter->convert($markdown); } catch (CommonMarkException) { return null; } } /** * 파일명을 디렉터리 경계 안으로 안전하게 해석한다. * manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다. */ private function resolvePath(string $file): ?string { $baseReal = realpath(rtrim($this->config->dir, '/\\')); if ($baseReal === false) { return null; } $candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file); if ($candidate === false || ! is_file($candidate)) { return null; } $prefix = $baseReal . DIRECTORY_SEPARATOR; if (! str_starts_with($candidate, $prefix)) { return null; } return $candidate; } }