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.
Files changed (32) hide show
  1. package/README.md +36 -10
  2. package/package.json +99 -99
  3. package/packages/app/README.md +19 -0
  4. package/packages/app/package.json +1 -1
  5. package/packages/app/src/lib/genie/GeniePanel.svelte +672 -144
  6. package/packages/app/src/lib/genie/config-patch.ts +72 -0
  7. package/packages/app/src/lib/genie/ops.ts +231 -10
  8. package/packages/app/src/lib/genie/store.ts +12 -3
  9. package/packages/app/src/lib/pages.server.ts +4 -2
  10. package/packages/app/src/routes/sitemap.xml/+server.ts +5 -2
  11. package/packages/app/vite-plugin-genie.ts +64 -1
  12. package/packages/app/vite-plugin-path-mounts.ts +107 -0
  13. package/packages/app/vite.config.ts +112 -81
  14. package/packages/core/README.md +29 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/src/lib/config.ts +12 -1
  17. package/packages/core/src/lib/content/feeds.ts +5 -1
  18. package/packages/core/src/lib/content/pages.ts +11 -1
  19. package/packages/core/src/lib/content/parse.ts +12 -3
  20. package/packages/core/src/lib/index.ts +2 -1
  21. package/packages/core/src/lib/paths-shared.ts +89 -0
  22. package/packages/core/src/lib/paths.ts +84 -0
  23. package/packages/core/src/lib/server.ts +8 -1
  24. package/packages/core/src/lib/styles/theme.css +3 -2
  25. package/packages/import/package.json +2 -1
  26. package/packages/import/src/cli.ts +77 -3
  27. package/packages/import/src/ollama.ts +43 -1
  28. package/packages/import/src/ollanet-scan.ts +132 -0
  29. package/packages/import/tsconfig.json +1 -1
  30. package/scripts/copy-path-mounts.mjs +40 -0
  31. package/scripts/create-site.mjs +4 -11
  32. 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
- /** Optional fixed port from `filepress dev|preview --port` (via FILEPRESS_PORT). */
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
- server: fixedPort
69
- ? { port: fixedPort, strictPort: true }
70
- : undefined,
71
- preview: fixedPort
72
- ? { port: fixedPort, strictPort: true }
73
- : undefined,
74
- // Regex aliases (exact) so npm installs don't need workspace links, and so a
75
- // bare `@filepress/core` string alias can't steal `/server` + `/theme`.
76
- resolve: {
77
- alias: [
78
- { find: /^@filepress\/core\/server$/, replacement: coreServer },
79
- { find: /^@filepress\/core\/theme$/, replacement: coreTheme },
80
- { find: /^@filepress\/core$/, replacement: coreEntry }
81
- ]
82
- },
83
- plugins: [
84
- criticalThemePlugin(siteTheme, criticalThemeOut),
85
- geniePlugin(siteRoot),
86
- sveltekit({
87
- alias: {
88
- // Site configs (monorepo or linked) import from `getfilepress`.
89
- getfilepress: coreEntry,
90
- 'getfilepress/server': coreServer,
91
- 'getfilepress/theme': coreTheme,
92
- '$site-config': siteConfig,
93
- // Loaded after the core Essay theme so site rules win the cascade.
94
- '$site-theme': siteTheme,
95
- // Per-site critical tokens (written under site/.filepress/).
96
- '$critical-theme': criticalThemeOut
97
- },
98
-
99
- // Absolute `/_app/...` asset URLs — more reliable on CDN/custom domains
100
- // than `./_app/...` relative links (avoids unstyled flashes on deploy).
101
- paths: {
102
- relative: false
103
- },
104
-
105
- files: {
106
- assets: assetsDir
107
- },
108
-
109
- compilerOptions: {
110
- runes: ({ filename }) =>
111
- filename.split(/[/\\]/).includes('node_modules') ? undefined : true
112
- },
113
-
114
- adapter: adapter({
115
- pages: siteBuild,
116
- assets: siteBuild,
117
- fallback: '404.html',
118
- strict: true
119
- }),
120
-
121
- prerender: {
122
- entries: ['*'],
123
-
124
- // Some parameterized routes legitimately have zero pages: /page/[n]
125
- // when everything fits on page 1, /tags/[tag] when no listed post
126
- // carries a tag, and /[slug] itself when every post is still
127
- // draft:true (a site before its first published essay). Ignore
128
- // those; stay strict for any other unseen route.
129
- handleUnseenRoutes: ({ routes }) => {
130
- const emptyOk = new Set(['/page/[n]', '/tags/[tag]', '/[slug]']);
131
- const unexpected = routes.filter((r) => !emptyOk.has(r));
132
- if (unexpected.length > 0) {
133
- throw new Error(
134
- `Routes marked prerenderable but not prerendered: ${unexpected.join(', ')}`
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.2",
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 posts. */
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -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) => parsePage(`/pages/${name}`, readFileSync(join(dir, name), 'utf8')));
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(path: string, raw: string): PageSource {
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
- if (RESERVED.has(slug)) {
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
  }
@@ -20,7 +20,8 @@ export type {
20
20
  NewsletterConfig,
21
21
  Topic,
22
22
  NavItem,
23
- NavIconName
23
+ NavIconName,
24
+ PathMount
24
25
  } from './config';
25
26
 
26
27
  export { formatDate } from './format';
@@ -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 > .post-card:first-child {
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.2",
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": {