getfilepress 0.1.2 → 0.1.4
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 +36 -10
- package/package.json +99 -99
- package/packages/app/README.md +19 -0
- package/packages/app/package.json +1 -1
- package/packages/app/src/lib/genie/GeniePanel.svelte +672 -144
- package/packages/app/src/lib/genie/config-patch.ts +72 -0
- package/packages/app/src/lib/genie/ops.ts +231 -10
- package/packages/app/src/lib/genie/store.ts +12 -3
- package/packages/app/src/lib/pages.server.ts +4 -2
- package/packages/app/src/routes/sitemap.xml/+server.ts +5 -2
- package/packages/app/vite-plugin-genie.ts +64 -1
- package/packages/app/vite-plugin-path-mounts.ts +107 -0
- package/packages/app/vite.config.ts +112 -81
- package/packages/core/README.md +29 -0
- package/packages/core/package.json +1 -1
- package/packages/core/src/lib/config.ts +12 -1
- package/packages/core/src/lib/content/feeds.ts +5 -1
- package/packages/core/src/lib/content/pages.ts +11 -1
- package/packages/core/src/lib/content/parse.ts +12 -3
- package/packages/core/src/lib/index.ts +2 -1
- package/packages/core/src/lib/paths-shared.ts +89 -0
- package/packages/core/src/lib/paths.ts +84 -0
- package/packages/core/src/lib/server.ts +8 -1
- package/packages/core/src/lib/styles/theme.css +3 -2
- package/packages/import/package.json +2 -1
- package/packages/import/src/cli.ts +77 -3
- package/packages/import/src/ollama.ts +43 -1
- package/packages/import/src/ollanet-scan.ts +132 -0
- package/packages/import/tsconfig.json +1 -1
- package/scripts/copy-path-mounts.mjs +40 -0
- package/scripts/create-site.mjs +4 -11
- package/scripts/filepress.mjs +31 -3
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import adapter from '@sveltejs/adapter-static';
|
|
2
2
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
3
|
-
import { defineConfig } from 'vite';
|
|
3
|
+
import { defineConfig, createServer } from 'vite';
|
|
4
4
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
7
|
import {
|
|
8
8
|
criticalThemePlugin,
|
|
9
9
|
writeCriticalThemeModule
|
|
10
10
|
} from './vite-plugin-critical-theme.ts';
|
|
11
11
|
import { geniePlugin } from './vite-plugin-genie.ts';
|
|
12
|
+
import { pathMountsPlugin } from './vite-plugin-path-mounts.ts';
|
|
13
|
+
import type { PathMount } from '../core/src/lib/paths-shared.ts';
|
|
12
14
|
|
|
13
15
|
const appRoot = dirname(fileURLToPath(import.meta.url));
|
|
14
16
|
const defaultSiteRoot = resolve(appRoot, '../../sites/demo');
|
|
@@ -24,8 +26,6 @@ const siteStatic = join(siteRoot, 'static');
|
|
|
24
26
|
const siteBuild = join(siteRoot, 'build');
|
|
25
27
|
const fallbackStatic = join(appRoot, 'static');
|
|
26
28
|
|
|
27
|
-
// SvelteKit requires an assets directory to exist. Prefer the site's static/;
|
|
28
|
-
// fall back to packages/app/static for sites that haven't added one yet.
|
|
29
29
|
const assetsDir = existsSync(siteStatic) ? siteStatic : fallbackStatic;
|
|
30
30
|
if (!existsSync(fallbackStatic)) mkdirSync(fallbackStatic, { recursive: true });
|
|
31
31
|
|
|
@@ -33,10 +33,6 @@ if (!existsSync(siteConfig)) {
|
|
|
33
33
|
throw new Error(`No filepress.config.ts at site root: ${siteConfig}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Prefer site-root theme.css so Genie activate can overwrite a real file Vite watches.
|
|
38
|
-
* Create an empty theme.css when absent (not the app stub path).
|
|
39
|
-
*/
|
|
40
36
|
function resolveSiteTheme(): string {
|
|
41
37
|
const css = join(siteRoot, 'theme.css');
|
|
42
38
|
if (existsSync(css)) return css;
|
|
@@ -51,10 +47,41 @@ const criticalThemeOut = join(filepressCache, 'critical-theme.generated.ts');
|
|
|
51
47
|
writeCriticalThemeModule(siteTheme, criticalThemeOut);
|
|
52
48
|
|
|
53
49
|
const coreEntry = join(appRoot, '../core/src/lib/index.ts');
|
|
50
|
+
const coreConfig = join(appRoot, '../core/src/lib/config.ts');
|
|
54
51
|
const coreServer = join(appRoot, '../core/src/lib/server.ts');
|
|
55
52
|
const coreTheme = join(appRoot, '../core/src/lib/theme.ts');
|
|
56
53
|
|
|
57
|
-
/**
|
|
54
|
+
/** Load `paths` via a short-lived Vite SSR graph (config-only getfilepress alias). */
|
|
55
|
+
async function loadPathMounts(): Promise<PathMount[]> {
|
|
56
|
+
const temp = await createServer({
|
|
57
|
+
configFile: false,
|
|
58
|
+
root: siteRoot,
|
|
59
|
+
logLevel: 'silent',
|
|
60
|
+
resolve: {
|
|
61
|
+
alias: [
|
|
62
|
+
{ find: /^getfilepress\/server$/, replacement: coreServer },
|
|
63
|
+
{ find: /^getfilepress\/theme$/, replacement: coreTheme },
|
|
64
|
+
// Config-only — avoid pulling Essay Svelte components into this loader.
|
|
65
|
+
{ find: /^getfilepress$/, replacement: coreConfig }
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
server: { middlewareMode: true },
|
|
69
|
+
appType: 'custom',
|
|
70
|
+
optimizeDeps: { noDiscovery: true, include: [] }
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const mod = await temp.ssrLoadModule(pathToFileURL(siteConfig).href);
|
|
74
|
+
const paths = mod.default?.paths;
|
|
75
|
+
return Array.isArray(paths) ? (paths as PathMount[]) : [];
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
78
|
+
console.warn(`filepress: could not load path mounts (${detail}); continuing with none.`);
|
|
79
|
+
return [];
|
|
80
|
+
} finally {
|
|
81
|
+
await temp.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
function resolvePort(): number | undefined {
|
|
59
86
|
const raw = process.env.FILEPRESS_PORT?.trim();
|
|
60
87
|
if (!raw) return undefined;
|
|
@@ -64,78 +91,82 @@ function resolvePort(): number | undefined {
|
|
|
64
91
|
|
|
65
92
|
const fixedPort = resolvePort();
|
|
66
93
|
|
|
67
|
-
export default defineConfig({
|
|
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
|
-
)
|
|
94
|
+
export default defineConfig(async () => {
|
|
95
|
+
const pathMounts = await loadPathMounts();
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(filepressCache, 'path-mounts.json'),
|
|
98
|
+
`${JSON.stringify(pathMounts, null, '\t')}\n`
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
server: fixedPort
|
|
103
|
+
? { port: fixedPort, strictPort: true }
|
|
104
|
+
: undefined,
|
|
105
|
+
preview: fixedPort
|
|
106
|
+
? { port: fixedPort, strictPort: true }
|
|
107
|
+
: undefined,
|
|
108
|
+
resolve: {
|
|
109
|
+
alias: [
|
|
110
|
+
{ find: /^@filepress\/core\/server$/, replacement: coreServer },
|
|
111
|
+
{ find: /^@filepress\/core\/theme$/, replacement: coreTheme },
|
|
112
|
+
{ find: /^@filepress\/core$/, replacement: coreEntry }
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
optimizeDeps: {
|
|
116
|
+
exclude: ['ollanet']
|
|
117
|
+
},
|
|
118
|
+
ssr: {
|
|
119
|
+
external: ['ollanet']
|
|
120
|
+
},
|
|
121
|
+
plugins: [
|
|
122
|
+
criticalThemePlugin(siteTheme, criticalThemeOut),
|
|
123
|
+
geniePlugin(siteRoot),
|
|
124
|
+
pathMountsPlugin({ siteRoot, mounts: pathMounts }),
|
|
125
|
+
sveltekit({
|
|
126
|
+
alias: {
|
|
127
|
+
getfilepress: coreEntry,
|
|
128
|
+
'getfilepress/server': coreServer,
|
|
129
|
+
'getfilepress/theme': coreTheme,
|
|
130
|
+
'$site-config': siteConfig,
|
|
131
|
+
'$site-theme': siteTheme,
|
|
132
|
+
'$critical-theme': criticalThemeOut
|
|
133
|
+
},
|
|
134
|
+
paths: {
|
|
135
|
+
relative: false
|
|
136
|
+
},
|
|
137
|
+
files: {
|
|
138
|
+
assets: assetsDir
|
|
139
|
+
},
|
|
140
|
+
compilerOptions: {
|
|
141
|
+
runes: ({ filename }) =>
|
|
142
|
+
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
|
143
|
+
},
|
|
144
|
+
adapter: adapter({
|
|
145
|
+
pages: siteBuild,
|
|
146
|
+
assets: siteBuild,
|
|
147
|
+
fallback: '404.html',
|
|
148
|
+
strict: true
|
|
149
|
+
}),
|
|
150
|
+
prerender: {
|
|
151
|
+
entries: ['*'],
|
|
152
|
+
handleHttpError: ({ path, message }) => {
|
|
153
|
+
// Nav may link into `paths` mounts; those are copied after vite build.
|
|
154
|
+
if (pathMounts.some((m) => path === m.url || path.startsWith(`${m.url}/`))) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(message);
|
|
158
|
+
},
|
|
159
|
+
handleUnseenRoutes: ({ routes }) => {
|
|
160
|
+
const emptyOk = new Set(['/page/[n]', '/tags/[tag]', '/[slug]']);
|
|
161
|
+
const unexpected = routes.filter((r) => !emptyOk.has(r));
|
|
162
|
+
if (unexpected.length > 0) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Routes marked prerenderable but not prerendered: ${unexpected.join(', ')}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
136
167
|
}
|
|
137
168
|
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
169
|
+
})
|
|
170
|
+
]
|
|
171
|
+
};
|
|
141
172
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @filepress/core
|
|
2
|
+
|
|
3
|
+
The reusable filepress engine. Sites under [`../../sites`](../../sites) depend on
|
|
4
|
+
it (via `workspace:*` in this monorepo) and provide their own SvelteKit routes,
|
|
5
|
+
content, and `filepress.config.ts`.
|
|
6
|
+
|
|
7
|
+
## Entry points
|
|
8
|
+
|
|
9
|
+
- `@filepress/core` — client-safe: `PostCard`, `PostIndex`, `Newsletter`,
|
|
10
|
+
`SiteHeader`, `SiteFooter`, `defineFilepressConfig`, `absoluteUrl`,
|
|
11
|
+
`formatDate`, and shared types.
|
|
12
|
+
- `@filepress/core/server` — server-only (filesystem access): `createContent`,
|
|
13
|
+
`renderMarkdown`, `buildRssXml` / `buildSitemapXml` / `buildRobotsTxt`, and the
|
|
14
|
+
content-parsing primitives. Import only from `+page.server.ts`, `+server.ts`,
|
|
15
|
+
or `*.server.ts` modules.
|
|
16
|
+
- `@filepress/core/theme` — self-hosted fonts + the Essay theme CSS. Import once
|
|
17
|
+
from a site's root layout.
|
|
18
|
+
|
|
19
|
+
## Why routes live in the site
|
|
20
|
+
|
|
21
|
+
SvelteKit's router is per-project, so each site owns its `src/routes/`. Those
|
|
22
|
+
route files stay thin: they call `createContent(...)` + core builders and render
|
|
23
|
+
core components. `scripts/create-site.mjs` scaffolds them.
|
|
24
|
+
|
|
25
|
+
## Testing
|
|
26
|
+
|
|
27
|
+
`pnpm --filter @filepress/core test` runs the unit tests over the pure parsing
|
|
28
|
+
and figure-transform logic. Type-checking of the whole library happens through
|
|
29
|
+
each site's `svelte-check`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@filepress/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Reusable engine for filepress sites: content loader, Markdown pipeline, feed/sitemap builders, config helper, Essay theme, and shared Svelte components.",
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* keeps `@filepress/core` app-agnostic and importable from both client and
|
|
8
8
|
* server code.
|
|
9
9
|
*/
|
|
10
|
+
import { normalizePathMounts, type PathMount } from './paths-shared';
|
|
11
|
+
export type { PathMount } from './paths-shared';
|
|
10
12
|
export interface NewsletterConfig {
|
|
11
13
|
/** Full URL to an external signup form (Buttondown, Substack, etc.). */
|
|
12
14
|
url: string;
|
|
@@ -64,6 +66,12 @@ export interface SiteConfig {
|
|
|
64
66
|
footerLinks: NavItem[];
|
|
65
67
|
topics: Topic[];
|
|
66
68
|
newsletter: NewsletterConfig | null;
|
|
69
|
+
/**
|
|
70
|
+
* Site-owned trees mounted at a URL prefix (e.g. `/docs` ← `docs/dist`).
|
|
71
|
+
* Copied into `build/` on `filepress build`; served in `filepress dev`.
|
|
72
|
+
* FilePress does not parse or theme mount contents.
|
|
73
|
+
*/
|
|
74
|
+
paths: PathMount[];
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
/** What a site author supplies; everything but `title` and `url` is optional. */
|
|
@@ -85,6 +93,8 @@ export interface SiteConfigInput {
|
|
|
85
93
|
footerLinks?: NavItem[];
|
|
86
94
|
topics?: Topic[];
|
|
87
95
|
newsletter?: NewsletterConfig | null;
|
|
96
|
+
/** Mount site-relative dirs at URL prefixes (docs shells, etc.). */
|
|
97
|
+
paths?: PathMount[];
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
const defaultFooterLinks: NavItem[] = [
|
|
@@ -167,7 +177,8 @@ export function defineFilepressConfig(input: SiteConfigInput): SiteConfig {
|
|
|
167
177
|
nav: normalizeNavItems(input.nav) ?? defaultNav,
|
|
168
178
|
footerLinks: normalizeNavItems(input.footerLinks) ?? [...defaultFooterLinks],
|
|
169
179
|
topics: input.topics ?? [],
|
|
170
|
-
newsletter: input.newsletter ?? null
|
|
180
|
+
newsletter: input.newsletter ?? null,
|
|
181
|
+
paths: normalizePathMounts(input.paths)
|
|
171
182
|
};
|
|
172
183
|
}
|
|
173
184
|
|
|
@@ -46,7 +46,7 @@ ${items}
|
|
|
46
46
|
`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** Build a sitemap covering the index, static pages, paginated pages, topics, tags, and
|
|
49
|
+
/** Build a sitemap covering the index, static pages, paginated pages, topics, tags, posts, and path mounts. */
|
|
50
50
|
export function buildSitemapXml(
|
|
51
51
|
site: SiteConfig,
|
|
52
52
|
data: {
|
|
@@ -55,6 +55,8 @@ export function buildSitemapXml(
|
|
|
55
55
|
pageCount: number;
|
|
56
56
|
/** Published static pages (`pages/*.md`). */
|
|
57
57
|
pages?: PageMeta[];
|
|
58
|
+
/** Absolute site paths from `paths` mounts (e.g. `/docs`, `/docs/install`). */
|
|
59
|
+
mountUrls?: string[];
|
|
58
60
|
}
|
|
59
61
|
): string {
|
|
60
62
|
const esc = (v: string) => v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -63,6 +65,7 @@ export function buildSitemapXml(
|
|
|
63
65
|
for (let n = 2; n <= data.pageCount; n++) extraPages.push({ loc: absoluteUrl(site, `/page/${n}`) });
|
|
64
66
|
|
|
65
67
|
const staticPages = data.pages ?? [];
|
|
68
|
+
const mountUrls = data.mountUrls ?? [];
|
|
66
69
|
|
|
67
70
|
const urls: { loc: string; lastmod?: string }[] = [
|
|
68
71
|
{ loc: absoluteUrl(site, '/') },
|
|
@@ -71,6 +74,7 @@ export function buildSitemapXml(
|
|
|
71
74
|
{ loc: absoluteUrl(site, '/tags') },
|
|
72
75
|
...extraPages,
|
|
73
76
|
...staticPages.map((p) => ({ loc: absoluteUrl(site, `/${p.slug}`) })),
|
|
77
|
+
...mountUrls.map((path) => ({ loc: absoluteUrl(site, path) })),
|
|
74
78
|
...data.posts.map((p) => ({
|
|
75
79
|
loc: absoluteUrl(site, `/posts/${p.slug}`),
|
|
76
80
|
lastmod: p.updated ?? p.date
|
|
@@ -20,6 +20,11 @@ export interface CreatePagesOptions {
|
|
|
20
20
|
/** Absolute or cwd-relative path to `pages/`. Missing dir → empty site (ok). */
|
|
21
21
|
pagesDir: string;
|
|
22
22
|
listDrafts?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Extra slugs reserved against `pages/*.md` (e.g. first segments of `paths` mounts).
|
|
25
|
+
* Merged with {@link RESERVED_PAGE_SLUGS}.
|
|
26
|
+
*/
|
|
27
|
+
extraReservedSlugs?: string[];
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
/**
|
|
@@ -31,6 +36,9 @@ export function createPages(opts: CreatePagesOptions): PagesApi {
|
|
|
31
36
|
? opts.pagesDir
|
|
32
37
|
: resolve(process.cwd(), opts.pagesDir);
|
|
33
38
|
const listDrafts = resolveListDrafts(opts.listDrafts);
|
|
39
|
+
const extraReserved = (opts.extraReservedSlugs ?? [])
|
|
40
|
+
.map((s) => s.trim().toLowerCase())
|
|
41
|
+
.filter(Boolean);
|
|
34
42
|
|
|
35
43
|
let cache: PageSource[] | null = null;
|
|
36
44
|
|
|
@@ -52,7 +60,9 @@ export function createPages(opts: CreatePagesOptions): PagesApi {
|
|
|
52
60
|
|
|
53
61
|
const pages = filenames
|
|
54
62
|
.sort((a, b) => a.localeCompare(b))
|
|
55
|
-
.map((name) =>
|
|
63
|
+
.map((name) =>
|
|
64
|
+
parsePage(`/pages/${name}`, readFileSync(join(dir, name), 'utf8'), extraReserved)
|
|
65
|
+
);
|
|
56
66
|
|
|
57
67
|
assertUniqueSlugs(pages);
|
|
58
68
|
cache = pages;
|
|
@@ -157,7 +157,11 @@ export function assertUniqueSlugs(sources: { slug: string; sourcePath: string }[
|
|
|
157
157
|
const RESERVED = new Set<string>(RESERVED_PAGE_SLUGS);
|
|
158
158
|
|
|
159
159
|
/** Parse and validate one static page Markdown file. Throws ContentError. */
|
|
160
|
-
export function parsePage(
|
|
160
|
+
export function parsePage(
|
|
161
|
+
path: string,
|
|
162
|
+
raw: string,
|
|
163
|
+
extraReservedSlugs: string[] = []
|
|
164
|
+
): PageSource {
|
|
161
165
|
let parsed: matter.GrayMatterFile<string>;
|
|
162
166
|
try {
|
|
163
167
|
parsed = matter(raw);
|
|
@@ -180,9 +184,14 @@ export function parsePage(path: string, raw: string): PageSource {
|
|
|
180
184
|
`${path}: could not derive a non-empty slug from ${explicitSlug ? 'the `slug` field' : 'the filename'}.`
|
|
181
185
|
);
|
|
182
186
|
}
|
|
183
|
-
|
|
187
|
+
const reserved = new Set([...RESERVED, ...extraReservedSlugs.map((s) => s.trim().toLowerCase())]);
|
|
188
|
+
if (reserved.has(slug)) {
|
|
189
|
+
const extras =
|
|
190
|
+
extraReservedSlugs.length > 0
|
|
191
|
+
? `, plus path mounts: ${extraReservedSlugs.join(', ')}`
|
|
192
|
+
: '';
|
|
184
193
|
throw new ContentError(
|
|
185
|
-
`${path}: slug "${slug}" is reserved by the engine (${RESERVED_PAGE_SLUGS.join(', ')}). ` +
|
|
194
|
+
`${path}: slug "${slug}" is reserved by the engine (${RESERVED_PAGE_SLUGS.join(', ')}${extras}). ` +
|
|
186
195
|
`Rename the file or set a different \`slug\` in frontmatter.`
|
|
187
196
|
);
|
|
188
197
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe path-mount helpers (no filesystem).
|
|
3
|
+
* Copy / HTML listing live in `./paths.ts` (server-only).
|
|
4
|
+
*/
|
|
5
|
+
import { RESERVED_PAGE_SLUGS } from './content/types';
|
|
6
|
+
|
|
7
|
+
/** One mount: `dir` (site-relative) is copied/served at `url` (e.g. `/docs`). */
|
|
8
|
+
export interface PathMount {
|
|
9
|
+
/** Site-relative directory (e.g. `docs/dist`). */
|
|
10
|
+
dir: string;
|
|
11
|
+
/** URL prefix starting with `/`, no trailing slash (e.g. `/docs`). */
|
|
12
|
+
url: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Engine route prefixes a mount must not steal. */
|
|
16
|
+
const ENGINE_URL_PREFIXES = new Set([
|
|
17
|
+
...RESERVED_PAGE_SLUGS,
|
|
18
|
+
'',
|
|
19
|
+
'_app',
|
|
20
|
+
'__filepress',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize and validate `paths` from site config.
|
|
25
|
+
* Throws on empty/invalid entries or collisions with engine routes.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizePathMounts(input: PathMount[] | undefined): PathMount[] {
|
|
28
|
+
if (input == null) return [];
|
|
29
|
+
if (!Array.isArray(input)) {
|
|
30
|
+
throw new Error('filepress.config: `paths` must be an array of { url, dir }.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const seenUrls = new Set<string>();
|
|
34
|
+
const seenDirs = new Set<string>();
|
|
35
|
+
const out: PathMount[] = [];
|
|
36
|
+
|
|
37
|
+
for (const raw of input) {
|
|
38
|
+
if (!raw || typeof raw !== 'object') {
|
|
39
|
+
throw new Error('filepress.config: each `paths` entry must be an object { url, dir }.');
|
|
40
|
+
}
|
|
41
|
+
const dir = String(raw.dir ?? '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
42
|
+
let url = String(raw.url ?? '').trim().replace(/\\/g, '/');
|
|
43
|
+
if (!dir) {
|
|
44
|
+
throw new Error('filepress.config: `paths[].dir` must be a non-empty site-relative path.');
|
|
45
|
+
}
|
|
46
|
+
if (dir.startsWith('/') || /^[a-zA-Z]:/.test(dir) || dir.includes('..')) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`filepress.config: \`paths[].dir\` must be a relative path without "..\" (got "${raw.dir}").`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!url.startsWith('/')) {
|
|
52
|
+
throw new Error(`filepress.config: \`paths[].url\` must start with / (got "${raw.url}").`);
|
|
53
|
+
}
|
|
54
|
+
url = url.replace(/\/+$/, '') || '/';
|
|
55
|
+
if (url === '/') {
|
|
56
|
+
throw new Error('filepress.config: `paths[].url` cannot be `/` (that is the site home).');
|
|
57
|
+
}
|
|
58
|
+
const first = url.slice(1).split('/')[0] ?? '';
|
|
59
|
+
if (!first || ENGINE_URL_PREFIXES.has(first)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`filepress.config: \`paths\` url "/${first}" collides with an engine route ` +
|
|
62
|
+
`(${[...RESERVED_PAGE_SLUGS].join(', ')}). Choose another prefix.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (seenUrls.has(url)) {
|
|
66
|
+
throw new Error(`filepress.config: duplicate \`paths\` url "${url}".`);
|
|
67
|
+
}
|
|
68
|
+
if (seenDirs.has(dir)) {
|
|
69
|
+
throw new Error(`filepress.config: duplicate \`paths\` dir "${dir}".`);
|
|
70
|
+
}
|
|
71
|
+
for (const other of seenUrls) {
|
|
72
|
+
if (url.startsWith(`${other}/`) || other.startsWith(`${url}/`)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`filepress.config: \`paths\` urls "${url}" and "${other}" nest; use disjoint prefixes.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
seenUrls.add(url);
|
|
79
|
+
seenDirs.add(dir);
|
|
80
|
+
out.push({ url, dir });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** First URL segment of each mount — reserved against `pages/<slug>.md`. */
|
|
87
|
+
export function pathMountReservedSlugs(mounts: PathMount[]): string[] {
|
|
88
|
+
return mounts.map((m) => m.url.slice(1).split('/')[0]!).filter(Boolean);
|
|
89
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only path-mount filesystem helpers.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
cpSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import type { PathMount } from './paths-shared';
|
|
13
|
+
|
|
14
|
+
export type { PathMount } from './paths-shared';
|
|
15
|
+
export { normalizePathMounts, pathMountReservedSlugs } from './paths-shared';
|
|
16
|
+
|
|
17
|
+
/** Absolute filesystem path for a mount's source directory. */
|
|
18
|
+
export function resolvePathMountDir(siteRoot: string, mount: PathMount): string {
|
|
19
|
+
return resolve(siteRoot, mount.dir);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Copy each mount's directory into `buildDir<url>/`.
|
|
24
|
+
* Missing source dirs are skipped with a warning to stderr (site may build docs first).
|
|
25
|
+
*/
|
|
26
|
+
export function copyPathMounts(
|
|
27
|
+
siteRoot: string,
|
|
28
|
+
buildDir: string,
|
|
29
|
+
mounts: PathMount[],
|
|
30
|
+
): void {
|
|
31
|
+
for (const mount of mounts) {
|
|
32
|
+
const src = resolvePathMountDir(siteRoot, mount);
|
|
33
|
+
const dest = join(buildDir, ...mount.url.slice(1).split('/'));
|
|
34
|
+
if (!existsSync(src)) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`filepress: path mount ${mount.url} ← ${mount.dir} (missing; skipped)`,
|
|
37
|
+
);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!statSync(src).isDirectory()) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`filepress: path mount dir "${mount.dir}" is not a directory (${src}).`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(dest, { recursive: true });
|
|
46
|
+
cpSync(src, dest, { recursive: true });
|
|
47
|
+
console.log(`filepress: mounted ${mount.url} ← ${mount.dir}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List site-relative URL paths for HTML files under each mount (for the sitemap).
|
|
53
|
+
* Includes `index.html` as the directory URL (e.g. `/docs` not `/docs/index.html`).
|
|
54
|
+
*/
|
|
55
|
+
export function listPathMountHtmlUrls(siteRoot: string, mounts: PathMount[]): string[] {
|
|
56
|
+
const urls: string[] = [];
|
|
57
|
+
for (const mount of mounts) {
|
|
58
|
+
const src = resolvePathMountDir(siteRoot, mount);
|
|
59
|
+
if (!existsSync(src) || !statSync(src).isDirectory()) continue;
|
|
60
|
+
walkHtml(src, src, mount.url, urls);
|
|
61
|
+
}
|
|
62
|
+
return [...new Set(urls)].sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function walkHtml(root: string, dir: string, urlBase: string, out: string[]): void {
|
|
66
|
+
for (const name of readdirSync(dir)) {
|
|
67
|
+
const abs = join(dir, name);
|
|
68
|
+
const st = statSync(abs);
|
|
69
|
+
if (st.isDirectory()) {
|
|
70
|
+
walkHtml(root, abs, urlBase, out);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!name.toLowerCase().endsWith('.html')) continue;
|
|
74
|
+
const rel = relative(root, abs).split(sep).join('/');
|
|
75
|
+
if (rel.toLowerCase() === 'index.html') {
|
|
76
|
+
out.push(urlBase);
|
|
77
|
+
} else if (name.toLowerCase() === 'index.html') {
|
|
78
|
+
const parent = rel.slice(0, -'/index.html'.length);
|
|
79
|
+
out.push(`${urlBase}/${parent}`);
|
|
80
|
+
} else {
|
|
81
|
+
out.push(`${urlBase}/${rel.replace(/\.html$/i, '')}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -21,7 +21,14 @@ export {
|
|
|
21
21
|
} from './content/parse';
|
|
22
22
|
|
|
23
23
|
export { absoluteUrl, ogImageUrl } from './config';
|
|
24
|
-
export type { SiteConfig } from './config';
|
|
24
|
+
export type { SiteConfig, PathMount } from './config';
|
|
25
|
+
export {
|
|
26
|
+
normalizePathMounts,
|
|
27
|
+
pathMountReservedSlugs,
|
|
28
|
+
resolvePathMountDir,
|
|
29
|
+
copyPathMounts,
|
|
30
|
+
listPathMountHtmlUrls
|
|
31
|
+
} from './paths';
|
|
25
32
|
export type {
|
|
26
33
|
PostMeta,
|
|
27
34
|
PostSource,
|
|
@@ -317,7 +317,8 @@ a:hover {
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
.featured .post-card {
|
|
320
|
-
padding: 0
|
|
320
|
+
/* Do not set padding: 0 — sites that paint boxed cards need horizontal inset.
|
|
321
|
+
Essay cards already have horizontal padding 0 from .post-card. */
|
|
321
322
|
border-bottom: none;
|
|
322
323
|
}
|
|
323
324
|
|
|
@@ -344,7 +345,7 @@ a:hover {
|
|
|
344
345
|
border-bottom: 1px solid var(--rule);
|
|
345
346
|
}
|
|
346
347
|
|
|
347
|
-
.post-list >
|
|
348
|
+
.post-list > li:first-child .post-card {
|
|
348
349
|
padding-top: 0;
|
|
349
350
|
}
|
|
350
351
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@filepress/import",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Crawl an existing site and scaffold a filepress content-only sibling (optional Ollama restyle).",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"fast-xml-parser": "^5.2.5",
|
|
17
17
|
"linkedom": "^0.18.12",
|
|
18
|
+
"ollanet": "^0.4.0",
|
|
18
19
|
"turndown": "^7.2.1"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|