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 }) =>
,
+ ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => (
+
+ ),
}));
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\n\n\n\n[参考资料](https://example.com/guide)',
+ readmeMarkdown: '# 开始搭建\n\n\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();
+ });
+});