diff --git a/.project-docs/30-worklog/tasks/20260820-direct-readme-client-a4d8e2c7.md b/.project-docs/30-worklog/tasks/20260820-direct-readme-client-a4d8e2c7.md new file mode 100644 index 0000000..2b59d16 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260820-direct-readme-client-a4d8e2c7.md @@ -0,0 +1,62 @@ +# Task: Allow direct HTTPS images in Learning README + +## Identity + +- Task ID: 20260820-direct-readme-client-a4d8e2c7 +- Mode: Feature +- Branch: codex/20260820-direct-readme-client-a4d8e2c7-direct-readme-client +- Worktree: D:\Datas\OthersProjects\makelore-direct-readme-client-a4d8e2c7 +- Base commit: 28bfd8bc3e21a3c2aeec7ee7b859abf571f08757 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Allow Learning README Markdown image nodes to load credential-free HTTPS URLs directly. +- Keep controlled project-media handling, cover behavior, raw-HTML suppression, external + link handling, and archive download boundaries unchanged. + +## Intent And Constraints + +- Follow the user's explicit replacement decision even though accepted client ADR-005 + currently requires publish-time mirrored README media. +- Limit HTTPS enablement to README image nodes by opting into the existing guarded + `ProjectImage` direct-source path; do not create a Renderer network proxy. +- Preserve credential rejection and per-image failure isolation. + +## Outcome + +- Learning project detail now opts README images into the existing credential-free HTTPS + direct-loading path, including SVG and any format Electron can render. +- Renamed the shared URL predicate from cover-specific to image-generic terminology. +- Updated the detail-page regression to use a remote SVG URL and assert the HTTPS opt-in; + added direct component coverage proving the opt-in remains required. + +## Verification + +- Focused Learning tests: `5 passed` across the detail page and `ProjectImage`. +- Full unit suite: `176` files, `2058 passed`. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with zero errors and six pre-existing warnings. +- `pnpm run build:vite`: passed; existing chunk-size/dynamic-import advisories only. +- `git diff --check`: passed. + +## Follow-ups + +- Reconcile ADR-005, Learning architecture/domain/current-state documents, the server + contract, and the production Learning commitment in an Integration Gate task. +- Package and smoke the matching server/client revisions with real remote README images. + +## Promotion Candidates + +- Target: ADR-005, Learning system overview/domain/current state, + `docs/learning-project-catalog-server-contract.md`, and Learning commitments. + - Proposal: document sanitized direct HTTPS README images instead of authenticated + mirrored media; retain Main-owned archive download and controlled cover/media paths. + - Evidence: explicit user direction, focused direct-SVG rendering regressions, full + client tests, typecheck, lint, and production build. + - Future impact: Renderer image requests go directly to third-party HTTPS origins; + image format support and availability are provided by Electron and the origin. + - Semantic conflicts: reverses the mirrored-media rule in accepted ADR-005 and its + derivative contract and operational checklist. + - Human confirmation required: no; the user explicitly selected direct rendering. diff --git a/src/lib/learning.ts b/src/lib/learning.ts index 797094b..16c71c5 100644 --- a/src/lib/learning.ts +++ b/src/lib/learning.ts @@ -132,7 +132,7 @@ export function isLearningProjectMediaUrl(value: string): boolean { return PROJECT_MEDIA_URL_PATTERN.test(value); } -export function isSafeLearningCoverUrl(value: string): boolean { +export function isSafeLearningImageUrl(value: string): boolean { if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true; try { const url = new URL(value); diff --git a/src/pages/Learning/ProjectDetail.tsx b/src/pages/Learning/ProjectDetail.tsx index ef4490b..e2dde0d 100644 --- a/src/pages/Learning/ProjectDetail.tsx +++ b/src/pages/Learning/ProjectDetail.tsx @@ -146,6 +146,7 @@ export function LearningProjectDetail() { ) : null, diff --git a/src/pages/Learning/ProjectImage.tsx b/src/pages/Learning/ProjectImage.tsx index daa8cc7..8f40b4e 100644 --- a/src/pages/Learning/ProjectImage.tsx +++ b/src/pages/Learning/ProjectImage.tsx @@ -3,7 +3,7 @@ import { ImageOff, Loader2 } from 'lucide-react'; import { fetchLearningProjectMedia, isLearningProjectMediaUrl, - isSafeLearningCoverUrl, + isSafeLearningImageUrl, } from '@/lib/learning'; import { cn } from '@/lib/utils'; @@ -16,7 +16,7 @@ type ProjectImageProps = { export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) { const controlledMedia = isLearningProjectMediaUrl(src); - const directSource = allowHttps && isSafeLearningCoverUrl(src) && !controlledMedia ? src : null; + const directSource = allowHttps && isSafeLearningImageUrl(src) && !controlledMedia ? src : null; const [failedSource, setFailedSource] = useState(null); const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({ input: '', diff --git a/tests/unit/learning-page.test.tsx b/tests/unit/learning-page.test.tsx index c8d18aa..57f7ce3 100644 --- a/tests/unit/learning-page.test.tsx +++ b/tests/unit/learning-page.test.tsx @@ -17,7 +17,9 @@ vi.mock('@/lib/learning', () => ({ })); vi.mock('@/pages/Learning/ProjectImage', () => ({ - ProjectImage: ({ src, alt }: { src: string; alt: string }) => {alt}, + ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => ( + {alt} + ), })); const project = { @@ -74,12 +76,12 @@ describe('Learning project pages', () => { expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 }); }); - it('renders Markdown without raw HTML and keeps remote README media on the controlled path', async () => { + it('renders Markdown without raw HTML and allows direct HTTPS README images', async () => { fetchLearningProjectMock.mockResolvedValue({ ...project, archiveFileName: 'robot-arm.zip', archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 开始搭建\n\n![接线图](/api/learning/projects/robot-arm/media/wiring)\n\n\n\n[参考资料](https://example.com/guide)', + readmeMarkdown: '# 开始搭建\n\n![接线图](https://images.example.com/wiring.svg)\n\n\n\n[参考资料](https://example.com/guide)', }); render( @@ -90,8 +92,9 @@ describe('Learning project pages', () => { expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument(); expect(screen.getByAltText('接线图')).toHaveAttribute( 'src', - '/api/learning/projects/robot-arm/media/wiring', + 'https://images.example.com/wiring.svg', ); + expect(screen.getByAltText('接线图')).toHaveAttribute('data-allow-https', 'true'); expect(document.querySelector('script')).toBeNull(); fireEvent.click(screen.getByRole('link', { name: '参考资料' })); expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide'); diff --git a/tests/unit/learning-project-image.test.tsx b/tests/unit/learning-project-image.test.tsx new file mode 100644 index 0000000..6a9864c --- /dev/null +++ b/tests/unit/learning-project-image.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ProjectImage } from '@/pages/Learning/ProjectImage'; + +describe('Learning ProjectImage', () => { + it('renders a credential-free HTTPS image only when direct loading is allowed', () => { + const { rerender } = render( + , + ); + + expect(screen.getByAltText('项目结构图')).toHaveAttribute( + 'src', + 'https://images.example.com/diagram.svg', + ); + + rerender( + , + ); + + expect(screen.queryByAltText('项目结构图')).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: '项目结构图加载失败' })).toBeInTheDocument(); + }); +});