getfilepress 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getfilepress",
3
- "version": "0.1.5",
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`.",
@@ -84,7 +84,7 @@
84
84
  "scripts": {
85
85
  "postinstall": "node scripts/postinstall.mjs",
86
86
  "filepress": "node scripts/filepress.mjs",
87
- "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",
88
88
  "check": "pnpm filepress check --site demo",
89
89
  "build": "pnpm filepress build --site demo",
90
90
  "build:demo": "pnpm filepress build --site demo",
@@ -95,6 +95,7 @@
95
95
  "dev:demo": "node scripts/ensure-lease.mjs demo 5179 && pnpm filepress dev --site demo",
96
96
  "dev:www": "node scripts/ensure-lease.mjs getfilepress 5180 && pnpm filepress dev --site getfilepress",
97
97
  "create-site": "node scripts/create-site.mjs",
98
+ "sync-siblings": "tsx scripts/sync-sibling-sites.ts",
98
99
  "import": "node scripts/filepress.mjs import",
99
100
  "pack:smoke": "node scripts/pack-smoke.mjs"
100
101
  }
@@ -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
+ }
@@ -12,6 +12,7 @@ import { geniePlugin } from './vite-plugin-genie.ts';
12
12
  import { pathMountsPlugin } from './vite-plugin-path-mounts.ts';
13
13
  import { filepressLeaseName, localberthGet } from './localberth-port.ts';
14
14
  import type { PathMount } from '../core/src/lib/paths-shared.ts';
15
+ import { unexpectedUnseenPrerenderRoutes } from './src/lib/prerender-empty-ok.ts';
15
16
 
16
17
  const appRoot = dirname(fileURLToPath(import.meta.url));
17
18
  const defaultSiteRoot = resolve(appRoot, '../../sites/demo');
@@ -160,8 +161,7 @@ export default defineConfig(async () => {
160
161
  throw new Error(message);
161
162
  },
162
163
  handleUnseenRoutes: ({ routes }) => {
163
- const emptyOk = new Set(['/page/[n]', '/tags/[tag]', '/[slug]']);
164
- const unexpected = routes.filter((r) => !emptyOk.has(r));
164
+ const unexpected = unexpectedUnseenPrerenderRoutes(routes);
165
165
  if (unexpected.length > 0) {
166
166
  throw new Error(
167
167
  `Routes marked prerenderable but not prerendered: ${unexpected.join(', ')}`
@@ -19,3 +19,116 @@ export function writeBuildHeaders(buildDir: string): 'wrote' | 'kept' {
19
19
  writeFileSync(dest, defaultSecurityHeaders());
20
20
  return 'wrote';
21
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,7 +22,8 @@ export {
22
22
 
23
23
  export { absoluteUrl, ogImageUrl } from './config';
24
24
  export type { SiteConfig, PathMount } from './config';
25
- export { defaultSecurityHeaders, writeBuildHeaders } from './headers';
25
+ export { defaultSecurityHeaders, writeBuildHeaders, mergeSecurityHeaders } from './headers';
26
+ export type { MergeSecurityHeadersResult } from './headers';
26
27
  export {
27
28
  normalizePathMounts,
28
29
  pathMountReservedSlugs,