getfilepress 0.1.27 → 0.1.29
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 +1 -1
- package/package.json +1 -1
- package/packages/app/{localberth-port.ts → localslip-port.ts} +3 -3
- package/packages/app/src/routes/tags/[tag]/+page.svelte +5 -5
- package/packages/app/vite.config.ts +2 -2
- package/packages/core/src/lib/components/NavIcon.svelte +15 -0
- package/packages/core/src/lib/components/SiteFooter.svelte +56 -20
- package/packages/core/src/lib/config.ts +39 -3
- package/packages/core/src/lib/format.ts +14 -0
- package/packages/core/src/lib/index.ts +4 -2
- package/packages/core/src/lib/styles/theme.css +18 -1
- package/packages/import/src/discover.ts +4 -2
- package/packages/import/src/extract.ts +100 -30
- package/packages/import/src/theme.ts +48 -4
- package/packages/import/src/wordpress.ts +194 -0
- package/scripts/ensure-lease.mjs +4 -4
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/
|
|
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
|
@@ -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
|
-
/** `
|
|
6
|
-
export function
|
|
7
|
-
const r = spawnSync('
|
|
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,
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { PageData } from './$types';
|
|
3
|
-
import { PostCard } from '@filepress/core';
|
|
3
|
+
import { PostCard, tagDisplayLabel } from '@filepress/core';
|
|
4
4
|
import config from '$site-config';
|
|
5
5
|
|
|
6
6
|
let { data }: { data: PageData } = $props();
|
|
7
|
+
const heading = $derived(tagDisplayLabel(config.topics, data.tag));
|
|
7
8
|
</script>
|
|
8
9
|
|
|
9
10
|
<svelte:head>
|
|
10
|
-
<title>{
|
|
11
|
-
<meta name="description" content="Posts tagged {
|
|
11
|
+
<title>{heading} — {config.title}</title>
|
|
12
|
+
<meta name="description" content="Posts tagged {heading} on {config.title}." />
|
|
12
13
|
</svelte:head>
|
|
13
14
|
|
|
14
15
|
<header class="post-header">
|
|
15
|
-
<
|
|
16
|
-
<h1>{data.tag}</h1>
|
|
16
|
+
<h1>{heading}</h1>
|
|
17
17
|
</header>
|
|
18
18
|
|
|
19
19
|
<ul class="post-list">
|
|
@@ -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,
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
{#
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
<span class="footer-meta">
|
|
39
|
+
<span>© {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
|
|
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
|
|
133
|
-
throw new Error(
|
|
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),
|
|
@@ -34,3 +34,17 @@ export function readingMinutes(body: string, wordsPerMinute = 228): number {
|
|
|
34
34
|
export function formatReadingTime(minutes: number): string {
|
|
35
35
|
return minutes === 1 ? '1 min read' : `${minutes} min read`;
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/** Display name for a tag: curated topic label, else title-cased slug. */
|
|
39
|
+
export function tagDisplayLabel(
|
|
40
|
+
topics: Array<{ label: string; tag: string }>,
|
|
41
|
+
tag: string
|
|
42
|
+
): string {
|
|
43
|
+
const curated = topics.find((topic) => topic.tag === tag)?.label.trim();
|
|
44
|
+
if (curated) return curated;
|
|
45
|
+
return tag
|
|
46
|
+
.split('-')
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
49
|
+
.join(' ');
|
|
50
|
+
}
|
|
@@ -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
|
|
@@ -30,6 +32,6 @@ export type {
|
|
|
30
32
|
|
|
31
33
|
export { isPathMountHref } from './paths-shared';
|
|
32
34
|
|
|
33
|
-
export { formatDate, formatReadingTime, readingMinutes } from './format';
|
|
35
|
+
export { formatDate, formatReadingTime, readingMinutes, tagDisplayLabel } from './format';
|
|
34
36
|
|
|
35
37
|
export type { PostMeta, PostSource, RenderedPost, RawFrontmatter } from './content/types';
|
|
@@ -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
|
-
|
|
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 (/\/
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
282
|
-
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
|
|
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 =
|
|
337
|
-
|
|
338
|
-
.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 }> =
|
|
345
|
-
?
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
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(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'|'/g, "'")
|
|
52
|
+
.replace(/ /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
|
+
}
|
package/scripts/ensure-lease.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Idempotent
|
|
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('
|
|
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('
|
|
21
|
+
const claim = spawnSync('localslip', ['claim', name, '--port', port], { ...opt, stdio: 'inherit' });
|
|
22
22
|
if (claim.error || claim.status !== 0) {
|
|
23
|
-
console.warn(`
|
|
23
|
+
console.warn(`localslip: skip claim ${name} (install the CLI to pin this site to ${port})`);
|
|
24
24
|
}
|