File size: 7,100 Bytes
dc2b30a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
/**
 * Build-time docs index.
 *
 * `import.meta.glob` pulls every Markdown file under `../../docs/` (this
 * project's own copy of the Exocore documentation tree) into the bundle as
 * a raw string. We then turn each path into a routable doc record with a
 * slug, title, section, breadcrumb, and full body — all available
 * synchronously to the search box on the home page (no extra network
 * round-trip on a static host).
 *
 * The docs tree is now fully self-contained inside `exocore-docs/docs/` so
 * this site can be deployed independently of the `exocore-web` workspace.
 */
const RAW = import.meta.glob("../../docs/**/*.md", {
    query:  "?raw",
    import: "default",
    eager:  true,
}) as Record<string, string>;

export interface DocRecord {
    /** Stable URL slug, e.g. `editor/` for docs/editor/README.md or `editor/sub` for docs/editor/sub.md. */
    slug:        string;
    /** First H1 in the file, falling back to the directory name. */
    title:       string;
    /** Top-level section folder (panel / auth / dashboard / …). `""` for the root README. */
    section:     string;
    /** Pretty breadcrumb segments like `["Editor"]` or `["Editor", "Languages"]`. */
    breadcrumbs: string[];
    /** Full markdown body — exactly what shipped on disk. */
    body:        string;
    /** First paragraph after the H1 (used as a search-result preview). */
    excerpt:     string;
    /** Lower-cased, whitespace-collapsed copy of the body for fast `includes` search. */
    haystack:    string;
}

const FOLDER_LABELS: Record<string, string> = {
    panel:       "Panel Gate",
    auth:        "Auth",
    dashboard:   "Dashboard",
    profile:     "Profile",
    social:      "Social",
    leaderboard: "Leaderboard",
    projects:    "Projects",
    cloud:       "Cloud Drive",
    github:      "GitHub",
    editor:      "Editor / IDE",
    screenshots: "Screenshots",
};

function prettify(name: string): string {
    if (FOLDER_LABELS[name]) return FOLDER_LABELS[name];
    return name
        .replace(/[-_]+/g, " ")
        .replace(/\b\w/g, (c) => c.toUpperCase());
}

function firstHeading(md: string): string | null {
    const m = md.match(/^\s*#\s+(.+?)\s*$/m);
    return m ? m[1].trim() : null;
}

function firstParagraph(md: string): string {
    // Strip H1, blockquotes, code fences, and tables, then grab the first
    // non-empty paragraph.
    const trimmed = md
        .replace(/^\s*#\s+.+$/m, "")
        .replace(/^>.*$/gm, "")
        .replace(/```[\s\S]*?```/g, "")
        .replace(/^\|.*\|$/gm, "")
        .trim();
    const para = trimmed.split(/\n{2,}/).find((p) => p.trim().length > 0) || "";
    return para.replace(/\s+/g, " ").slice(0, 220);
}

function buildIndex(): DocRecord[] {
    const records: DocRecord[] = [];
    for (const [absPath, body] of Object.entries(RAW)) {
        // absPath ends with /exocore-web/docs/<...>.md — strip everything up
        // to and including `/docs/` to get the relative path.
        const m = absPath.match(/\/docs\/(.+\.md)$/i);
        if (!m) continue;
        const rel = m[1];

        // Compute slug: README.md collapses to its folder, anything else
        // keeps its own filename (sans .md).
        const segments = rel.split("/");
        const file     = segments.pop()!;
        let slug: string;
        if (/^readme\.md$/i.test(file)) {
            slug = segments.join("/") + "/";
            if (slug === "/") slug = "";
        } else {
            slug = [...segments, file.replace(/\.md$/i, "")].join("/");
        }

        const section = segments[0] || "";
        const heading = firstHeading(body);
        const title   = heading || (segments.length ? prettify(segments[segments.length - 1]) : "Overview");
        const breadcrumbs = segments.length ? segments.map(prettify) : ["Overview"];
        const excerpt  = firstParagraph(body);
        const haystack = (title + " " + body).toLowerCase().replace(/\s+/g, " ");

        records.push({ slug, title, section, breadcrumbs, body, excerpt, haystack });
    }

    // Stable order: root first, then alphabetical by slug.
    records.sort((a, b) => {
        if (a.slug === "" || a.slug === "/") return -1;
        if (b.slug === "" || b.slug === "/") return  1;
        return a.slug.localeCompare(b.slug);
    });

    return records;
}

export const DOCS: DocRecord[] = buildIndex();

export function findBySlug(slug: string): DocRecord | undefined {
    const normalized = slug.replace(/^\/+/, "").replace(/\/{2,}/g, "/");
    return (
        DOCS.find((d) => d.slug === normalized) ||
        // try with trailing slash variant
        DOCS.find((d) => d.slug === normalized + "/") ||
        DOCS.find((d) => d.slug.replace(/\/$/, "") === normalized.replace(/\/$/, ""))
    );
}

/** Group docs into top-level sections for the sidebar / home grid. */
export interface DocSection {
    id:    string;
    label: string;
    docs:  DocRecord[];
}

export function sectionsView(): DocSection[] {
    const map = new Map<string, DocRecord[]>();
    for (const d of DOCS) {
        const id = d.section || "_root";
        if (!map.has(id)) map.set(id, []);
        map.get(id)!.push(d);
    }
    const sections: DocSection[] = [];
    for (const [id, docs] of map.entries()) {
        sections.push({
            id,
            label: id === "_root" ? "Overview" : prettify(id),
            docs,
        });
    }
    sections.sort((a, b) => {
        if (a.id === "_root") return -1;
        if (b.id === "_root") return  1;
        return a.label.localeCompare(b.label);
    });
    return sections;
}

export interface SearchHit {
    doc:     DocRecord;
    score:   number;
    snippet: string;
}

const SNIPPET_RADIUS = 80;

/** Naive but fast in-memory search: tokenise the query, score by total
 *  hits across title + body, and return a snippet around the first hit. */
export function search(query: string, limit = 25): SearchHit[] {
    const q = query.trim().toLowerCase();
    if (!q) return [];

    const terms = q.split(/\s+/).filter(Boolean);
    const hits: SearchHit[] = [];

    for (const doc of DOCS) {
        let score = 0;
        let firstIdx = -1;
        for (const t of terms) {
            const titleMatch = doc.title.toLowerCase().includes(t);
            const bodyIdx    = doc.haystack.indexOf(t);
            if (titleMatch) score += 5;
            if (bodyIdx >= 0) {
                score += 1;
                if (firstIdx === -1 || bodyIdx < firstIdx) firstIdx = bodyIdx;
            }
        }
        if (score === 0) continue;

        let snippet = doc.excerpt;
        if (firstIdx >= 0) {
            const start = Math.max(0, firstIdx - SNIPPET_RADIUS);
            const end   = Math.min(doc.haystack.length, firstIdx + SNIPPET_RADIUS);
            snippet = (start > 0 ? "… " : "") + doc.haystack.slice(start, end).trim() + (end < doc.haystack.length ? " …" : "");
        }
        hits.push({ doc, score, snippet });
    }

    hits.sort((a, b) => b.score - a.score || a.doc.title.localeCompare(b.doc.title));
    return hits.slice(0, limit);
}