getfilepress 0.1.27 → 0.1.28

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 CHANGED
@@ -38,7 +38,7 @@ Crawls a public site (sitemap/RSS preferred) into a sibling content tree. Option
38
38
  | Genie (dev only) | [Genie](https://getfilepress.com/genie) · [spec](docs/GENIE_MODE_SPEC.md) |
39
39
  | Deploy | [Deploy](https://getfilepress.com/deploy) · [docs/DEPLOY.md](docs/DEPLOY.md) |
40
40
  | Sibling / external sites | [docs/EXTERNAL_SITES.md](docs/EXTERNAL_SITES.md) |
41
- | Local ports | [docs/LOCALBERTH.md](docs/LOCALBERTH.md) |
41
+ | Local ports | [docs/LOCALSLIP.md](docs/LOCALSLIP.md) |
42
42
  | Agent skill page | [Skill page](https://getfilepress.com/skill-page) · [docs/SKILL_PAGE.md](docs/SKILL_PAGE.md) |
43
43
 
44
44
  ## In this repo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getfilepress",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilePress — file-based Markdown blog engine. Link this package from a content-only site (config + posts), then run `filepress build`.",
@@ -2,9 +2,9 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
 
5
- /** `localberth get <name>` — undefined if the CLI or lease is missing. */
6
- export function localberthGet(name: string): number | undefined {
7
- const r = spawnSync('localberth', ['get', name], {
5
+ /** `localslip get <name>` — undefined if the CLI or lease is missing. */
6
+ export function localslipGet(name: string): number | undefined {
7
+ const r = spawnSync('localslip', ['get', name], {
8
8
  encoding: 'utf8',
9
9
  timeout: 5000,
10
10
  windowsHide: true,
@@ -10,7 +10,7 @@ import {
10
10
  } from './vite-plugin-critical-theme.ts';
11
11
  import { geniePlugin, resolveGenieMount } from './vite-plugin-genie.ts';
12
12
  import { pathMountsPlugin } from './vite-plugin-path-mounts.ts';
13
- import { filepressLeaseName, localberthGet } from './localberth-port.ts';
13
+ import { filepressLeaseName, localslipGet } from './localslip-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
16
  import { buildRedirectRules, THEME_PRESETS, type ThemePreset } from '../core/src/lib/config.ts';
@@ -117,7 +117,7 @@ function resolvePort(): number | undefined {
117
117
  const n = Number(raw);
118
118
  return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
119
119
  }
120
- return localberthGet(filepressLeaseName(siteRoot));
120
+ return localslipGet(filepressLeaseName(siteRoot));
121
121
  }
122
122
 
123
123
  const fixedPort = resolvePort();
@@ -16,4 +16,19 @@
16
16
  0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
17
17
  />
18
18
  </svg>
19
+ {:else if name === 'hn'}
20
+ <svg class="nav-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
21
+ <rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.2" />
22
+ <path
23
+ fill="currentColor"
24
+ d="M4.35 4h1.85l1.8 3.55L9.8 4h1.85L9.15 8.35V12H7.85V8.35L4.35 4z"
25
+ />
26
+ </svg>
27
+ {:else if name === 'thingiverse'}
28
+ <svg class="nav-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
29
+ <path
30
+ fill="currentColor"
31
+ d="M8 1.4 14.2 4.7v6.6L8 14.6 1.8 11.3V4.7L8 1.4zm0 1.7L3.9 5.2 8 7.4l4.1-2.2L8 3.1zM3.1 6.3 7.3 8.6v4.3L3.1 10.6V6.3zm9.8 0v4.3L8.7 12.9V8.6l4.2-2.3z"
32
+ />
33
+ </svg>
19
34
  {/if}
@@ -1,32 +1,68 @@
1
1
  <script lang="ts">
2
- import type { SiteConfig } from '../config';
2
+ import type { NavItem, SiteConfig } from '../config';
3
3
  import { isPathMountHref } from '../paths-shared';
4
4
  import NavIcon from './NavIcon.svelte';
5
5
 
6
6
  let { site, year = new Date().getFullYear() }: { site: SiteConfig; year?: number } = $props();
7
+
8
+ const collections = $derived(site.footerLinks.filter((item) => item.icon));
9
+ const utilities = $derived(site.footerLinks.filter((item) => !item.icon));
10
+
11
+ function linkAttrs(item: NavItem) {
12
+ if (item.icon || /^https?:\/\//i.test(item.href)) {
13
+ return { target: '_blank' as const, rel: 'noopener noreferrer' };
14
+ }
15
+ if (isPathMountHref(item.href, site.paths)) {
16
+ return { 'data-sveltekit-reload': true };
17
+ }
18
+ return {};
19
+ }
7
20
  </script>
8
21
 
22
+ {#snippet footerLink(item: NavItem)}
23
+ <a
24
+ href={item.href}
25
+ class:has-icon={Boolean(item.icon)}
26
+ class:nav-github={item.icon === 'github'}
27
+ {...linkAttrs(item)}
28
+ >
29
+ {#if item.icon}
30
+ <NavIcon name={item.icon} />
31
+ {/if}
32
+ <span class="nav-label">{item.label}</span>
33
+ </a>
34
+ {/snippet}
35
+
9
36
  <footer class="site-footer">
10
37
  <div class="wrap">
11
- <span>&copy; {year} {site.author}</span>
12
- <span class="footer-links">
13
- {#each site.footerLinks as item (item.href)}
14
- <a
15
- href={item.href}
16
- class:has-icon={Boolean(item.icon)}
17
- class:nav-github={item.icon === 'github'}
18
- {...(item.icon === 'github'
19
- ? { target: '_blank', rel: 'noopener noreferrer' }
20
- : isPathMountHref(item.href, site.paths)
21
- ? { 'data-sveltekit-reload': true }
22
- : {})}
23
- >
24
- {#if item.icon}
25
- <NavIcon name={item.icon} />
26
- {/if}
27
- <span class="nav-label">{item.label}</span>
28
- </a>
29
- {/each}
38
+ <span class="footer-meta">
39
+ <span>&copy; {year} {site.author}</span>
40
+ {#if site.footerCredit}
41
+ <span class="footer-credit">
42
+ {site.footerCredit.preface}
43
+ <a
44
+ href={site.footerCredit.href}
45
+ target="_blank"
46
+ rel="noopener noreferrer"
47
+ >{site.footerCredit.label}</a>
48
+ </span>
49
+ {/if}
50
+ </span>
51
+ <span class="footer-end">
52
+ {#if collections.length}
53
+ <span class="footer-collections">
54
+ {#each collections as item (item.href)}
55
+ {@render footerLink(item)}
56
+ {/each}
57
+ </span>
58
+ {/if}
59
+ {#if utilities.length}
60
+ <span class="footer-links">
61
+ {#each utilities as item (item.href)}
62
+ {@render footerLink(item)}
63
+ {/each}
64
+ </span>
65
+ {/if}
30
66
  </span>
31
67
  </div>
32
68
  </footer>
@@ -30,7 +30,8 @@ export interface Topic {
30
30
  }
31
31
 
32
32
  /** Built-in icons that chrome can render beside a nav/footer label. */
33
- export type NavIconName = 'github';
33
+ export const NAV_ICON_NAMES = ['github', 'hn', 'thingiverse'] as const;
34
+ export type NavIconName = (typeof NAV_ICON_NAMES)[number];
34
35
 
35
36
  export interface NavItem {
36
37
  label: string;
@@ -39,6 +40,14 @@ export interface NavItem {
39
40
  icon?: NavIconName;
40
41
  }
41
42
 
43
+ /** Optional footer credit, e.g. a named partnership with an outbound link. */
44
+ export interface FooterCredit {
45
+ /** Words before the linked name. Default: "In partnership with". */
46
+ preface: string;
47
+ label: string;
48
+ href: string;
49
+ }
50
+
42
51
  /** Fully-resolved site configuration (after defaults are applied). */
43
52
  export interface SiteConfig {
44
53
  title: string;
@@ -73,6 +82,8 @@ export interface SiteConfig {
73
82
  * Pass an explicit list (including RSS/Topics if you still want them) to customize.
74
83
  */
75
84
  footerLinks: NavItem[];
85
+ /** Partnership / credit line under the copyright; null when omitted. */
86
+ footerCredit: FooterCredit | null;
76
87
  topics: Topic[];
77
88
  newsletter: NewsletterConfig | null;
78
89
  /**
@@ -105,6 +116,12 @@ export interface SiteConfigInput {
105
116
  nav?: NavItem[];
106
117
  /** Custom footer links; replaces the default RSS + Topics row when set. */
107
118
  footerLinks?: NavItem[];
119
+ /** Optional credit under the copyright (partnership, studio, etc.). */
120
+ footerCredit?: {
121
+ preface?: string;
122
+ label: string;
123
+ href: string;
124
+ };
108
125
  topics?: Topic[];
109
126
  newsletter?: NewsletterConfig | null;
110
127
  /** Mount site-relative dirs at URL prefixes (docs shells, etc.). */
@@ -129,13 +146,31 @@ function normalizeNavItems(items: NavItem[] | undefined): NavItem[] | null {
129
146
  throw new Error('filepress.config: nav/footerLinks entries need non-empty label and href.');
130
147
  }
131
148
  const icon = item.icon;
132
- if (icon != null && icon !== 'github') {
133
- throw new Error(`filepress.config: unsupported icon "${String(icon)}" (supported: github).`);
149
+ if (icon != null && !NAV_ICON_NAMES.includes(icon)) {
150
+ throw new Error(
151
+ `filepress.config: unsupported icon "${String(icon)}" (supported: ${NAV_ICON_NAMES.join(', ')}).`
152
+ );
134
153
  }
135
154
  return icon ? { label, href, icon } : { label, href };
136
155
  });
137
156
  }
138
157
 
158
+ function normalizeFooterCredit(
159
+ credit: SiteConfigInput['footerCredit']
160
+ ): FooterCredit | null {
161
+ if (!credit) return null;
162
+ const label = (credit.label ?? '').trim();
163
+ const href = (credit.href ?? '').trim();
164
+ if (!label || !href) {
165
+ throw new Error('filepress.config: footerCredit needs non-empty label and href.');
166
+ }
167
+ if (!/^https?:\/\//i.test(href)) {
168
+ throw new Error('filepress.config: footerCredit.href must start with http(s)://.');
169
+ }
170
+ const preface = (credit.preface ?? 'In partnership with').trim() || 'In partnership with';
171
+ return { preface, label, href };
172
+ }
173
+
139
174
  /** Path to page 1 of the chronological post index. */
140
175
  export function postsIndexPath(site: Pick<SiteConfig, 'homePage'>): string {
141
176
  return site.homePage ? '/posts' : '/';
@@ -202,6 +237,7 @@ export function defineFilepressConfig(input: SiteConfigInput): SiteConfig {
202
237
  homePage,
203
238
  nav: normalizeNavItems(input.nav) ?? defaultNav,
204
239
  footerLinks: normalizeNavItems(input.footerLinks) ?? [...defaultFooterLinks],
240
+ footerCredit: normalizeFooterCredit(input.footerCredit),
205
241
  topics: input.topics ?? [],
206
242
  newsletter: input.newsletter ?? null,
207
243
  paths: normalizePathMounts(input.paths),
@@ -14,7 +14,8 @@ export {
14
14
  ogImageUrl,
15
15
  postsIndexPath,
16
16
  buildRedirectRules,
17
- THEME_PRESETS
17
+ THEME_PRESETS,
18
+ NAV_ICON_NAMES
18
19
  } from './config';
19
20
  export type {
20
21
  SiteConfig,
@@ -23,6 +24,7 @@ export type {
23
24
  Topic,
24
25
  NavItem,
25
26
  NavIconName,
27
+ FooterCredit,
26
28
  PathMount,
27
29
  ThemePreset,
28
30
  RedirectRule
@@ -212,10 +212,27 @@ body {
212
212
  color: var(--ink-faint);
213
213
  }
214
214
 
215
+ .site-footer .footer-meta {
216
+ display: flex;
217
+ flex-direction: column;
218
+ align-items: flex-start;
219
+ gap: 0.2rem;
220
+ }
221
+
222
+ .site-footer .footer-end {
223
+ display: flex;
224
+ flex-direction: column;
225
+ align-items: flex-end;
226
+ gap: 0.45rem;
227
+ }
228
+
229
+ .site-footer .footer-collections,
215
230
  .site-footer .footer-links {
216
231
  display: flex;
217
232
  align-items: center;
218
- gap: 1.1rem;
233
+ flex-wrap: wrap;
234
+ justify-content: flex-end;
235
+ gap: 0.35rem 1.1rem;
219
236
  }
220
237
 
221
238
  .site-footer a {
@@ -16,12 +16,14 @@ function asArray<T>(v: T | T[] | undefined | null): T[] {
16
16
  return Array.isArray(v) ? v : [v];
17
17
  }
18
18
 
19
- function classify(url: string, origin: string): DiscoveredUrl['kind'] {
19
+ export function classify(url: string, origin: string): DiscoveredUrl['kind'] {
20
20
  const u = new URL(url);
21
21
  if (u.origin !== new URL(origin).origin) return 'other';
22
22
  const path = u.pathname.replace(/\/+$/, '') || '/';
23
23
  if (path === '/') return 'home';
24
- if (/\/tags?\//i.test(path) || /\/topics?\//i.test(path)) return 'tag';
24
+ if (/\/wp-(content|includes|admin|json|login)/i.test(path)) return 'other';
25
+ if (/\/(tags?|topics?|categor(?:y|ies))\//i.test(path)) return 'tag';
26
+ if (/^\/\d{4}\/\d{2}(?:\/\d{2})?\//.test(path)) return 'post';
25
27
  if (/\/(writing|essays|posts|blog|articles)\/[^/]+/i.test(path)) return 'post';
26
28
  if (/\/(writing|essays|posts|blog|articles)\/?$/i.test(path)) return 'listing';
27
29
  if (/\/(about|contact|speaking|now|colophon|privacy|resume|cv)\/?$/i.test(path)) return 'page';
@@ -3,6 +3,12 @@ import type { DiscoverResult } from './discover.ts';
3
3
  import { fetchText, resolveUrl, sameOrigin } from './fetch.ts';
4
4
  import { htmlToMarkdown } from './html-to-md.ts';
5
5
  import type { SiteIR, SiteIRPage, SiteIRPost } from './ir.ts';
6
+ import {
7
+ fetchWordpressCatalog,
8
+ navFromWordpress,
9
+ topicsFromWordpress,
10
+ type WpCatalog
11
+ } from './wordpress.ts';
6
12
 
7
13
  const RESERVED = new Set([
8
14
  'posts',
@@ -137,12 +143,14 @@ function normalizeTagSlug(raw: string): string {
137
143
 
138
144
  function tagsFromDoc(document: Document): string[] {
139
145
  const tags = new Set<string>();
140
- for (const a of document.querySelectorAll('a[href*="/tag"], a[href*="/tags/"]')) {
146
+ for (const a of document.querySelectorAll(
147
+ 'a[href*="/tag"], a[href*="/tags/"], a[href*="/category/"], a[rel="tag"], a[rel="category"]'
148
+ )) {
141
149
  const href = a.getAttribute('href') ?? '';
142
- const m = href.match(/\/tags?\/([^/]+)/i);
150
+ const m = href.match(/\/(?:tags?|categor(?:y|ies))\/([^/]+)/i);
143
151
  if (m) {
144
152
  const t = normalizeTagSlug(decodeURIComponent(m[1]));
145
- if (t) tags.add(t);
153
+ if (t && t !== 'uncategorized') tags.add(t);
146
154
  }
147
155
  }
148
156
  for (const chip of document.querySelectorAll('.tag-chip, .tag, [rel="tag"]')) {
@@ -186,11 +194,30 @@ function uniqueSlug(base: string, used: Set<string>): string {
186
194
  return candidate;
187
195
  }
188
196
 
197
+ function pathKeyOf(url: string): string {
198
+ try {
199
+ return new URL(url).pathname.replace(/\/+$/, '') || '/';
200
+ } catch {
201
+ return url;
202
+ }
203
+ }
204
+
189
205
  /** Build SiteIR from discovery + HTML extraction. */
190
206
  export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
191
207
  const { origin, urls, rss, rssTitle } = discovered;
192
208
  const notes: string[] = [];
193
209
  const usedSlugs = new Set<string>();
210
+ let wp: WpCatalog | null = null;
211
+ try {
212
+ wp = await fetchWordpressCatalog(origin);
213
+ if (wp) {
214
+ notes.push(
215
+ `WordPress REST: ${wp.posts.length} posts, ${wp.pages.length} pages, ${wp.categories.length} categories.`
216
+ );
217
+ }
218
+ } catch (e) {
219
+ notes.push(`WordPress REST skipped: ${e instanceof Error ? e.message : e}`);
220
+ }
194
221
 
195
222
  const homeUrl = urls.find((u) => u.kind === 'home')?.url ?? `${origin}/`;
196
223
  const homeDoc = await loadDoc(homeUrl);
@@ -235,6 +262,9 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
235
262
  if (homeMd.length > 200 && paragraphs.length >= 2) {
236
263
  homeMarkdown = homeMd;
237
264
  notes.push('Home bio is long enough for pages/home.md; the post index moves to /posts.');
265
+ } else if (wp && homeMd.length > 20) {
266
+ homeMarkdown = homeMd;
267
+ notes.push('WordPress home page kept as pages/home.md even though it is short.');
238
268
  } else {
239
269
  notes.push('Home bio mapped to config `lede` (posts remain the index).');
240
270
  }
@@ -243,10 +273,12 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
243
273
  const posts: SiteIRPost[] = [];
244
274
  const rssByPath = new Map(rss.map((r) => [new URL(r.link, origin).pathname.replace(/\/+$/, ''), r]));
245
275
 
276
+ const wpByPath = new Map((wp?.posts ?? []).map((p) => [pathKeyOf(p.link), p]));
246
277
  const postUrls = [
247
278
  ...new Set([
248
279
  ...urls.filter((u) => u.kind === 'post').map((u) => u.url),
249
- ...rss.map((r) => resolveUrl(origin, r.link)).filter((u): u is string => Boolean(u))
280
+ ...rss.map((r) => resolveUrl(origin, r.link)).filter((u): u is string => Boolean(u)),
281
+ ...(wp?.posts ?? []).map((p) => p.link)
250
282
  ])
251
283
  ];
252
284
 
@@ -269,17 +301,28 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
269
301
  notes.push(`Skipped thin post body: ${finalUrl}`);
270
302
  continue;
271
303
  }
272
- const slug = uniqueSlug(slugFromUrl(finalUrl), usedSlugs);
304
+ const wpPost =
305
+ wpByPath.get(pathKey) ??
306
+ wp?.posts.find((p) => p.slug === slugFromUrl(finalUrl) || pathKeyOf(p.link) === pathKey);
307
+ const slug = uniqueSlug(wpPost?.slug || slugFromUrl(finalUrl), usedSlugs);
273
308
  const date =
309
+ (wpPost?.date && /^\d{4}-\d{2}-\d{2}$/.test(wpPost.date) ? wpPost.date : null) ||
274
310
  rfc822ToIso(rssItem?.pubDate ?? null) ||
275
311
  dateFromDoc(document) ||
276
312
  new Date().toISOString().slice(0, 10);
313
+ // WP REST categories/tags are authoritative. HTML chips pick up theme
314
+ // category widgets and stamp every heading onto every post.
315
+ const tags = wpPost ? [...wpPost.tags] : tagsFromDoc(document);
277
316
  posts.push({
278
317
  slug,
279
- title: rssItem?.title || titleFromDoc(document),
318
+ title: wpPost?.title || rssItem?.title || titleFromDoc(document),
280
319
  date,
281
- tags: tagsFromDoc(document),
282
- description: rssItem?.description || subtitle || metaContent(document, 'meta[name="description"]'),
320
+ tags,
321
+ description:
322
+ wpPost?.excerpt ||
323
+ rssItem?.description ||
324
+ subtitle ||
325
+ metaContent(document, 'meta[name="description"]'),
283
326
  markdown,
284
327
  sourceUrl: finalUrl,
285
328
  imageUrls: extractImages(clone, finalUrl, origin)
@@ -290,7 +333,18 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
290
333
  posts.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)));
291
334
 
292
335
  const pages: SiteIRPage[] = [];
293
- const pageUrls = urls.filter((u) => u.kind === 'page');
336
+ const pageUrlSet = new Set<string>();
337
+ const pageUrls = [
338
+ ...urls.filter((u) => u.kind === 'page'),
339
+ ...(wp?.pages ?? [])
340
+ .filter((p) => !p.isHome)
341
+ .map((p) => ({ url: p.link, kind: 'page' as const }))
342
+ ].filter((u) => {
343
+ const key = pathKeyOf(u.url);
344
+ if (pageUrlSet.has(key)) return false;
345
+ pageUrlSet.add(key);
346
+ return true;
347
+ });
294
348
  let order = 1;
295
349
  for (const { url } of pageUrls) {
296
350
  const loaded = await loadDoc(url);
@@ -298,6 +352,10 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
298
352
  const { document, finalUrl } = loaded;
299
353
  const path = new URL(finalUrl).pathname.replace(/\/+$/, '') || '/';
300
354
  if (path === '/') continue;
355
+ if (slugFromUrl(finalUrl) === 'sample-page') {
356
+ notes.push(`Skipped default WordPress sample page: ${finalUrl}`);
357
+ continue;
358
+ }
301
359
  const main = pickMain(document);
302
360
  const clone = main.cloneNode(true) as Element;
303
361
  clone.querySelector('h1')?.remove();
@@ -328,32 +386,38 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
328
386
  notes.push(`Page ${finalUrl} → /${slug}`);
329
387
  }
330
388
 
331
- // Topics from tags
389
+ // Topics from WordPress categories when present; else from extracted tags
332
390
  const tagCounts = new Map<string, number>();
333
391
  for (const p of posts) {
334
392
  for (const t of p.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
335
393
  }
336
- const topics = [...tagCounts.entries()]
337
- .sort((a, b) => b[1] - a[1])
338
- .slice(0, 12)
339
- .map(([tag]) => ({
340
- label: tag.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
341
- tag
342
- }));
394
+ const topics = wp
395
+ ? topicsFromWordpress(wp)
396
+ : [...tagCounts.entries()]
397
+ .sort((a, b) => b[1] - a[1])
398
+ .slice(0, 12)
399
+ .map(([tag]) => ({
400
+ label: tag.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
401
+ tag
402
+ }));
343
403
 
344
- const nav: Array<{ label: string; href: string }> = homeMarkdown
345
- ? [
346
- { label: 'Home', href: '/' },
347
- { label: 'Posts', href: '/posts' }
348
- ]
349
- : [{ label: 'Posts', href: '/' }];
350
- for (const page of pages) {
351
- nav.push({
352
- label: page.title,
353
- href: `/${page.slug}`
354
- });
404
+ const nav: Array<{ label: string; href: string }> = wp
405
+ ? navFromWordpress(wp, { homePage: Boolean(homeMarkdown) })
406
+ : homeMarkdown
407
+ ? [
408
+ { label: 'Home', href: '/' },
409
+ { label: 'Posts', href: '/posts' }
410
+ ]
411
+ : [{ label: 'Posts', href: '/' }];
412
+ if (!wp) {
413
+ for (const page of pages) {
414
+ nav.push({
415
+ label: page.title,
416
+ href: `/${page.slug}`
417
+ });
418
+ }
419
+ if (topics.length) nav.push({ label: 'Topics', href: '/topics' });
355
420
  }
356
- if (topics.length) nav.push({ label: 'Topics', href: '/topics' });
357
421
 
358
422
  const assets: string[] = [];
359
423
  if (homeDoc) {
@@ -365,6 +429,12 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
365
429
  const abs = resolveUrl(homeUrl, href);
366
430
  if (abs && sameOrigin(abs, origin)) assets.push(abs);
367
431
  }
432
+ const logoImg = homeDoc.document.querySelector('img.custom-logo, .custom-logo-link img');
433
+ const logoSrc = logoImg?.getAttribute('src');
434
+ if (logoSrc) {
435
+ const abs = resolveUrl(homeUrl, logoSrc);
436
+ if (abs) assets.push(abs);
437
+ }
368
438
  }
369
439
  // Common fallbacks often linked from HTML even when not in <link>
370
440
  for (const path of ['/favicon.ico', '/favicon.svg', '/favicon-64.png', '/apple-touch-icon.png']) {
@@ -372,7 +442,7 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
372
442
  }
373
443
 
374
444
  return {
375
- source: { url: origin, generator },
445
+ source: { url: origin, generator: generator ?? (wp ? 'WordPress' : null) },
376
446
  identity: {
377
447
  title,
378
448
  description: description || `${title} — imported into filepress.`,
@@ -438,10 +438,54 @@ export function parseBriefJson(raw: string): DesignBrief {
438
438
  };
439
439
  }
440
440
 
441
+ function normalizeHex(raw: string | undefined): string | undefined {
442
+ if (!raw) return undefined;
443
+ const v = raw.trim();
444
+ if (/^#([0-9a-fA-F]{3})$/.test(v)) {
445
+ return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
446
+ }
447
+ if (/^#([0-9a-fA-F]{6})$/.test(v)) return v;
448
+ return undefined;
449
+ }
450
+
451
+ function firstInlineHex(html: string, patterns: RegExp[]): string | undefined {
452
+ for (const re of patterns) {
453
+ const m = html.match(re);
454
+ const hex = normalizeHex(m?.[1]);
455
+ if (hex) return hex;
456
+ }
457
+ return undefined;
458
+ }
459
+
460
+ /** Fallback when the source theme (Astra, etc.) inlines hex instead of :root tokens. */
461
+ function tokensFromInlineRules(html: string): Partial<DesignBrief['tokens']> {
462
+ const bg = firstInlineHex(html, [
463
+ /body(?:,[^{]{0,80})?\{[^}]*background-color:\s*(#[0-9a-fA-F]{3,8})/i
464
+ ]);
465
+ const accent = firstInlineHex(html, [
466
+ /::selection\{[^}]*background-color:\s*(#[0-9a-fA-F]{3,8})/i,
467
+ /\.elementor-button[^{]*\{[^}]*background-color:\s*(#[0-9a-fA-F]{3,8})/i,
468
+ /(?:^|[,}])\s*a(?:,[^{]{0,40})?\{[^}]*color:\s*(#[0-9a-fA-F]{3,8})/i
469
+ ]);
470
+ const ink = firstInlineHex(html, [
471
+ /body,h1,[^{]{0,120}\{[^}]*color:\s*(#[0-9a-fA-F]{3,8})/i,
472
+ /body(?:,[^{]{0,80})?\{[^}]*[^-]color:\s*(#[0-9a-fA-F]{3,8})/i
473
+ ]);
474
+ const out: Partial<DesignBrief['tokens']> = {};
475
+ if (accent) {
476
+ out.accent = accent;
477
+ out.accentStrong = darken(accent);
478
+ }
479
+ if (bg) out.bg = bg;
480
+ if (ink) out.ink = ink;
481
+ return out;
482
+ }
483
+
441
484
  /** Extract CSS custom properties from inline :root blocks (source site). */
442
485
  export function tokensFromSourceCss(html: string): Partial<DesignBrief['tokens']> {
486
+ const fallback = tokensFromInlineRules(html);
443
487
  const m = html.match(/:root\s*\{([^}]+)\}/);
444
- if (!m) return {};
488
+ if (!m) return fallback;
445
489
  const block = m[1];
446
490
  const vars = new Map<string, string>();
447
491
  for (const hit of block.matchAll(/--([a-zA-Z0-9-_]+)\s*:\s*([^;}]+)/g)) {
@@ -457,15 +501,15 @@ export function tokensFromSourceCss(html: string): Partial<DesignBrief['tokens']
457
501
  };
458
502
  const firstHex = (...names: string[]) => {
459
503
  for (const n of names) {
460
- const v = resolve(n);
461
- if (v && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) return v;
504
+ const v = normalizeHex(resolve(n));
505
+ if (v) return v;
462
506
  }
463
507
  return undefined;
464
508
  };
465
509
  const accent = firstHex('accent-color', 'accent', 'color-gold', 'color-augment');
466
510
  const bg = firstHex('bg-color', 'bg', 'color-bg', 'color-cream');
467
511
  const ink = firstHex('text-color', 'ink', 'color-espresso');
468
- const out: Partial<DesignBrief['tokens']> = {};
512
+ const out: Partial<DesignBrief['tokens']> = { ...fallback };
469
513
  if (accent) {
470
514
  out.accent = accent;
471
515
  out.accentStrong = firstHex('accent-strong', 'color-understand') || accent;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * WordPress REST overlay for filepress import.
3
+ * Sitemap + HTML still own the crawl; this fills categories → tags and nav.
4
+ */
5
+ import { fetchText, originOf } from './fetch.ts';
6
+
7
+ export type WpTerm = {
8
+ id: number;
9
+ name: string;
10
+ slug: string;
11
+ count: number;
12
+ };
13
+
14
+ export type WpPostStub = {
15
+ slug: string;
16
+ title: string;
17
+ date: string;
18
+ link: string;
19
+ excerpt: string | null;
20
+ /** Category + tag slugs; `uncategorized` dropped. */
21
+ tags: string[];
22
+ };
23
+
24
+ export type WpPageStub = {
25
+ slug: string;
26
+ title: string;
27
+ link: string;
28
+ excerpt: string | null;
29
+ isHome: boolean;
30
+ };
31
+
32
+ export type WpCatalog = {
33
+ posts: WpPostStub[];
34
+ pages: WpPageStub[];
35
+ categories: WpTerm[];
36
+ tags: WpTerm[];
37
+ };
38
+
39
+ const SKIP_CATEGORY = new Set(['uncategorized']);
40
+ const SKIP_PAGE = new Set(['sample-page']);
41
+
42
+ export function decodeWpText(raw: string): string {
43
+ return raw
44
+ .replace(/<[^>]+>/g, ' ')
45
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
46
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
47
+ .replace(/&amp;/g, '&')
48
+ .replace(/&lt;/g, '<')
49
+ .replace(/&gt;/g, '>')
50
+ .replace(/&quot;/g, '"')
51
+ .replace(/&#039;|&apos;/g, "'")
52
+ .replace(/&nbsp;/g, ' ')
53
+ .replace(/\s+/g, ' ')
54
+ .trim();
55
+ }
56
+
57
+ export function termsToTags(categories: WpTerm[], tags: WpTerm[], categoryIds: number[], tagIds: number[]): string[] {
58
+ const byId = new Map<number, WpTerm>();
59
+ for (const t of [...categories, ...tags]) byId.set(t.id, t);
60
+ const out: string[] = [];
61
+ for (const id of [...categoryIds, ...tagIds]) {
62
+ const term = byId.get(id);
63
+ if (!term || SKIP_CATEGORY.has(term.slug)) continue;
64
+ if (!out.includes(term.slug)) out.push(term.slug);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ export function navFromWordpress(catalog: WpCatalog, opts: { homePage: boolean }): Array<{ label: string; href: string }> {
70
+ const nav: Array<{ label: string; href: string }> = opts.homePage
71
+ ? [
72
+ { label: 'Home', href: '/' },
73
+ { label: 'Posts', href: '/posts' }
74
+ ]
75
+ : [{ label: 'Posts', href: '/' }];
76
+ for (const c of catalog.categories) {
77
+ if (SKIP_CATEGORY.has(c.slug) || c.count < 1) continue;
78
+ nav.push({ label: c.name, href: `/tags/${c.slug}` });
79
+ }
80
+ for (const page of catalog.pages) {
81
+ if (page.isHome || SKIP_PAGE.has(page.slug)) continue;
82
+ const label = page.slug === 'contact-us' ? 'Contact' : page.title;
83
+ nav.push({ label, href: `/${page.slug}` });
84
+ }
85
+ return nav;
86
+ }
87
+
88
+ export function topicsFromWordpress(catalog: WpCatalog): Array<{ label: string; tag: string }> {
89
+ return catalog.categories
90
+ .filter((c) => !SKIP_CATEGORY.has(c.slug) && c.count > 0)
91
+ .map((c) => ({ label: c.name, tag: c.slug }));
92
+ }
93
+
94
+ type WpJsonPost = {
95
+ slug?: string;
96
+ title?: { rendered?: string };
97
+ date?: string;
98
+ link?: string;
99
+ excerpt?: { rendered?: string };
100
+ categories?: number[];
101
+ tags?: number[];
102
+ };
103
+
104
+ type WpJsonPage = WpJsonPost & { id?: number };
105
+
106
+ type WpJsonTerm = {
107
+ id?: number;
108
+ name?: string;
109
+ slug?: string;
110
+ count?: number;
111
+ };
112
+
113
+ async function fetchCollection<T>(url: string): Promise<T[] | null> {
114
+ const { status, text, contentType } = await fetchText(url, {
115
+ headers: { accept: 'application/json' }
116
+ });
117
+ if (status >= 400) return null;
118
+ if (contentType && !/json/i.test(contentType) && !text.trimStart().startsWith('[')) return null;
119
+ try {
120
+ const data = JSON.parse(text) as unknown;
121
+ return Array.isArray(data) ? (data as T[]) : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function mapTerm(t: WpJsonTerm): WpTerm | null {
128
+ if (typeof t.id !== 'number' || !t.slug) return null;
129
+ return {
130
+ id: t.id,
131
+ name: decodeWpText(String(t.name ?? t.slug)),
132
+ slug: String(t.slug),
133
+ count: typeof t.count === 'number' ? t.count : 0
134
+ };
135
+ }
136
+
137
+ /** Probe `/wp-json/wp/v2` and load posts, pages, categories, tags. Null if not WordPress. */
138
+ export async function fetchWordpressCatalog(sourceUrl: string): Promise<WpCatalog | null> {
139
+ const origin = originOf(sourceUrl.endsWith('/') ? sourceUrl : `${sourceUrl}/`);
140
+ const probe = await fetchText(`${origin}/wp-json/wp/v2/categories?per_page=1&_fields=id`, {
141
+ headers: { accept: 'application/json' }
142
+ });
143
+ if (probe.status >= 400) return null;
144
+ if (!/json/i.test(probe.contentType) && !probe.text.trimStart().startsWith('[')) return null;
145
+
146
+ const [postsRaw, pagesRaw, catsRaw, tagsRaw] = await Promise.all([
147
+ fetchCollection<WpJsonPost>(
148
+ `${origin}/wp-json/wp/v2/posts?per_page=100&_fields=slug,title,date,link,excerpt,categories,tags`
149
+ ),
150
+ fetchCollection<WpJsonPage>(
151
+ `${origin}/wp-json/wp/v2/pages?per_page=100&_fields=id,slug,title,date,link,excerpt`
152
+ ),
153
+ fetchCollection<WpJsonTerm>(`${origin}/wp-json/wp/v2/categories?per_page=100`),
154
+ fetchCollection<WpJsonTerm>(`${origin}/wp-json/wp/v2/tags?per_page=100`)
155
+ ]);
156
+
157
+ if (!postsRaw && !pagesRaw) return null;
158
+
159
+ const categories = (catsRaw ?? []).map(mapTerm).filter((t): t is WpTerm => Boolean(t));
160
+ const tags = (tagsRaw ?? []).map(mapTerm).filter((t): t is WpTerm => Boolean(t));
161
+
162
+ const posts: WpPostStub[] = (postsRaw ?? [])
163
+ .filter((p) => p.link && p.slug)
164
+ .map((p) => ({
165
+ slug: String(p.slug),
166
+ title: decodeWpText(String(p.title?.rendered ?? p.slug)),
167
+ date: String(p.date ?? '').slice(0, 10),
168
+ link: String(p.link),
169
+ excerpt: p.excerpt?.rendered ? decodeWpText(p.excerpt.rendered) : null,
170
+ tags: termsToTags(categories, tags, p.categories ?? [], p.tags ?? [])
171
+ }));
172
+
173
+ const pages: WpPageStub[] = (pagesRaw ?? [])
174
+ .filter((p) => p.link && p.slug)
175
+ .filter((p) => !SKIP_PAGE.has(String(p.slug)))
176
+ .map((p) => {
177
+ const link = String(p.link);
178
+ let path = '/';
179
+ try {
180
+ path = new URL(link).pathname.replace(/\/+$/, '') || '/';
181
+ } catch {
182
+ /* keep / */
183
+ }
184
+ return {
185
+ slug: String(p.slug),
186
+ title: decodeWpText(String(p.title?.rendered ?? p.slug)),
187
+ link,
188
+ excerpt: p.excerpt?.rendered ? decodeWpText(p.excerpt.rendered) : null,
189
+ isHome: path === '/'
190
+ };
191
+ });
192
+
193
+ return { posts, pages, categories, tags };
194
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Idempotent LocalBerth claim for FilePress (and sibling) dev ports.
3
+ * Idempotent LocalSlip claim for FilePress (and sibling) dev ports.
4
4
  * Usage: node scripts/ensure-lease.mjs <name> <port>
5
5
  * Missing CLI → one line, exit 0. Existing lease → keep it (do not rewrite).
6
6
  */
@@ -14,11 +14,11 @@ if (!name || !port) {
14
14
  }
15
15
 
16
16
  const opt = { encoding: 'utf8', timeout: 8000, windowsHide: true, shell: process.platform === 'win32' };
17
- const got = spawnSync('localberth', ['get', name], { ...opt, stdio: ['ignore', 'pipe', 'ignore'] });
17
+ const got = spawnSync('localslip', ['get', name], { ...opt, stdio: ['ignore', 'pipe', 'ignore'] });
18
18
  if (got.status === 0 && String(got.stdout || '').trim()) {
19
19
  process.exit(0);
20
20
  }
21
- const claim = spawnSync('localberth', ['claim', name, '--port', port], { ...opt, stdio: 'inherit' });
21
+ const claim = spawnSync('localslip', ['claim', name, '--port', port], { ...opt, stdio: 'inherit' });
22
22
  if (claim.error || claim.status !== 0) {
23
- console.warn(`localberth: skip claim ${name} (install the CLI to pin this site to ${port})`);
23
+ console.warn(`localslip: skip claim ${name} (install the CLI to pin this site to ${port})`);
24
24
  }