getfilepress 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/package.json +6 -3
- package/packages/app/src/lib/genie/GeniePanel.svelte +290 -21
- package/packages/app/src/lib/genie/ops.ts +11 -1
- package/packages/app/src/lib/genie/store.ts +54 -1
- package/packages/app/src/lib/theme-entry.ts +2 -1
- package/packages/app/src/routes/+error.svelte +38 -0
- package/packages/app/src/routes/posts/[slug]/+page.svelte +2 -1
- package/packages/app/src/site-theme.d.ts +3 -0
- package/packages/app/vite-plugin-genie.ts +45 -1
- package/packages/app/vite.config.ts +38 -7
- package/packages/core/src/lib/components/PostCard.svelte +2 -1
- package/packages/core/src/lib/config.ts +47 -1
- package/packages/core/src/lib/content/parse.ts +2 -0
- package/packages/core/src/lib/content/types.ts +2 -0
- package/packages/core/src/lib/format.ts +11 -0
- package/packages/core/src/lib/index.ts +7 -3
- package/packages/core/src/lib/redirects.ts +88 -0
- package/packages/core/src/lib/server.ts +9 -2
- package/packages/core/src/lib/styles/presets/essay.css +2 -0
- package/packages/core/src/lib/styles/presets/folio.css +27 -0
- package/packages/core/src/lib/styles/presets/ink.css +28 -0
- package/packages/core/src/lib/styles/theme.css +37 -0
- package/packages/import/src/cli.ts +1 -0
- package/packages/import/src/extract.ts +15 -2
- package/packages/import/src/ir.ts +2 -0
- package/packages/import/src/ollama.ts +332 -77
- package/packages/import/src/redirects.ts +17 -0
- package/packages/import/src/write-site.ts +25 -3
- package/scripts/copy-path-mounts.mjs +30 -1
- package/scripts/create-site.mjs +1 -0
- package/scripts/filepress.mjs +24 -14
- package/scripts/new-post.ts +125 -0
- package/scripts/preview.mjs +95 -0
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
runInspire,
|
|
12
12
|
scanOllamaHosts
|
|
13
13
|
} from './src/lib/genie/ops.ts';
|
|
14
|
-
import { deleteVersion, listVersions } from './src/lib/genie/store.ts';
|
|
14
|
+
import { deleteVersion, duplicateVersion, listVersions, updateVersionMeta } from './src/lib/genie/store.ts';
|
|
15
15
|
|
|
16
16
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
17
17
|
return new Promise((resolve, reject) => {
|
|
@@ -160,8 +160,51 @@ export function geniePlugin(siteRoot: string): Plugin {
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
if (method === 'POST' && path === '/__filepress/genie/star') {
|
|
164
|
+
const body = JSON.parse(await readBody(req));
|
|
165
|
+
if (!body.versionId) {
|
|
166
|
+
return sendJson(res, 400, { error: '`versionId` required' });
|
|
167
|
+
}
|
|
168
|
+
if (typeof body.starred !== 'boolean') {
|
|
169
|
+
return sendJson(res, 400, { error: '`starred` boolean required' });
|
|
170
|
+
}
|
|
171
|
+
return sendJson(res, 200, {
|
|
172
|
+
version: updateVersionMeta(siteRoot, body.versionId, { starred: body.starred })
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (method === 'POST' && path === '/__filepress/genie/label') {
|
|
177
|
+
const body = JSON.parse(await readBody(req));
|
|
178
|
+
if (!body.versionId) {
|
|
179
|
+
return sendJson(res, 400, { error: '`versionId` required' });
|
|
180
|
+
}
|
|
181
|
+
if (typeof body.label !== 'string') {
|
|
182
|
+
return sendJson(res, 400, { error: '`label` string required' });
|
|
183
|
+
}
|
|
184
|
+
return sendJson(res, 200, {
|
|
185
|
+
version: updateVersionMeta(siteRoot, body.versionId, { label: body.label })
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (method === 'POST' && path === '/__filepress/genie/duplicate') {
|
|
190
|
+
const body = JSON.parse(await readBody(req));
|
|
191
|
+
if (!body.versionId) {
|
|
192
|
+
return sendJson(res, 400, { error: '`versionId` required' });
|
|
193
|
+
}
|
|
194
|
+
return sendJson(res, 200, {
|
|
195
|
+
version: duplicateVersion(
|
|
196
|
+
siteRoot,
|
|
197
|
+
body.versionId,
|
|
198
|
+
typeof body.label === 'string' ? body.label : undefined
|
|
199
|
+
)
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
163
203
|
if (method === 'POST' && path === '/__filepress/genie/delete') {
|
|
164
204
|
const body = JSON.parse(await readBody(req));
|
|
205
|
+
if (!body.versionId) {
|
|
206
|
+
return sendJson(res, 400, { error: '`versionId` required' });
|
|
207
|
+
}
|
|
165
208
|
deleteVersion(siteRoot, body.versionId);
|
|
166
209
|
return sendJson(res, 200, { ok: true });
|
|
167
210
|
}
|
|
@@ -169,6 +212,7 @@ export function geniePlugin(siteRoot: string): Plugin {
|
|
|
169
212
|
return sendJson(res, 404, { error: `Unknown Genie route: ${method} ${path}` });
|
|
170
213
|
} catch (e) {
|
|
171
214
|
const message = e instanceof Error ? e.message : String(e);
|
|
215
|
+
console.error(`filepress genie: ${method} ${path} failed: ${message}`);
|
|
172
216
|
return sendJson(res, 500, { error: message });
|
|
173
217
|
}
|
|
174
218
|
});
|
|
@@ -13,6 +13,8 @@ import { pathMountsPlugin } from './vite-plugin-path-mounts.ts';
|
|
|
13
13
|
import { filepressLeaseName, localberthGet } from './localberth-port.ts';
|
|
14
14
|
import type { PathMount } from '../core/src/lib/paths-shared.ts';
|
|
15
15
|
import { unexpectedUnseenPrerenderRoutes } from './src/lib/prerender-empty-ok.ts';
|
|
16
|
+
import { buildRedirectRules, THEME_PRESETS, type ThemePreset } from '../core/src/lib/config.ts';
|
|
17
|
+
import { serializeRedirects, type RedirectRule } from '../core/src/lib/redirects.ts';
|
|
16
18
|
|
|
17
19
|
const appRoot = dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
const defaultSiteRoot = resolve(appRoot, '../../sites/demo');
|
|
@@ -53,8 +55,15 @@ const coreConfig = join(appRoot, '../core/src/lib/config.ts');
|
|
|
53
55
|
const coreServer = join(appRoot, '../core/src/lib/server.ts');
|
|
54
56
|
const coreTheme = join(appRoot, '../core/src/lib/theme.ts');
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
type SiteHints = {
|
|
59
|
+
paths: PathMount[];
|
|
60
|
+
theme: ThemePreset;
|
|
61
|
+
redirects: RedirectRule[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Load config via a short-lived Vite SSR graph (config-only getfilepress alias). */
|
|
65
|
+
async function loadSiteHints(): Promise<SiteHints> {
|
|
66
|
+
const fallback: SiteHints = { paths: [], theme: 'essay', redirects: [] };
|
|
58
67
|
const temp = await createServer({
|
|
59
68
|
configFile: false,
|
|
60
69
|
root: siteRoot,
|
|
@@ -73,17 +82,35 @@ async function loadPathMounts(): Promise<PathMount[]> {
|
|
|
73
82
|
});
|
|
74
83
|
try {
|
|
75
84
|
const mod = await temp.ssrLoadModule(pathToFileURL(siteConfig).href);
|
|
76
|
-
const
|
|
77
|
-
|
|
85
|
+
const cfg = mod.default as {
|
|
86
|
+
paths?: PathMount[];
|
|
87
|
+
theme?: ThemePreset;
|
|
88
|
+
homePage?: string | null;
|
|
89
|
+
redirects?: RedirectRule[];
|
|
90
|
+
} | null;
|
|
91
|
+
const theme = cfg?.theme && THEME_PRESETS.includes(cfg.theme) ? cfg.theme : 'essay';
|
|
92
|
+
return {
|
|
93
|
+
paths: Array.isArray(cfg?.paths) ? cfg.paths : [],
|
|
94
|
+
theme,
|
|
95
|
+
redirects: buildRedirectRules({
|
|
96
|
+
homePage: cfg?.homePage ?? null,
|
|
97
|
+
redirects: Array.isArray(cfg?.redirects) ? cfg.redirects : []
|
|
98
|
+
})
|
|
99
|
+
};
|
|
78
100
|
} catch (err) {
|
|
79
101
|
const detail = err instanceof Error ? err.message : String(err);
|
|
80
|
-
console.warn(`filepress: could not load
|
|
81
|
-
return
|
|
102
|
+
console.warn(`filepress: could not load site config (${detail}); continuing with defaults.`);
|
|
103
|
+
return fallback;
|
|
82
104
|
} finally {
|
|
83
105
|
await temp.close();
|
|
84
106
|
}
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
function resolveSitePreset(theme: ThemePreset): string {
|
|
110
|
+
const file = join(appRoot, '../core/src/lib/styles/presets', `${theme}.css`);
|
|
111
|
+
return existsSync(file) ? file : join(appRoot, '../core/src/lib/styles/presets/essay.css');
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
function resolvePort(): number | undefined {
|
|
88
115
|
const raw = process.env.FILEPRESS_PORT?.trim();
|
|
89
116
|
if (raw) {
|
|
@@ -96,11 +123,14 @@ function resolvePort(): number | undefined {
|
|
|
96
123
|
const fixedPort = resolvePort();
|
|
97
124
|
|
|
98
125
|
export default defineConfig(async () => {
|
|
99
|
-
const
|
|
126
|
+
const hints = await loadSiteHints();
|
|
127
|
+
const pathMounts = hints.paths;
|
|
128
|
+
const sitePreset = resolveSitePreset(hints.theme);
|
|
100
129
|
writeFileSync(
|
|
101
130
|
join(filepressCache, 'path-mounts.json'),
|
|
102
131
|
`${JSON.stringify(pathMounts, null, '\t')}\n`
|
|
103
132
|
);
|
|
133
|
+
writeFileSync(join(filepressCache, 'redirects.txt'), serializeRedirects(hints.redirects));
|
|
104
134
|
|
|
105
135
|
const host = process.env.HOST?.trim() || '127.0.0.1';
|
|
106
136
|
return {
|
|
@@ -134,6 +164,7 @@ export default defineConfig(async () => {
|
|
|
134
164
|
'getfilepress/theme': coreTheme,
|
|
135
165
|
'$site-config': siteConfig,
|
|
136
166
|
'$site-theme': siteTheme,
|
|
167
|
+
'$site-preset': sitePreset,
|
|
137
168
|
'$critical-theme': criticalThemeOut
|
|
138
169
|
},
|
|
139
170
|
paths: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { PostMeta } from '../content/types';
|
|
3
|
-
import { formatDate } from '../format';
|
|
3
|
+
import { formatDate, formatReadingTime } from '../format';
|
|
4
4
|
|
|
5
5
|
let {
|
|
6
6
|
post,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
{#if post.author}
|
|
19
19
|
· <span class="byline">{post.author}</span>
|
|
20
20
|
{/if}
|
|
21
|
+
· <span class="reading-time">{formatReadingTime(post.readingMinutes)}</span>
|
|
21
22
|
</p>
|
|
22
23
|
<h2 class="post-title"><a href="/posts/{post.slug}">{post.title}</a></h2>
|
|
23
24
|
{#if post.description}
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* server code.
|
|
9
9
|
*/
|
|
10
10
|
import { normalizePathMounts, type PathMount } from './paths-shared';
|
|
11
|
+
import { writingPostRedirects, type RedirectRule } from './redirects';
|
|
11
12
|
export type { PathMount } from './paths-shared';
|
|
13
|
+
export type { RedirectRule } from './redirects';
|
|
14
|
+
|
|
15
|
+
export const THEME_PRESETS = ['essay', 'ink', 'folio'] as const;
|
|
16
|
+
export type ThemePreset = (typeof THEME_PRESETS)[number];
|
|
12
17
|
export interface NewsletterConfig {
|
|
13
18
|
/** Full URL to an external signup form (Buttondown, Substack, etc.). */
|
|
14
19
|
url: string;
|
|
@@ -72,6 +77,10 @@ export interface SiteConfig {
|
|
|
72
77
|
* FilePress does not parse or theme mount contents.
|
|
73
78
|
*/
|
|
74
79
|
paths: PathMount[];
|
|
80
|
+
/** Named token sheet loaded after Essay, before the site `theme.css`. */
|
|
81
|
+
theme: ThemePreset;
|
|
82
|
+
/** Extra Cloudflare/Netlify `_redirects` lines merged at build. */
|
|
83
|
+
redirects: RedirectRule[];
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
/** What a site author supplies; everything but `title` and `url` is optional. */
|
|
@@ -95,6 +104,10 @@ export interface SiteConfigInput {
|
|
|
95
104
|
newsletter?: NewsletterConfig | null;
|
|
96
105
|
/** Mount site-relative dirs at URL prefixes (docs shells, etc.). */
|
|
97
106
|
paths?: PathMount[];
|
|
107
|
+
/** Built-in token preset. Default `essay`. Site `theme.css` still wins last. */
|
|
108
|
+
theme?: ThemePreset;
|
|
109
|
+
/** Extra `_redirects` rules (from → to). */
|
|
110
|
+
redirects?: RedirectRule[];
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
const defaultFooterLinks: NavItem[] = [
|
|
@@ -178,10 +191,43 @@ export function defineFilepressConfig(input: SiteConfigInput): SiteConfig {
|
|
|
178
191
|
footerLinks: normalizeNavItems(input.footerLinks) ?? [...defaultFooterLinks],
|
|
179
192
|
topics: input.topics ?? [],
|
|
180
193
|
newsletter: input.newsletter ?? null,
|
|
181
|
-
paths: normalizePathMounts(input.paths)
|
|
194
|
+
paths: normalizePathMounts(input.paths),
|
|
195
|
+
theme: normalizeTheme(input.theme),
|
|
196
|
+
redirects: normalizeRedirects(input.redirects)
|
|
182
197
|
};
|
|
183
198
|
}
|
|
184
199
|
|
|
200
|
+
/** Engine + site rules to write into `build/_redirects`. */
|
|
201
|
+
export function buildRedirectRules(site: Pick<SiteConfig, 'homePage' | 'redirects'>): RedirectRule[] {
|
|
202
|
+
return [...(site.homePage ? writingPostRedirects() : []), ...site.redirects];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeTheme(theme: ThemePreset | undefined): ThemePreset {
|
|
206
|
+
const name = (theme ?? 'essay').trim().toLowerCase();
|
|
207
|
+
if (!THEME_PRESETS.includes(name as ThemePreset)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`filepress.config: \`theme\` must be ${THEME_PRESETS.join(', ')} (got "${theme}").`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return name as ThemePreset;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeRedirects(rules: RedirectRule[] | undefined): RedirectRule[] {
|
|
216
|
+
if (!rules?.length) return [];
|
|
217
|
+
return rules.map((rule) => {
|
|
218
|
+
const from = (rule.from ?? '').trim();
|
|
219
|
+
const to = (rule.to ?? '').trim();
|
|
220
|
+
if (!from || !to) {
|
|
221
|
+
throw new Error('filepress.config: redirects need non-empty `from` and `to`.');
|
|
222
|
+
}
|
|
223
|
+
const status = rule.status ?? 301;
|
|
224
|
+
if (status !== 301 && status !== 302 && status !== 308) {
|
|
225
|
+
throw new Error(`filepress.config: redirect status must be 301, 302, or 308 (got ${status}).`);
|
|
226
|
+
}
|
|
227
|
+
return { from, to, status };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
185
231
|
/** Join the site origin with a path, guarding against double slashes. */
|
|
186
232
|
export function absoluteUrl(site: Pick<SiteConfig, 'url'>, path: string): string {
|
|
187
233
|
const base = site.url.replace(/\/+$/, '');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
|
+
import { readingMinutes } from '../format';
|
|
2
3
|
import type { PageSource, PostSource, RawFrontmatter, RawPageFrontmatter } from './types';
|
|
3
4
|
import { RESERVED_PAGE_SLUGS } from './types';
|
|
4
5
|
|
|
@@ -135,6 +136,7 @@ export function parsePost(path: string, raw: string): PostSource {
|
|
|
135
136
|
author,
|
|
136
137
|
draft,
|
|
137
138
|
sourcePath: path,
|
|
139
|
+
readingMinutes: readingMinutes(parsed.content),
|
|
138
140
|
body: parsed.content
|
|
139
141
|
};
|
|
140
142
|
}
|
|
@@ -29,6 +29,8 @@ export interface PostMeta {
|
|
|
29
29
|
draft: boolean;
|
|
30
30
|
/** Source file path relative to the repo root, e.g. "/posts/foo.md". */
|
|
31
31
|
sourcePath: string;
|
|
32
|
+
/** Whole minutes at ~228 wpm, computed from the Markdown body. */
|
|
33
|
+
readingMinutes: number;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** A post's metadata plus its raw (uncompiled) Markdown body. */
|
|
@@ -23,3 +23,14 @@ export function formatDate(iso: string): string {
|
|
|
23
23
|
const [y, m, d] = iso.split('-').map(Number);
|
|
24
24
|
return `${d} ${MONTHS[m - 1]} ${y}`;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/** ~228 wpm (adult silent reading). Always at least one minute for a published post. */
|
|
28
|
+
export function readingMinutes(body: string, wordsPerMinute = 228): number {
|
|
29
|
+
const words = body.trim().split(/\s+/).filter(Boolean).length;
|
|
30
|
+
if (words === 0) return 1;
|
|
31
|
+
return Math.max(1, Math.round(words / wordsPerMinute));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatReadingTime(minutes: number): string {
|
|
35
|
+
return minutes === 1 ? '1 min read' : `${minutes} min read`;
|
|
36
|
+
}
|
|
@@ -12,7 +12,9 @@ export {
|
|
|
12
12
|
defineFilepressConfig,
|
|
13
13
|
absoluteUrl,
|
|
14
14
|
ogImageUrl,
|
|
15
|
-
postsIndexPath
|
|
15
|
+
postsIndexPath,
|
|
16
|
+
buildRedirectRules,
|
|
17
|
+
THEME_PRESETS
|
|
16
18
|
} from './config';
|
|
17
19
|
export type {
|
|
18
20
|
SiteConfig,
|
|
@@ -21,11 +23,13 @@ export type {
|
|
|
21
23
|
Topic,
|
|
22
24
|
NavItem,
|
|
23
25
|
NavIconName,
|
|
24
|
-
PathMount
|
|
26
|
+
PathMount,
|
|
27
|
+
ThemePreset,
|
|
28
|
+
RedirectRule
|
|
25
29
|
} from './config';
|
|
26
30
|
|
|
27
31
|
export { isPathMountHref } from './paths-shared';
|
|
28
32
|
|
|
29
|
-
export { formatDate } from './format';
|
|
33
|
+
export { formatDate, formatReadingTime, readingMinutes } from './format';
|
|
30
34
|
|
|
31
35
|
export type { PostMeta, PostSource, RenderedPost, RawFrontmatter } from './content/types';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Pages / Netlify `_redirects` lines.
|
|
3
|
+
* Code owns the file shape; sites and import only supply from/to pairs.
|
|
4
|
+
*/
|
|
5
|
+
export type RedirectStatus = 301 | 302 | 308;
|
|
6
|
+
|
|
7
|
+
export type RedirectRule = {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
status: RedirectStatus;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const STATUS = new Set<RedirectStatus>([301, 302, 308]);
|
|
14
|
+
|
|
15
|
+
export function normalizeRedirectPath(path: string): string {
|
|
16
|
+
const trimmed = path.trim();
|
|
17
|
+
if (!trimmed) throw new Error('redirect path cannot be empty');
|
|
18
|
+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
19
|
+
const url = new URL(trimmed);
|
|
20
|
+
const out = url.pathname || '/';
|
|
21
|
+
return out === '/' ? '/' : out.replace(/\/+$/, '') || '/';
|
|
22
|
+
}
|
|
23
|
+
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
24
|
+
if (withSlash.includes('*') || withSlash.includes(':splat')) return withSlash;
|
|
25
|
+
return withSlash === '/' ? '/' : withSlash.replace(/\/+$/, '') || '/';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function serializeRedirects(rules: RedirectRule[]): string {
|
|
29
|
+
const lines = rules.map((r) => `${r.from} ${r.to} ${r.status}`);
|
|
30
|
+
return `${lines.join('\n')}\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseRedirectsFile(text: string): RedirectRule[] {
|
|
34
|
+
const rules: RedirectRule[] = [];
|
|
35
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
36
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
37
|
+
if (!line) continue;
|
|
38
|
+
const parts = line.split(/\s+/);
|
|
39
|
+
if (parts.length < 2) continue;
|
|
40
|
+
const status = Number(parts[2] ?? 301);
|
|
41
|
+
if (!STATUS.has(status as RedirectStatus)) continue;
|
|
42
|
+
rules.push({
|
|
43
|
+
from: parts[0],
|
|
44
|
+
to: parts[1],
|
|
45
|
+
status: status as RedirectStatus
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return rules;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function mergeRedirects(existing: string, extra: RedirectRule[]): string {
|
|
52
|
+
const have = new Set(parseRedirectsFile(existing).map((r) => `${r.from}\0${r.to}`));
|
|
53
|
+
const add = extra.filter((r) => !have.has(`${r.from}\0${r.to}`));
|
|
54
|
+
if (add.length === 0) return existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
55
|
+
const prefix = existing.trimEnd();
|
|
56
|
+
const block = serializeRedirects(add);
|
|
57
|
+
return prefix ? `${prefix}\n${block}` : block;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** When `/` is a landing page, old `/writing` indexes used to hold the post list. */
|
|
61
|
+
export function writingPostRedirects(): RedirectRule[] {
|
|
62
|
+
return [
|
|
63
|
+
{ from: '/writing', to: '/posts', status: 308 },
|
|
64
|
+
{ from: '/writing/*', to: '/posts/:splat', status: 301 }
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function redirectsFromSourceUrls(
|
|
69
|
+
pairs: Array<{ sourceUrl: string; destPath: string }>
|
|
70
|
+
): RedirectRule[] {
|
|
71
|
+
const rules: RedirectRule[] = [];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
for (const pair of pairs) {
|
|
74
|
+
let from: string;
|
|
75
|
+
try {
|
|
76
|
+
from = normalizeRedirectPath(pair.sourceUrl);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const to = pair.destPath.startsWith('/') ? pair.destPath : `/${pair.destPath}`;
|
|
81
|
+
if (from === to || from === '/') continue;
|
|
82
|
+
const key = `${from}\0${to}`;
|
|
83
|
+
if (seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
rules.push({ from, to, status: 301 });
|
|
86
|
+
}
|
|
87
|
+
return rules;
|
|
88
|
+
}
|
|
@@ -20,8 +20,15 @@ export {
|
|
|
20
20
|
filenameOf
|
|
21
21
|
} from './content/parse';
|
|
22
22
|
|
|
23
|
-
export { absoluteUrl, ogImageUrl } from './config';
|
|
24
|
-
export type { SiteConfig, PathMount } from './config';
|
|
23
|
+
export { absoluteUrl, ogImageUrl, buildRedirectRules } from './config';
|
|
24
|
+
export type { SiteConfig, PathMount, ThemePreset, RedirectRule } from './config';
|
|
25
|
+
export {
|
|
26
|
+
serializeRedirects,
|
|
27
|
+
parseRedirectsFile,
|
|
28
|
+
mergeRedirects,
|
|
29
|
+
writingPostRedirects,
|
|
30
|
+
redirectsFromSourceUrls
|
|
31
|
+
} from './redirects';
|
|
25
32
|
export { defaultSecurityHeaders, writeBuildHeaders, mergeSecurityHeaders } from './headers';
|
|
26
33
|
export type { MergeSecurityHeadersResult } from './headers';
|
|
27
34
|
export {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Warmer folio: more margin, amber accent. Tokens only. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #f7f1e6;
|
|
4
|
+
--surface: #fffdf8;
|
|
5
|
+
--ink: #1c1812;
|
|
6
|
+
--ink-soft: #5a5146;
|
|
7
|
+
--ink-faint: #8a7f70;
|
|
8
|
+
--rule: #e6dcc8;
|
|
9
|
+
--rule-strong: #d2c4a8;
|
|
10
|
+
--accent: #8a4b12;
|
|
11
|
+
--accent-strong: #6b3910;
|
|
12
|
+
--gap: clamp(1.5rem, 5vw, 3rem);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--bg: #16130e;
|
|
18
|
+
--surface: #1f1b14;
|
|
19
|
+
--ink: #f3ead8;
|
|
20
|
+
--ink-soft: #c4b59a;
|
|
21
|
+
--ink-faint: #8e826c;
|
|
22
|
+
--rule: #322c22;
|
|
23
|
+
--rule-strong: #443b2d;
|
|
24
|
+
--accent: #e0a05a;
|
|
25
|
+
--accent-strong: #f0c08a;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* Cooler, slightly tighter cousin of Essay. Tokens only — HTML stays the same. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #f3f4f2;
|
|
4
|
+
--surface: #ffffff;
|
|
5
|
+
--ink: #141513;
|
|
6
|
+
--ink-soft: #454843;
|
|
7
|
+
--ink-faint: #767a73;
|
|
8
|
+
--rule: #dce0d8;
|
|
9
|
+
--rule-strong: #c5cbbf;
|
|
10
|
+
--accent: #1c3d5a;
|
|
11
|
+
--accent-strong: #132b40;
|
|
12
|
+
--measure: 38rem;
|
|
13
|
+
--measure-wide: 44rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #101210;
|
|
19
|
+
--surface: #181a17;
|
|
20
|
+
--ink: #e8ebe4;
|
|
21
|
+
--ink-soft: #a8ada4;
|
|
22
|
+
--ink-faint: #7a7f76;
|
|
23
|
+
--rule: #272a25;
|
|
24
|
+
--rule-strong: #353930;
|
|
25
|
+
--accent: #8eb6d4;
|
|
26
|
+
--accent-strong: #b0cce3;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
|
|
60
60
|
html {
|
|
61
61
|
-webkit-text-size-adjust: 100%;
|
|
62
|
+
overflow-x: clip;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
body {
|
|
@@ -773,6 +774,20 @@ hr {
|
|
|
773
774
|
}
|
|
774
775
|
|
|
775
776
|
@media (max-width: 34rem) {
|
|
777
|
+
body {
|
|
778
|
+
font-size: 1.08rem;
|
|
779
|
+
line-height: 1.65;
|
|
780
|
+
}
|
|
781
|
+
.site-logo {
|
|
782
|
+
height: 3.1rem;
|
|
783
|
+
}
|
|
784
|
+
.site-title {
|
|
785
|
+
font-size: 1.28rem;
|
|
786
|
+
}
|
|
787
|
+
.site-nav {
|
|
788
|
+
flex-wrap: wrap;
|
|
789
|
+
gap: 0.75rem 1.1rem;
|
|
790
|
+
}
|
|
776
791
|
.post-nav {
|
|
777
792
|
grid-template-columns: 1fr;
|
|
778
793
|
}
|
|
@@ -780,3 +795,25 @@ hr {
|
|
|
780
795
|
text-align: left;
|
|
781
796
|
}
|
|
782
797
|
}
|
|
798
|
+
|
|
799
|
+
.error-page {
|
|
800
|
+
text-align: center;
|
|
801
|
+
font-style: normal;
|
|
802
|
+
padding: 4rem 0;
|
|
803
|
+
}
|
|
804
|
+
.error-page h1 {
|
|
805
|
+
font-size: 1.65rem;
|
|
806
|
+
margin: 0.35rem 0 0.75rem;
|
|
807
|
+
}
|
|
808
|
+
.error-code {
|
|
809
|
+
font-family: var(--font-sans);
|
|
810
|
+
font-size: 0.72rem;
|
|
811
|
+
letter-spacing: 0.16em;
|
|
812
|
+
text-transform: uppercase;
|
|
813
|
+
color: var(--ink-faint);
|
|
814
|
+
margin: 0;
|
|
815
|
+
}
|
|
816
|
+
.error-actions {
|
|
817
|
+
margin-top: 1.5rem;
|
|
818
|
+
font-style: normal;
|
|
819
|
+
}
|
|
@@ -199,6 +199,7 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
|
|
|
199
199
|
let description = '';
|
|
200
200
|
let author = title;
|
|
201
201
|
let lede: string | null = null;
|
|
202
|
+
let homeMarkdown: string | null = null;
|
|
202
203
|
let generator: string | null = null;
|
|
203
204
|
|
|
204
205
|
if (homeDoc) {
|
|
@@ -230,7 +231,13 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
|
|
|
230
231
|
lede = sentence && sentence.length > 80 ? sentence : `${cut.replace(/\s+\S*$/, '').trim()}…`;
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
|
-
|
|
234
|
+
const homeMd = htmlToMarkdown(clone.innerHTML).trim();
|
|
235
|
+
if (homeMd.length > 200 && paragraphs.length >= 2) {
|
|
236
|
+
homeMarkdown = homeMd;
|
|
237
|
+
notes.push('Home bio is long enough for pages/home.md; the post index moves to /posts.');
|
|
238
|
+
} else {
|
|
239
|
+
notes.push('Home bio mapped to config `lede` (posts remain the index).');
|
|
240
|
+
}
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
const posts: SiteIRPost[] = [];
|
|
@@ -334,7 +341,12 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
|
|
|
334
341
|
tag
|
|
335
342
|
}));
|
|
336
343
|
|
|
337
|
-
const nav: Array<{ label: string; href: string }> =
|
|
344
|
+
const nav: Array<{ label: string; href: string }> = homeMarkdown
|
|
345
|
+
? [
|
|
346
|
+
{ label: 'Home', href: '/' },
|
|
347
|
+
{ label: 'Posts', href: '/posts' }
|
|
348
|
+
]
|
|
349
|
+
: [{ label: 'Posts', href: '/' }];
|
|
338
350
|
for (const page of pages) {
|
|
339
351
|
nav.push({
|
|
340
352
|
label: page.title,
|
|
@@ -372,6 +384,7 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
|
|
|
372
384
|
nav,
|
|
373
385
|
topics,
|
|
374
386
|
lede,
|
|
387
|
+
homeMarkdown,
|
|
375
388
|
notes,
|
|
376
389
|
assets: [...new Set(assets)]
|
|
377
390
|
};
|
|
@@ -36,6 +36,8 @@ export type SiteIR = {
|
|
|
36
36
|
topics: Array<{ label: string; tag: string }>;
|
|
37
37
|
/** Suggested lede for the filepress index (from home bio), or null. */
|
|
38
38
|
lede: string | null;
|
|
39
|
+
/** Full home bio as Markdown when it is long enough to be `pages/home.md`. */
|
|
40
|
+
homeMarkdown: string | null;
|
|
39
41
|
/** Notes for the import report (URL remaps, skips). */
|
|
40
42
|
notes: string[];
|
|
41
43
|
/** Same-origin chrome assets to copy into static/ (favicons, etc.). */
|