Skip to content

Commit 1716739

Browse files
YunYouJunclaude
andcommitted
refactor: comprehensive security, performance & architecture optimization (#11)
* refactor: comprehensive security, performance & architecture optimization - Fix XSS vulnerability: escape HTML before markdown rendering in commit messages - Fix delimiter collision: replace `|` with `\x1F` (Unit Separator) in git pretty formats - Fix valaxy.config.ts: use `defineValaxyConfig` instead of `defineTheme` - Cache git-log.json client-side to avoid redundant fetches on navigation - Replace @octokit/rest with native fetch in client bundle (~100KB saved) - Optimize deduplicateContributors from O(n²) to O(n) via Map grouping - Replace sync execFileSync with async git.raw() in getLastUpdated - Only cache changelog in CI mode for fresh data during dev - Reduce maxConcurrentProcesses from 200 to 10 - Decouple GitLogChangelog.vue from @vueuse/metadata - Add configurable changelog filter (includeTypes, includeBreaking) - Add useGitLogState() composable with loading/error states - Define types locally, remove @vueuse/metadata type dependency - Move @octokit/rest to optionalDependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update lock * fix: address copilot review feedback - Sanitize URLs in markdown renderer: block javascript:, data:, vbscript: protocols in links and images to close XSS vector (copilot #3) - Document Changelog.body as deprecated — field was never reliably populated due to %b multiline conflicts with --name-only parsing (copilot #1, #2) - Move @octokit/rest back to dependencies to prevent silent feature loss when installed with --no-optional (copilot #4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address copilot review feedback (cycle 2) - Block protocol-relative URLs (//evil.example) in sanitizeUrl regex - Add res.ok check before parsing git-log.json response - Fix comment/behavior mismatch on changelog cache condition - Replace O(n²) indexOf dedup with Set-based O(n) dedup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address copilot review feedback (cycle 3) - Fix nested <a> tags: only replace #issue refs in text content, not inside tags - Fix breaking change detection: match conventional commit `type!:` pattern instead of any `!` anywhere in message - Fix type matching: require `type:`, `type(`, or `type!` after prefix to prevent false positives like `feature:` matching `feat` - Fix contributor map key: use email instead of name to avoid merging distinct contributors with same display name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining copilot review feedback - Prevent nested <a> tags when commit messages contain markdown links with issue references (e.g. `[#123](url)`) by splitting on anchor elements before replacing #N patterns - Include statusText and URL in fetchContributors error message for easier debugging of rate-limit (403) responses Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address copilot review feedback (cycle 4) - Re-export ./types from index.ts so runtime helpers like shouldIncludeCommit are available at runtime, not just types. - Restore getLastUpdated to sync (backward compatible) and add getLastUpdatedAsync for async pipelines. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address copilot review feedback (cycle 5) - shouldIncludeCommit: when `includeBreaking: false`, also exclude breaking commits whose type token is in `includeTypes` (e.g. `feat!:`, `feat(scope)!:`). Previously they leaked through via the `types.some(matchesType)` path. - Cover the regression with explicit `feat!:` / `feat(scope)!:` / `fix!:` test cases under `includeBreaking: false`. - replaceIssueRefs: tokenize HTML into anchor blocks, single tags, and text nodes so `#N` is only rewritten in text. Prevents `<img alt='#1'/>` / `<code>#1</code>` etc. from being rewritten in attributes/tag bodies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address copilot review feedback (cycle 6) - batchGetChangelog: bound git log with --max-count = maxCount * filePaths.length so multi-path calls no longer read the full repo history when a maxCount guard is set. Per-file slicing still happens in JS for correctness. - Rewrite the two batch-parsing tests so they actually call flushGitLogBatch (where parsing happens). Previously the mocked git.raw output was never consumed, so the tests only verified frontmatter.path. New assertions check that contributors are parsed with the \x1F separator and that pipe-in-name authors are preserved verbatim. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 41ce535 commit 1716739

16 files changed

Lines changed: 1384 additions & 973 deletions

File tree

client/composables/changelog.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ export function useChangelog(_path?: MaybeRefOrGetter<string>): Ref<Changelog[]>
1111
const strategy = gitLogOptions.value.contributor?.strategy
1212

1313
if (strategy === 'runtime') {
14-
const contributors = computedAsync<Changelog[]>(
14+
const changelog = computedAsync<Changelog[]>(
1515
async () => {
16-
// TODO: Complete the API-based method
16+
// Runtime changelog is not yet supported via GitHub API
17+
// (GitHub commits endpoint doesn't provide conventional-commit filtering)
18+
// Fall back to empty — consumers should prefer 'prebuilt' or 'build-time'
1719
return []
1820
},
1921
gitLog.value.changeLog,
2022
{ lazy: true },
2123
)
2224

23-
return contributors
25+
return changelog
2426
}
2527

2628
return computed(() => gitLog.value.changeLog)

client/composables/gitlog.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ import { useFrontmatter } from 'valaxy'
55
import { computed, ref, toRef } from 'vue'
66
import { useAddonGitLogConfig } from '../options'
77

8+
/**
9+
* Module-level cache to avoid re-fetching git-log.json on every route navigation.
10+
*/
11+
let cachedData: GitLogFileEntry | null = null
12+
let fetchPromise: Promise<GitLogFileEntry> | null = null
13+
14+
function getGitLogData(): Promise<GitLogFileEntry> {
15+
if (cachedData)
16+
return Promise.resolve(cachedData)
17+
if (!fetchPromise) {
18+
const base = import.meta.env.BASE_URL || '/'
19+
const url = `${base}git-log.json`
20+
fetchPromise = fetch(url)
21+
.then((res) => {
22+
if (!res.ok)
23+
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`)
24+
return res.json()
25+
})
26+
.then((data: GitLogFileEntry) => {
27+
cachedData = data
28+
return data
29+
})
30+
.catch((err) => {
31+
// Allow retry on next call
32+
fetchPromise = null
33+
throw err
34+
})
35+
}
36+
return fetchPromise
37+
}
38+
839
export function useGitLog(): Ref<GitLog> {
940
const initGitLog: GitLog = {
1041
contributors: [],
@@ -24,10 +55,8 @@ export function useGitLog(): Ref<GitLog> {
2455
const path = frontmatter.value.git_log?.path
2556

2657
if (isPrebuilt && path) {
27-
const base = import.meta.env.BASE_URL || '/'
28-
fetch(`${base}git-log.json`)
29-
.then(res => res.json())
30-
.then((data: GitLogFileEntry) => {
58+
getGitLogData()
59+
.then((data) => {
3160
if (data[path])
3261
gitLogData.value = data[path]
3362
})
@@ -42,3 +71,56 @@ export function useGitLog(): Ref<GitLog> {
4271

4372
return gitLog
4473
}
74+
75+
/**
76+
* Extended version of useGitLog that also exposes loading and error state.
77+
*/
78+
export function useGitLogState() {
79+
const initGitLog: GitLog = {
80+
contributors: [],
81+
changeLog: [],
82+
path: '',
83+
}
84+
85+
const isLoading = ref(false)
86+
const error = ref<Error | null>(null)
87+
88+
if (!isClient) {
89+
return {
90+
data: toRef(initGitLog),
91+
isLoading,
92+
error,
93+
}
94+
}
95+
96+
const frontmatter = useFrontmatter<{ git_log: GitLog }>()
97+
const gitLogOptions = useAddonGitLogConfig()
98+
const gitLogData = ref<GitLog>(initGitLog)
99+
100+
const strategy = gitLogOptions.value.contributor?.strategy
101+
const isPrebuilt = strategy === 'prebuilt'
102+
const path = frontmatter.value.git_log?.path
103+
104+
if (isPrebuilt && path) {
105+
isLoading.value = true
106+
getGitLogData()
107+
.then((data) => {
108+
if (data[path])
109+
gitLogData.value = data[path]
110+
})
111+
.catch((e) => {
112+
error.value = e
113+
})
114+
.finally(() => {
115+
isLoading.value = false
116+
})
117+
}
118+
119+
const data = computed<GitLog>(() => {
120+
if (isPrebuilt)
121+
return gitLogData.value
122+
return frontmatter.value.git_log || initGitLog
123+
})
124+
125+
return { data, isLoading, error }
126+
}

client/services/fetchContributors.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,49 @@
11
import type { Contributor } from '../../types'
2-
import { Octokit } from '@octokit/rest'
32
import gravatar from 'gravatar'
43
import md5 from 'md5'
54

6-
const octokit = new Octokit()
7-
5+
/**
6+
* Fetch contributors for a file path via the GitHub REST API.
7+
* Uses native `fetch` instead of @octokit/rest to keep the client bundle small.
8+
*
9+
* Note: Unauthenticated requests are limited to 60/hour per IP.
10+
*/
811
export async function fetchContributors(owner: string, repo: string, path: string): Promise<Contributor[]> {
912
let contributors: Contributor[] = []
1013

1114
try {
12-
const { data } = await octokit.repos.listCommits({ owner, repo, path })
13-
const contributorMap: { [key: string]: Contributor } = {}
15+
const url = `https://api.github.com/repos/${owner}/${repo}/commits?path=${encodeURIComponent(path)}&per_page=100`
16+
const res = await fetch(url, {
17+
headers: { Accept: 'application/vnd.github.v3+json' },
18+
})
19+
20+
if (!res.ok)
21+
throw new Error(`GitHub API responded with ${res.status} ${res.statusText} (${url})`)
1422

15-
data.forEach(({ author, commit }) => {
23+
const data: Array<{
24+
author?: { login?: string, avatar_url?: string, name?: string, email?: string }
25+
commit: { author?: { name?: string, email?: string } }
26+
}> = await res.json()
27+
28+
const contributorMap: Record<string, Contributor> = {}
29+
30+
for (const { author, commit } of data) {
1631
const name = author?.name || author?.login || commit.author?.name || 'Unknown Contributor'
1732
const email = author?.email || commit.author?.email
1833

1934
if (!email)
20-
return
35+
continue
2136

22-
const github = author?.login ? `https://github.com/${author?.login}` : null
37+
const github = author?.login ? `https://github.com/${author.login}` : null
2338
const avatar = author?.avatar_url || `https:${gravatar.url(email, { d: 'wavatar' })}`
2439
const hash = md5(email)
2540

26-
if (contributorMap[name])
27-
contributorMap[name].count += 1
41+
// Use email as key to avoid merging distinct contributors with the same name
42+
if (contributorMap[email])
43+
contributorMap[email].count += 1
2844
else
29-
contributorMap[name] = { count: 1, name, email, avatar, hash, github }
30-
})
45+
contributorMap[email] = { count: 1, name, email, avatar, hash, github }
46+
}
3147

3248
contributors = Object.values(contributorMap)
3349

components/GitLogChangelog.vue

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
11
<script setup lang="ts">
2-
import { functions } from '@vueuse/metadata'
3-
import changelog from 'virtual:git-log/changelog'
2+
import type { Changelog } from '../types'
43
import { computed } from 'vue'
5-
import { useAddonGitLogConfig } from '../client'
4+
import { useAddonGitLogConfig, useChangelog } from '../client'
65
import { renderCommitMessage } from '../utils'
76
8-
const props = defineProps<{ fn: string }>()
7+
const props = defineProps<{
8+
changelog?: Changelog[]
9+
}>()
10+
911
const gitLogOptions = useAddonGitLogConfig()
1012
const repositoryUrl = computed(() => gitLogOptions.value.repositoryUrl || '')
13+
const gitLogChangelog = useChangelog()
1114
12-
const info = computed(() => functions.find(i => i.name === props.fn))
13-
14-
const names = computed(() => [props.fn, ...info.value?.alias || []])
15-
const commits = computed(() => {
16-
const related = changelog
17-
.filter(c => c.version || c.functions?.some(i => names.value.includes(i)))
18-
return related.filter((i, idx) => {
19-
if (i.version && (!related[idx + 1] || related[idx + 1]?.version))
20-
return false
21-
return true
22-
})
23-
})
15+
const commits = computed(() => props.changelog || gitLogChangelog.value)
2416
</script>
2517

2618
<template>
@@ -62,7 +54,7 @@ const commits = computed(() => {
6254
</a>
6355
<span text="sm">
6456
-
65-
<span v-html="renderCommitMessage(commit.message.replace(`(${fn})`, ''), repositoryUrl)" />
57+
<span v-html="renderCommitMessage(commit.message, repositoryUrl)" />
6658
</span>
6759
</div>
6860
</template>

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './client'
22
export * from './node'
3+
export * from './types'

node/changeLog.ts

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import type { Changelog } from '../types'
2-
import { uniq } from '@vueuse/metadata'
1+
import type { Changelog, GitLogOptions } from '../types'
2+
import process from 'node:process'
33
import { git } from '.'
4+
import { shouldIncludeCommit } from '../types'
5+
6+
/**
7+
* ASCII Unit Separator — used as field delimiter in git pretty formats.
8+
*/
9+
const FIELD_SEP = '\x1F'
410

511
let cache: Changelog[] | undefined
612

@@ -12,18 +18,17 @@ const RE_PACKAGE_PATH = /^packages\/\w+\/(\w+)\/\w+\.ts$/
1218
*/
1319
const COMMIT_SEP = '---COMMIT_SEP---'
1420

15-
export async function getChangelog(maxCount = 200, path?: string) {
16-
if (cache)
21+
export async function getChangelog(maxCount = 200, path?: string, options?: GitLogOptions) {
22+
// Only use cache when CI env var is set (CI builds call this multiple times for the virtual module)
23+
if (cache && process.env.CI)
1724
return cache
1825

1926
// Fetch commits together with their changed files in a single git call.
20-
// This replaces the previous N+1 pattern (1 `git log` + N `git diff-tree`)
21-
// that spawned ~100 sub-processes and took ~6 s.
2227
const raw = await git.raw([
2328
'log',
2429
`--max-count=${maxCount}`,
2530
'--name-only',
26-
`--pretty=format:${COMMIT_SEP}%n%H|%an|%ae|%aI|%s|%b`,
31+
`--pretty=format:${COMMIT_SEP}%n%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%s`,
2732
...(path ? ['--', path] : []),
2833
])
2934

@@ -37,20 +42,12 @@ export async function getChangelog(maxCount = 200, path?: string) {
3742
continue
3843

3944
const headerLine = lines[0]
40-
const [hash, authorName, authorEmail, date, ...rest] = headerLine.split('|')
41-
const messageParts = rest.join('|')
42-
// The pretty format puts subject|body — split on first occurrence
43-
const message = messageParts || ''
45+
const [hash, authorName, authorEmail, date, ...rest] = headerLine.split(FIELD_SEP)
46+
const message = rest.join(FIELD_SEP) || ''
4447

45-
// Apply the same filter as before
46-
if (
47-
!message.includes('chore: release')
48-
&& !message.includes('!')
49-
&& !message.startsWith('feat')
50-
&& !message.startsWith('fix')
51-
) {
48+
// Apply configurable filter
49+
if (!shouldIncludeCommit(message, options?.changelog))
5250
continue
53-
}
5451

5552
const log: Changelog = {
5653
hash,
@@ -59,23 +56,24 @@ export async function getChangelog(maxCount = 200, path?: string) {
5956
refs: '',
6057
author_name: authorName,
6158
author_email: authorEmail,
62-
} as Changelog
59+
}
6360

6461
if (message.includes('chore: release')) {
6562
log.version = message.split(' ')[2]?.trim()
6663
}
6764
else {
6865
// Changed files are on the remaining non-empty lines
6966
const files = lines.slice(1).filter(Boolean).map(f => f.replace(RE_BACKSLASH, '/'))
70-
log.functions = uniq(
67+
log.functions = [...new Set(
7168
files
7269
.map(i => i.match(RE_PACKAGE_PATH)?.[1])
73-
.filter(Boolean),
74-
)
70+
.filter((v): v is string => Boolean(v)),
71+
)]
7572
}
7673

7774
logs.push(log)
7875
}
7976

80-
return cache = logs
77+
cache = logs
78+
return logs
8179
}

0 commit comments

Comments
 (0)