getfilepress 0.1.4 → 0.1.6
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 +3 -1
- package/package.json +7 -5
- package/packages/app/localberth-port.ts +32 -0
- package/packages/app/src/lib/prerender-empty-ok.ts +15 -0
- package/packages/app/vite.config.ts +8 -5
- package/packages/core/src/lib/default-headers.txt +10 -0
- package/packages/core/src/lib/headers.ts +134 -0
- package/packages/core/src/lib/server.ts +2 -0
- package/scripts/copy-path-mounts.mjs +22 -7
- package/scripts/ensure-lease.mjs +24 -0
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ pnpm build:www # → sites/getfilepress/build/
|
|
|
73
73
|
pnpm ship # build + Wrangler Pages deploy (project: getfilepress)
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
Two `filepress dev` processes share Vite **5173** unless you pin the port. With [LocalBerth](https://www.npmjs.com/package/localberth) 0.2+, `filepress dev` reads a named lease. Claim once; see [docs/LOCALBERTH.md](docs/LOCALBERTH.md).
|
|
77
|
+
|
|
76
78
|
## Site configuration
|
|
77
79
|
|
|
78
80
|
```ts
|
|
@@ -194,7 +196,7 @@ Body content in **Markdown**.
|
|
|
194
196
|
|
|
195
197
|
Pin the engine in the site `package.json` (`"getfilepress": "^0.1.3"` or `github:Catalyst-Forge-LLC/filepress#v0.1.3`). Do not use `link:` in CI. Set config `url` to the live origin.
|
|
196
198
|
|
|
197
|
-
Any other static host works the same way: publish `build/` as the web root.
|
|
199
|
+
Any other static host works the same way: publish `build/` as the web root. The build also writes a `_headers` file (HSTS, no framing, no wildcard CORS) for Cloudflare Pages; override it with `static/_headers`.
|
|
198
200
|
|
|
199
201
|
Full notes (including an agent checklist): [`docs/DEPLOY.md`](docs/DEPLOY.md) · product page: [getfilepress.com/deploy](https://getfilepress.com/deploy).
|
|
200
202
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getfilepress",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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`.",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"scripts/filepress.mjs",
|
|
37
37
|
"scripts/copy-path-mounts.mjs",
|
|
38
38
|
"scripts/create-site.mjs",
|
|
39
|
+
"scripts/ensure-lease.mjs",
|
|
39
40
|
"scripts/postinstall.mjs",
|
|
40
41
|
"scripts/link-embedded-packages.mjs",
|
|
41
42
|
"packages/core",
|
|
@@ -83,17 +84,18 @@
|
|
|
83
84
|
"scripts": {
|
|
84
85
|
"postinstall": "node scripts/postinstall.mjs",
|
|
85
86
|
"filepress": "node scripts/filepress.mjs",
|
|
86
|
-
"test": "pnpm --filter @filepress/core test && pnpm --filter @filepress/import test && pnpm --filter @filepress/app test",
|
|
87
|
+
"test": "pnpm --filter @filepress/core test && pnpm --filter @filepress/import test && pnpm --filter @filepress/app test && tsx --test scripts/sync-sibling-sites.test.ts",
|
|
87
88
|
"check": "pnpm filepress check --site demo",
|
|
88
89
|
"build": "pnpm filepress build --site demo",
|
|
89
90
|
"build:demo": "pnpm filepress build --site demo",
|
|
90
91
|
"build:www": "pnpm filepress build --site getfilepress",
|
|
91
92
|
"ship": "pnpm build:www && wrangler pages deploy sites/getfilepress/build --project-name getfilepress",
|
|
92
93
|
"deploy:www": "pnpm ship",
|
|
93
|
-
"dev": "pnpm filepress dev --site demo",
|
|
94
|
-
"dev:demo": "pnpm filepress dev --site demo",
|
|
95
|
-
"dev:www": "pnpm filepress dev --site getfilepress",
|
|
94
|
+
"dev": "node scripts/ensure-lease.mjs demo 5179 && pnpm filepress dev --site demo",
|
|
95
|
+
"dev:demo": "node scripts/ensure-lease.mjs demo 5179 && pnpm filepress dev --site demo",
|
|
96
|
+
"dev:www": "node scripts/ensure-lease.mjs getfilepress 5180 && pnpm filepress dev --site getfilepress",
|
|
96
97
|
"create-site": "node scripts/create-site.mjs",
|
|
98
|
+
"sync-siblings": "tsx scripts/sync-sibling-sites.ts",
|
|
97
99
|
"import": "node scripts/filepress.mjs import",
|
|
98
100
|
"pack:smoke": "node scripts/pack-smoke.mjs"
|
|
99
101
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
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], {
|
|
8
|
+
encoding: 'utf8',
|
|
9
|
+
timeout: 5000,
|
|
10
|
+
windowsHide: true,
|
|
11
|
+
shell: process.platform === 'win32'
|
|
12
|
+
});
|
|
13
|
+
if (r.status !== 0) return undefined;
|
|
14
|
+
const n = Number((r.stdout || '').trim());
|
|
15
|
+
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** FILEPRESS_LEASE, then site package.json name, then the site folder name. */
|
|
19
|
+
export function filepressLeaseName(siteRoot: string): string {
|
|
20
|
+
const fromEnv = process.env.FILEPRESS_LEASE?.trim();
|
|
21
|
+
if (fromEnv) return fromEnv;
|
|
22
|
+
const pkgPath = join(siteRoot, 'package.json');
|
|
23
|
+
if (existsSync(pkgPath)) {
|
|
24
|
+
try {
|
|
25
|
+
const name = JSON.parse(readFileSync(pkgPath, 'utf8')).name;
|
|
26
|
+
if (typeof name === 'string' && name.trim()) return name.trim();
|
|
27
|
+
} catch {
|
|
28
|
+
/* fall through */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return siteRoot.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || 'filepress';
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic routes that may have zero `entries()` and still be a valid site:
|
|
3
|
+
* no extra index pages, no tags, no Markdown pages, or no posts (pages-only).
|
|
4
|
+
*/
|
|
5
|
+
export const prerenderEmptyOk = [
|
|
6
|
+
'/page/[n]',
|
|
7
|
+
'/tags/[tag]',
|
|
8
|
+
'/[slug]',
|
|
9
|
+
'/posts/[slug]'
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export function unexpectedUnseenPrerenderRoutes(routes: string[]): string[] {
|
|
13
|
+
const ok = new Set<string>(prerenderEmptyOk);
|
|
14
|
+
return routes.filter((route) => !ok.has(route));
|
|
15
|
+
}
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
} from './vite-plugin-critical-theme.ts';
|
|
11
11
|
import { geniePlugin } from './vite-plugin-genie.ts';
|
|
12
12
|
import { pathMountsPlugin } from './vite-plugin-path-mounts.ts';
|
|
13
|
+
import { filepressLeaseName, localberthGet } from './localberth-port.ts';
|
|
13
14
|
import type { PathMount } from '../core/src/lib/paths-shared.ts';
|
|
15
|
+
import { unexpectedUnseenPrerenderRoutes } from './src/lib/prerender-empty-ok.ts';
|
|
14
16
|
|
|
15
17
|
const appRoot = dirname(fileURLToPath(import.meta.url));
|
|
16
18
|
const defaultSiteRoot = resolve(appRoot, '../../sites/demo');
|
|
@@ -84,9 +86,11 @@ async function loadPathMounts(): Promise<PathMount[]> {
|
|
|
84
86
|
|
|
85
87
|
function resolvePort(): number | undefined {
|
|
86
88
|
const raw = process.env.FILEPRESS_PORT?.trim();
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
if (raw) {
|
|
90
|
+
const n = Number(raw);
|
|
91
|
+
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
|
|
92
|
+
}
|
|
93
|
+
return localberthGet(filepressLeaseName(siteRoot));
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
const fixedPort = resolvePort();
|
|
@@ -157,8 +161,7 @@ export default defineConfig(async () => {
|
|
|
157
161
|
throw new Error(message);
|
|
158
162
|
},
|
|
159
163
|
handleUnseenRoutes: ({ routes }) => {
|
|
160
|
-
const
|
|
161
|
-
const unexpected = routes.filter((r) => !emptyOk.has(r));
|
|
164
|
+
const unexpected = unexpectedUnseenPrerenderRoutes(routes);
|
|
162
165
|
if (unexpected.length > 0) {
|
|
163
166
|
throw new Error(
|
|
164
167
|
`Routes marked prerenderable but not prerendered: ${unexpected.join(', ')}`
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Emitted by FilePress. Override by adding static/_headers in the site.
|
|
2
|
+
# Cloudflare Pages and Netlify read this file; it is not served as a page.
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Strict-Transport-Security: max-age=31536000
|
|
6
|
+
Content-Security-Policy: frame-ancestors 'none'
|
|
7
|
+
X-Frame-Options: DENY
|
|
8
|
+
X-Content-Type-Options: nosniff
|
|
9
|
+
Referrer-Policy: strict-origin-when-cross-origin
|
|
10
|
+
! Access-Control-Allow-Origin
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default Cloudflare Pages / Netlify `_headers` for a FilePress build.
|
|
3
|
+
* A site-owned `static/_headers` wins: adapter-static copies it first, and
|
|
4
|
+
* we refuse to overwrite `build/_headers`.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'default-headers.txt');
|
|
11
|
+
|
|
12
|
+
export function defaultSecurityHeaders(): string {
|
|
13
|
+
return readFileSync(templatePath, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeBuildHeaders(buildDir: string): 'wrote' | 'kept' {
|
|
17
|
+
const dest = join(buildDir, '_headers');
|
|
18
|
+
if (existsSync(dest)) return 'kept';
|
|
19
|
+
writeFileSync(dest, defaultSecurityHeaders());
|
|
20
|
+
return 'wrote';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type MergeSecurityHeadersResult = {
|
|
24
|
+
text: string;
|
|
25
|
+
changed: boolean;
|
|
26
|
+
added: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type HeaderBlock = { path: string; lines: string[] };
|
|
30
|
+
|
|
31
|
+
/** Header name from a `_headers` line (`Name: value` or `! Name`). */
|
|
32
|
+
export function headerNameFromLine(line: string): string | null {
|
|
33
|
+
const t = line.trim();
|
|
34
|
+
if (!t || t.startsWith('#')) return null;
|
|
35
|
+
if (t.startsWith('! ')) return t.slice(2).trim().toLowerCase();
|
|
36
|
+
const colon = t.indexOf(':');
|
|
37
|
+
if (colon === -1) return null;
|
|
38
|
+
return t.slice(0, colon).trim().toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPathLine(line: string): boolean {
|
|
42
|
+
if (line.startsWith(' ') || line.startsWith('\t')) return false;
|
|
43
|
+
const t = line.trim();
|
|
44
|
+
return t.startsWith('/') || t.startsWith('http://') || t.startsWith('https://');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseHeadersFile(text: string): { preamble: string[]; blocks: HeaderBlock[] } {
|
|
48
|
+
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
49
|
+
const preamble: string[] = [];
|
|
50
|
+
const blocks: HeaderBlock[] = [];
|
|
51
|
+
let current: HeaderBlock | null = null;
|
|
52
|
+
let beforeFirst = true;
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (isPathLine(line)) {
|
|
56
|
+
current = { path: line.trim(), lines: [] };
|
|
57
|
+
blocks.push(current);
|
|
58
|
+
beforeFirst = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (beforeFirst) preamble.push(line);
|
|
62
|
+
else if (current) current.lines.push(line);
|
|
63
|
+
}
|
|
64
|
+
return { preamble, blocks };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function serializeHeadersFile(preamble: string[], blocks: HeaderBlock[]): string {
|
|
68
|
+
const out: string[] = [...preamble];
|
|
69
|
+
for (const block of blocks) {
|
|
70
|
+
out.push(block.path);
|
|
71
|
+
out.push(...block.lines);
|
|
72
|
+
}
|
|
73
|
+
let text = out.join('\n');
|
|
74
|
+
if (!text.endsWith('\n')) text += '\n';
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function starHeaderLines(defaults: string): string[] {
|
|
79
|
+
const { blocks } = parseHeadersFile(defaults);
|
|
80
|
+
const star = blocks.find((b) => b.path === '/*');
|
|
81
|
+
if (!star) {
|
|
82
|
+
throw new Error('default _headers template has no /* block');
|
|
83
|
+
}
|
|
84
|
+
return star.lines.filter((line) => headerNameFromLine(line));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function blockIndent(lines: string[]): string {
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (headerNameFromLine(line)) {
|
|
90
|
+
const match = /^(?<indent>[ \t]+)/.exec(line);
|
|
91
|
+
if (match?.groups?.indent) return match.groups.indent;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return ' ';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fold FilePress default `/*` security rules into a site-owned `_headers`
|
|
99
|
+
* without dropping other path blocks. Existing values for the same header
|
|
100
|
+
* name win (including an explicit `Access-Control-Allow-Origin`).
|
|
101
|
+
*/
|
|
102
|
+
export function mergeSecurityHeaders(
|
|
103
|
+
existing: string,
|
|
104
|
+
defaults: string = defaultSecurityHeaders()
|
|
105
|
+
): MergeSecurityHeadersResult {
|
|
106
|
+
const wanted = starHeaderLines(defaults);
|
|
107
|
+
const parsed = parseHeadersFile(existing);
|
|
108
|
+
const added: string[] = [];
|
|
109
|
+
let star = parsed.blocks.find((b) => b.path === '/*');
|
|
110
|
+
if (!star) {
|
|
111
|
+
star = { path: '/*', lines: [''] };
|
|
112
|
+
parsed.blocks.push(star);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const present = new Set(
|
|
116
|
+
star.lines.map((line) => headerNameFromLine(line)).filter((n): n is string => Boolean(n))
|
|
117
|
+
);
|
|
118
|
+
const indent = blockIndent(star.lines);
|
|
119
|
+
|
|
120
|
+
let insertAt = star.lines.length;
|
|
121
|
+
while (insertAt > 0 && star.lines[insertAt - 1].trim() === '') insertAt--;
|
|
122
|
+
|
|
123
|
+
for (const line of wanted) {
|
|
124
|
+
const name = headerNameFromLine(line);
|
|
125
|
+
if (!name || present.has(name)) continue;
|
|
126
|
+
star.lines.splice(insertAt, 0, `${indent}${line.trim()}`);
|
|
127
|
+
insertAt++;
|
|
128
|
+
present.add(name);
|
|
129
|
+
added.push(name);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const text = serializeHeadersFile(parsed.preamble, parsed.blocks);
|
|
133
|
+
return { text, changed: added.length > 0, added };
|
|
134
|
+
}
|
|
@@ -22,6 +22,8 @@ export {
|
|
|
22
22
|
|
|
23
23
|
export { absoluteUrl, ogImageUrl } from './config';
|
|
24
24
|
export type { SiteConfig, PathMount } from './config';
|
|
25
|
+
export { defaultSecurityHeaders, writeBuildHeaders, mergeSecurityHeaders } from './headers';
|
|
26
|
+
export type { MergeSecurityHeadersResult } from './headers';
|
|
25
27
|
export {
|
|
26
28
|
normalizePathMounts,
|
|
27
29
|
pathMountReservedSlugs,
|
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Post-`vite build` steps for a FilePress site:
|
|
3
|
+
* 1. Write default `build/_headers` unless the site already provided one
|
|
4
|
+
* 2. Copy `paths` mounts (from `.filepress/path-mounts.json`)
|
|
4
5
|
*
|
|
5
6
|
* Usage: node scripts/copy-path-mounts.mjs <siteRoot>
|
|
6
7
|
*/
|
|
7
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
-
import { join, resolve } from 'node:path';
|
|
8
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
9
11
|
|
|
10
12
|
const siteRoot = resolve(process.argv[2] ?? '');
|
|
11
13
|
const buildDir = join(siteRoot, 'build');
|
|
12
14
|
const cachePath = join(siteRoot, '.filepress', 'path-mounts.json');
|
|
15
|
+
const headersTemplate = join(
|
|
16
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
17
|
+
'../packages/core/src/lib/default-headers.txt'
|
|
18
|
+
);
|
|
13
19
|
|
|
14
|
-
if (!existsSync(
|
|
20
|
+
if (!existsSync(buildDir)) {
|
|
21
|
+
console.warn(`filepress: post-build skipped — build dir missing (${buildDir})`);
|
|
15
22
|
process.exit(0);
|
|
16
23
|
}
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
|
|
25
|
+
const headersDest = join(buildDir, '_headers');
|
|
26
|
+
if (existsSync(headersDest)) {
|
|
27
|
+
console.log('filepress: kept site _headers');
|
|
28
|
+
} else if (existsSync(headersTemplate)) {
|
|
29
|
+
writeFileSync(headersDest, readFileSync(headersTemplate, 'utf8'));
|
|
30
|
+
console.log('filepress: wrote default _headers');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!existsSync(cachePath)) {
|
|
19
34
|
process.exit(0);
|
|
20
35
|
}
|
|
21
36
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Idempotent LocalBerth claim for FilePress (and sibling) dev ports.
|
|
4
|
+
* Usage: node scripts/ensure-lease.mjs <name> <port>
|
|
5
|
+
* Missing CLI → one line, exit 0. Existing lease → keep it (do not rewrite).
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
const name = process.argv[2];
|
|
10
|
+
const port = process.argv[3];
|
|
11
|
+
if (!name || !port) {
|
|
12
|
+
console.error('usage: node scripts/ensure-lease.mjs <name> <port>');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
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'] });
|
|
18
|
+
if (got.status === 0 && String(got.stdout || '').trim()) {
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
const claim = spawnSync('localberth', ['claim', name, '--port', port], { ...opt, stdio: 'inherit' });
|
|
22
|
+
if (claim.error || claim.status !== 0) {
|
|
23
|
+
console.warn(`localberth: skip claim ${name} (install the CLI to pin this site to ${port})`);
|
|
24
|
+
}
|