@natjswenson/devlog 0.1.5 → 0.1.7
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/CHANGELOG.md +71 -0
- package/README.md +82 -40
- package/SECURITY.md +100 -0
- package/SKILL.md +74 -29
- package/bin/devlog.js +352 -112
- package/examples/react/DevLogPage.jsx +22 -3
- package/examples/react/useDevLogEntries.js +42 -11
- package/package.json +14 -9
- package/preview/App.jsx +46 -5
- package/preview/index.html +14 -0
- package/preview/main.jsx +4 -1
- package/preview/vite.config.js +8 -0
|
@@ -7,9 +7,24 @@ import './DevLogPage.css';
|
|
|
7
7
|
|
|
8
8
|
const ENTRIES_PER_PAGE = 10;
|
|
9
9
|
|
|
10
|
+
// Strict allowlist of URL schemes permitted in markdown links/images.
|
|
11
|
+
// react-markdown 9's default sanitizer already blocks `javascript:`,
|
|
12
|
+
// `vbscript:`, `file:`. We narrow further: only http/https/mailto.
|
|
13
|
+
// Anything else (data:, blob:, ftp:, custom schemes) is replaced with `#`.
|
|
14
|
+
const SAFE_URL_SCHEME = /^(https?:|mailto:|#|\/|\.\.?\/|[^:]*$)/i;
|
|
15
|
+
function safeUrlTransform(url) {
|
|
16
|
+
if (typeof url !== 'string') return '#';
|
|
17
|
+
if (SAFE_URL_SCHEME.test(url)) return url;
|
|
18
|
+
return '#';
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
function formatDate(dateStr) {
|
|
11
|
-
|
|
12
|
-
const
|
|
22
|
+
if (typeof dateStr !== 'string') return '';
|
|
23
|
+
const parts = dateStr.split('-');
|
|
24
|
+
if (parts.length !== 3) return '';
|
|
25
|
+
const [year, month, day] = parts;
|
|
26
|
+
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
27
|
+
if (isNaN(date.getTime())) return '';
|
|
13
28
|
return date.toLocaleDateString('en-US', {
|
|
14
29
|
month: 'short',
|
|
15
30
|
day: 'numeric',
|
|
@@ -139,7 +154,11 @@ export default function DevLogPage({
|
|
|
139
154
|
<div className="devlog-content-inner">
|
|
140
155
|
{isExpanded && content && (
|
|
141
156
|
<div className="devlog-content" onClick={(e) => e.stopPropagation()}>
|
|
142
|
-
<ReactMarkdown
|
|
157
|
+
<ReactMarkdown
|
|
158
|
+
remarkPlugins={[remarkGfm]}
|
|
159
|
+
urlTransform={safeUrlTransform}
|
|
160
|
+
skipHtml
|
|
161
|
+
>
|
|
143
162
|
{content}
|
|
144
163
|
</ReactMarkdown>
|
|
145
164
|
</div>
|
|
@@ -1,24 +1,44 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
2
2
|
import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
|
|
3
3
|
|
|
4
|
+
// Allowlist of frontmatter keys we recognize. Anything else is ignored —
|
|
5
|
+
// prevents prototype-pollution via crafted keys like `__proto__`.
|
|
6
|
+
const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary']);
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Parse YAML-ish frontmatter from a markdown string.
|
|
6
10
|
* Returns { metadata: { title, date, project, summary }, body: string }
|
|
7
11
|
*/
|
|
8
12
|
function parseFrontmatter(raw) {
|
|
9
13
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const body = match[2];
|
|
14
|
-
const metadata = {};
|
|
14
|
+
// Use Object.create(null) so the returned object has no prototype chain.
|
|
15
|
+
const metadata = Object.create(null);
|
|
16
|
+
if (!match) return { metadata, body: raw };
|
|
15
17
|
|
|
16
|
-
for (const line of
|
|
18
|
+
for (const line of match[1].split('\n')) {
|
|
17
19
|
const m = line.match(/^(\w+)\s*:\s*"?([^"]*)"?\s*$/);
|
|
18
|
-
if (m) metadata[m[1]] = m[2].trim();
|
|
20
|
+
if (m && FRONTMATTER_KEYS.has(m[1])) metadata[m[1]] = m[2].trim();
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
return { metadata, body };
|
|
23
|
+
return { metadata, body: match[2] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Schema validation for fetched manifest. Reject anything that isn't shaped
|
|
27
|
+
// like { entries: [{ date, file, title, summary }, ...] } so a hostile commit
|
|
28
|
+
// to the dev-log repo can't crash the page.
|
|
29
|
+
function validateManifest(data) {
|
|
30
|
+
if (!data || typeof data !== 'object') return null;
|
|
31
|
+
if (!Array.isArray(data.entries)) return null;
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const e of data.entries) {
|
|
34
|
+
if (!e || typeof e !== 'object') continue;
|
|
35
|
+
const { date, file, title, summary } = e;
|
|
36
|
+
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
37
|
+
if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
|
|
38
|
+
if (typeof title !== 'string' || typeof summary !== 'string') continue;
|
|
39
|
+
entries.push({ date, file, title, summary });
|
|
40
|
+
}
|
|
41
|
+
return { entries };
|
|
22
42
|
}
|
|
23
43
|
|
|
24
44
|
/**
|
|
@@ -51,7 +71,9 @@ export function useDevLogEntries(project, configOverride) {
|
|
|
51
71
|
setError(null);
|
|
52
72
|
|
|
53
73
|
try {
|
|
54
|
-
|
|
74
|
+
// Encode project key to neutralize any path-traversal characters
|
|
75
|
+
// (the project key is allowlisted upstream, but defense-in-depth).
|
|
76
|
+
const url = `${config.baseUrl}/${encodeURIComponent(project)}/manifest.json`;
|
|
55
77
|
const res = await fetch(url);
|
|
56
78
|
if (!res.ok) {
|
|
57
79
|
if (res.status === 404) {
|
|
@@ -61,7 +83,9 @@ export function useDevLogEntries(project, configOverride) {
|
|
|
61
83
|
throw new Error(`Failed to fetch manifest (${res.status})`);
|
|
62
84
|
}
|
|
63
85
|
const data = await res.json();
|
|
64
|
-
|
|
86
|
+
const validated = validateManifest(data);
|
|
87
|
+
if (!validated) throw new Error('Manifest failed schema validation');
|
|
88
|
+
setEntries(validated.entries);
|
|
65
89
|
} catch (err) {
|
|
66
90
|
setError(err.message);
|
|
67
91
|
} finally {
|
|
@@ -76,8 +100,15 @@ export function useDevLogEntries(project, configOverride) {
|
|
|
76
100
|
const fetchEntryContent = useCallback(async (filename) => {
|
|
77
101
|
if (contentCache.current.has(filename)) return;
|
|
78
102
|
|
|
103
|
+
// Final filename gate (manifest validation already enforces the same
|
|
104
|
+
// pattern — keep this here so any consumer calling fetchEntryContent
|
|
105
|
+
// directly is also protected).
|
|
106
|
+
if (typeof filename !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(filename)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
try {
|
|
80
|
-
const url = `${config.baseUrl}/${project}/${filename}`;
|
|
111
|
+
const url = `${config.baseUrl}/${encodeURIComponent(project)}/${filename}`;
|
|
81
112
|
const res = await fetch(url);
|
|
82
113
|
if (!res.ok) throw new Error(`Failed to fetch entry (${res.status})`);
|
|
83
114
|
const raw = await res.text();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Daily dev log generator — Claude Code skill + preview app for publishing git-based dev logs to your site",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"preview/",
|
|
30
30
|
"examples/",
|
|
31
31
|
"SKILL.md",
|
|
32
|
+
"SECURITY.md",
|
|
33
|
+
"CHANGELOG.md",
|
|
32
34
|
"config.example.json",
|
|
33
35
|
"README.md",
|
|
34
36
|
"LICENSE"
|
|
@@ -36,14 +38,17 @@
|
|
|
36
38
|
"engines": {
|
|
37
39
|
"node": ">=18"
|
|
38
40
|
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"audit": "npm audit --audit-level=moderate"
|
|
43
|
+
},
|
|
39
44
|
"dependencies": {
|
|
40
|
-
"@vitejs/plugin-react": "
|
|
41
|
-
"kleur": "
|
|
42
|
-
"prompts": "
|
|
43
|
-
"react": "
|
|
44
|
-
"react-dom": "
|
|
45
|
-
"react-markdown": "
|
|
46
|
-
"remark-gfm": "
|
|
47
|
-
"vite": "
|
|
45
|
+
"@vitejs/plugin-react": "6.0.1",
|
|
46
|
+
"kleur": "4.1.5",
|
|
47
|
+
"prompts": "2.4.2",
|
|
48
|
+
"react": "18.3.1",
|
|
49
|
+
"react-dom": "18.3.1",
|
|
50
|
+
"react-markdown": "9.1.0",
|
|
51
|
+
"remark-gfm": "4.0.1",
|
|
52
|
+
"vite": "8.0.10"
|
|
48
53
|
}
|
|
49
54
|
}
|
package/preview/App.jsx
CHANGED
|
@@ -6,12 +6,24 @@ const owner = import.meta.env.VITE_DEVLOG_OWNER;
|
|
|
6
6
|
const repo = import.meta.env.VITE_DEVLOG_REPO;
|
|
7
7
|
const branch = import.meta.env.VITE_DEVLOG_BRANCH || 'main';
|
|
8
8
|
const projectsRaw = import.meta.env.VITE_DEVLOG_PROJECTS;
|
|
9
|
+
const isDev = import.meta.env.DEV;
|
|
9
10
|
|
|
10
11
|
const isDemo = !owner || !repo;
|
|
11
12
|
|
|
12
13
|
let realProjects = [];
|
|
13
14
|
try {
|
|
14
|
-
|
|
15
|
+
const parsed = projectsRaw ? JSON.parse(projectsRaw) : [];
|
|
16
|
+
// Schema-validate: must be array of {key, label?} where key matches a safe
|
|
17
|
+
// allowlist. Anything else gets dropped silently.
|
|
18
|
+
if (Array.isArray(parsed)) {
|
|
19
|
+
for (const p of parsed) {
|
|
20
|
+
if (!p || typeof p !== 'object') continue;
|
|
21
|
+
if (typeof p.key !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(p.key)) continue;
|
|
22
|
+
if (p.key.includes('..')) continue;
|
|
23
|
+
const label = typeof p.label === 'string' ? p.label : p.key;
|
|
24
|
+
realProjects.push({ key: p.key, label });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
15
27
|
} catch {
|
|
16
28
|
realProjects = [];
|
|
17
29
|
}
|
|
@@ -49,16 +61,39 @@ function NoProjectsScreen() {
|
|
|
49
61
|
Env vars say you have a repo (<code>{owner}/{repo}</code>) but no projects.
|
|
50
62
|
That's like buying a stage and forgetting to invite a band.
|
|
51
63
|
</p>
|
|
52
|
-
<p>Add
|
|
64
|
+
<p>Add a project the easy way:</p>
|
|
65
|
+
<pre>npx @natjswenson/devlog add-project</pre>
|
|
66
|
+
<p>Or edit your config directly:</p>
|
|
53
67
|
<pre>{`{
|
|
68
|
+
"targetRepo": "${owner}/${repo}",
|
|
69
|
+
"branch": "${branch}",
|
|
54
70
|
"projects": [
|
|
55
|
-
{ "key": "myproject", "path": "...", "remote": "${owner}/myproject" }
|
|
71
|
+
{ "key": "myproject", "label": "My Project", "path": "...", "remote": "${owner}/myproject" }
|
|
56
72
|
]
|
|
57
73
|
}`}</pre>
|
|
74
|
+
<p>Then re-run <code>npx @natjswenson/devlog preview</code>.</p>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Production builds without env vars: show a clear "setup required" screen,
|
|
80
|
+
// not the demo banner with broken fetches (since installDemoFetch is gated to DEV).
|
|
81
|
+
function SetupRequiredScreen() {
|
|
82
|
+
return (
|
|
83
|
+
<div className="empty-screen">
|
|
84
|
+
<h1>Setup required</h1>
|
|
85
|
+
<p>
|
|
86
|
+
This preview was built without the env vars that point it at a dev-log repo.
|
|
87
|
+
Set these in your hosting environment (Vercel/Netlify/Cloudflare/wherever):
|
|
88
|
+
</p>
|
|
89
|
+
<pre>{`VITE_DEVLOG_OWNER=your-github-username
|
|
90
|
+
VITE_DEVLOG_REPO=daily-dev-log
|
|
91
|
+
VITE_DEVLOG_BRANCH=main
|
|
92
|
+
VITE_DEVLOG_PROJECTS=[{"key":"myproject","label":"My Project"}]`}</pre>
|
|
58
93
|
<p>
|
|
59
|
-
|
|
60
|
-
<code> VITE_DEVLOG_PROJECTS</code> directly in <code>preview/.env.local</code>.
|
|
94
|
+
Or, if you're trying this locally, the friendlier path is:
|
|
61
95
|
</p>
|
|
96
|
+
<pre>npx @natjswenson/devlog init && npx @natjswenson/devlog preview</pre>
|
|
62
97
|
</div>
|
|
63
98
|
);
|
|
64
99
|
}
|
|
@@ -66,6 +101,12 @@ function NoProjectsScreen() {
|
|
|
66
101
|
export default function App() {
|
|
67
102
|
const [activeKey, setActiveKey] = useState((projects && projects[0]?.key) || DEMO_PROJECT_KEY);
|
|
68
103
|
|
|
104
|
+
// Production build with no env vars: show actionable setup screen
|
|
105
|
+
// (demo fetch override is DEV-only, so demo entries would 404 here).
|
|
106
|
+
if (isDemo && !isDev) {
|
|
107
|
+
return <SetupRequiredScreen />;
|
|
108
|
+
}
|
|
109
|
+
|
|
69
110
|
if (!isDemo && !projects) {
|
|
70
111
|
return <NoProjectsScreen />;
|
|
71
112
|
}
|
package/preview/index.html
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<!--
|
|
7
|
+
Content-Security-Policy: defense-in-depth for the preview app.
|
|
8
|
+
- 'self' for scripts/styles (no inline JS, no eval)
|
|
9
|
+
- 'unsafe-inline' for styles only — required by Vite's HMR runtime in dev
|
|
10
|
+
and react-markdown's prismjs (none used here, but standard practice)
|
|
11
|
+
- connect to raw.githubusercontent.com for entry fetches; api.github.com
|
|
12
|
+
for any future repo metadata; demo-mode.local for the in-browser fetch
|
|
13
|
+
override that powers demo mode without env vars
|
|
14
|
+
- no objects, no frames, no plugins
|
|
15
|
+
Production build: replace 'unsafe-inline' with hashes/nonces if you
|
|
16
|
+
configure Vite to emit them.
|
|
17
|
+
-->
|
|
18
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com https://api.github.com https://demo-mode.local; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'none';" />
|
|
19
|
+
<meta name="referrer" content="no-referrer-when-downgrade" />
|
|
6
20
|
<title>devlog preview</title>
|
|
7
21
|
<style>
|
|
8
22
|
:root {
|
package/preview/main.jsx
CHANGED
|
@@ -4,7 +4,10 @@ import App from './App.jsx';
|
|
|
4
4
|
import { installDemoFetch } from './demo.js';
|
|
5
5
|
import './preview.css';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Gate the global window.fetch override behind DEV. In production builds
|
|
8
|
+
// (e.g. when adopters deploy the preview directory standalone), demo content
|
|
9
|
+
// must NOT silently intercept fetches — show the empty-state UX instead.
|
|
10
|
+
if (import.meta.env.DEV && (!import.meta.env.VITE_DEVLOG_OWNER || !import.meta.env.VITE_DEVLOG_REPO)) {
|
|
8
11
|
installDemoFetch();
|
|
9
12
|
}
|
|
10
13
|
|
package/preview/vite.config.js
CHANGED
|
@@ -19,8 +19,16 @@ export default defineConfig({
|
|
|
19
19
|
'style-to-js',
|
|
20
20
|
],
|
|
21
21
|
},
|
|
22
|
+
// Bind preview server to localhost only — the dev server has no auth and
|
|
23
|
+
// serves transformed source modules. Don't expose it to LAN by default.
|
|
24
|
+
// (npm audit flags moderate CVEs in dev-server CORS handling for any
|
|
25
|
+
// deployment that allows cross-origin reads; localhost-binding is
|
|
26
|
+
// defense-in-depth on top of vite's own patches.)
|
|
22
27
|
server: {
|
|
28
|
+
host: 'localhost',
|
|
23
29
|
port: 5173,
|
|
24
30
|
open: true,
|
|
31
|
+
strictPort: false,
|
|
32
|
+
cors: false,
|
|
25
33
|
},
|
|
26
34
|
});
|