-
-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathplugin-utils.ts
More file actions
169 lines (156 loc) · 5.62 KB
/
Copy pathplugin-utils.ts
File metadata and controls
169 lines (156 loc) · 5.62 KB
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
import {execFileSync} from 'node:child_process';
import {readdirSync, realpathSync, statSync} from 'node:fs';
import {isAbsolute, join, relative, resolve} from 'node:path';
import frontMatter from 'front-matter';
export {createSlugger, headingText, slugify} from './src/app/utils/heading-slug.ts';
/**
* Shared helpers for the build-time Vite plugins (`page-meta`, `sitemap`,
* `link-guard`, `search-index`). Every plugin walks `src/content/**\/*.md`
* and `src/app/pages/**\/*.page.ts` the same way; centralising those walks
* here keeps the discovery rules in sync.
*
* Routes are derived from filesystem paths:
* - `.md` under `src/content/`: `src/content/concepts/theming.md` → `/concepts/theming`
* - `.page.ts` under `src/app/pages/`: `home/index.page.ts` → `/home`,
* `index.page.ts` → `/`, dynamic / catch-all (`[...slug].page.ts`) → '' (skipped)
*
* `slugify` and `createSlugger` are re-exported from the runtime TOC's
* heading-id module so build-time link validation and runtime fragments
* stay aligned.
*/
/** Walk `src/app/pages/**\/*.page.ts` and return paths relative to `root`. */
export function walkPageFiles(dir: string, root: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir, {withFileTypes: true})) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walkPageFiles(full, root, out);
} else if (entry.isFile() && entry.name.endsWith('.page.ts')) {
out.push(relative(root, full));
}
}
return out;
}
/**
* Walk `src/content/**\/*.md` and return `[relativePath, route]` pairs.
* `relativePath` is from `root`; `route` mirrors the path under `baseDir`
* (defaults to `dir`) with the `.md` stripped.
*/
export function walkContentFiles(
dir: string,
root: string,
baseDir: string = dir,
out: Array<[string, string]> = [],
): Array<[string, string]> {
for (const entry of readdirSync(dir, {withFileTypes: true})) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walkContentFiles(full, root, baseDir, out);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
const rel = relative(root, full);
const fromContent = relative(baseDir, full)
.replace(/\\/g, '/')
.replace(/\.md$/, '')
.replace(/(^|\/)index$/, '');
out.push([rel, '/' + fromContent]);
}
}
return out;
}
/** `src/app/pages/foo/bar.page.ts` → `/foo/bar`. `index.page.ts` → `/`.
* Dynamic / catch-all (`[...slug].page.ts`) returns `''`, signalling "skip". */
export function routeFromPagePath(rel: string): string {
const segments = pageRouteSegments(rel);
if (!segments || segments.some((s) => s.startsWith('['))) return '';
return '/' + segments.join('/');
}
export function pageRouteMatcher(rel: string): RegExp | null {
const segments = pageRouteSegments(rel);
if (!segments || !segments.some((s) => s.startsWith('['))) return null;
const pattern = segments
.map((s) => (s.startsWith('[') ? '[^/]+' : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
.join('/');
return new RegExp(`^/${pattern}$`);
}
function pageRouteSegments(rel: string): string[] | null {
const trimmed = rel
.replace(/\\/g, '/')
.replace(/^src\/app\/pages\//, '')
.replace(/\.page\.ts$/, '');
if (trimmed.includes('[...')) return null;
return trimmed.split(/[/.]/).filter((s) => s !== 'index' && !/^\(.*\)$/.test(s));
}
/**
* Last-commit date for `file` (YYYY-MM-DD), via `git log -1 --format=%cs`.
* Falls back to file mtime when the file is uncommitted, and to `''`
* (or whatever `mtimeFallback` returns) when both are unavailable.
*/
export function gitDate(file: string, cwd: string, mtimeFallback: () => string = () => ''): string {
try {
const stamp = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
cwd,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
if (stamp) return stamp;
} catch {
// fall through to mtime
}
try {
return statSync(join(cwd, file)).mtime.toISOString().slice(0, 10);
} catch {
return mtimeFallback();
}
}
export function parseFrontmatter(text: string): {
attributes: Record<string, unknown>;
body: string;
} {
try {
const {attributes, body} = frontMatter<unknown>(text);
return {
attributes:
attributes && typeof attributes === 'object' ? (attributes as Record<string, unknown>) : {},
body,
};
} catch {
return {attributes: {}, body: text};
}
}
export function isNoIndex(attributes: Record<string, unknown>): boolean {
return /^(true|yes|1)$/i.test(String(attributes['noIndex'] ?? ''));
}
export function fenceTracker(): (line: string) => boolean {
let open = '';
return (line) => {
const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
if (!open) {
if (!m || (m[1][0] === '`' && m[2].includes('`'))) return false;
open = m[1];
return true;
}
if (m && m[1][0] === open[0] && m[1].length >= open.length && !m[2].trim()) open = '';
return true;
};
}
export function withoutCode(markdown: string): string {
const inFence = fenceTracker();
return markdown
.split(/\r?\n/)
.map((line) => (inFence(line) ? '' : line))
.join('\n')
.replace(/(`+)[^\n]*?\1/g, ' ');
}
/**
* Resolve `path` against `root`, following symlinks, and throw when the
* real target lies outside the real root.
*/
export function resolveInside(root: string, path: string): string {
const realRoot = realpathSync(root);
const full = realpathSync(resolve(realRoot, path));
const rel = relative(realRoot, full);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error('path resolves outside the project root');
}
return full;
}