Skip to content

Commit 7baa215

Browse files
committed
Merge: README DOMPurify 消毒渲染、官网 favicon 与 SEO meta
2 parents 409a42f + 9fceabe commit 7baa215

8 files changed

Lines changed: 178 additions & 134 deletions

File tree

index.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@
1313
<meta property="og:title" content="OpenSource.Win — For Our Shared Future" />
1414
<meta property="og:description" content="Believing in Openness to Build a Co-Evolving Human-AI Civilization." />
1515
<meta property="og:type" content="website" />
16-
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>_</text></svg>">
16+
<meta property="og:url" content="https://opensource.win/" />
17+
<meta property="og:image" content="https://opensource.win/og-image.jpg" />
18+
<meta name="twitter:card" content="summary_large_image" />
19+
<meta name="twitter:title" content="OpenSource.Win — For Our Shared Future" />
20+
<meta name="twitter:description" content="Believing in Openness to Build a Co-Evolving Human-AI Civilization." />
21+
<meta name="twitter:image" content="https://opensource.win/og-image.jpg" />
22+
<meta name="theme-color" content="#0d0d0d" />
23+
<link rel="canonical" href="https://opensource.win/" />
24+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
25+
<link rel="alternate icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 fill=%22%230d0d0d%22/><text x=%2232%22 y=%2243%22 font-family=%22monospace%22 font-size=%2230%22 font-weight=%22700%22 fill=%22%234bf08e%22 text-anchor=%22middle%22>%26gt;_</text></svg>">
1726
</head>
1827
<body>
1928
<div id="root"></div>

ossheroes/themes/OpenSourceWin/layout/_partial/head.ejs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,10 @@
8989
<%- favicon_tag('img/favicon.ico') %>
9090
<%- css('css/index.v1.css') %>
9191
<%- js('js/i18n.js') %>
92+
<%# 开发者详情页(page.ejs 且无 data_year)才渲染 README 模块:marked 与 DOMPurify 必须先于 detail-readme.js 加载 %>
93+
<% if (is_page() && !page.data_year) { %>
94+
<%- js('js/vendor/marked.min.js') %>
95+
<%- js('js/vendor/purify.min.js') %>
96+
<% } %>
9297
<%- js('js/detail-readme.js') %>
9398
</head>

ossheroes/themes/OpenSourceWin/source/js/detail-readme.js

Lines changed: 130 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,41 @@
11
/* OpenSource.Win ossheroes 详情页:GitHub Profile README 模块
22
* 客户端拉取 raw.githubusercontent.com/<login>/<login>/{main,master}/README.md,
3-
* 用 vendored marked(js/vendor/marked.min.js,不走 CDN)渲染为 HTML 注入 #detailReadmeContent:
4-
* - 原始 HTML 一律转义(renderer.html 覆写),防 README 内 HTML 注入破坏页面;
3+
* 用 vendored marked + DOMPurify(js/vendor/,不走 CDN)渲染进 #detailReadmeContent:
4+
* - marked 与 DOMPurify 由 head.ejs 在开发者详情页先于本脚本引入(顺序即依赖顺序);
5+
* - marked 解析 markdown,原始 HTML(GitHub Profile README 大量使用的徽章、
6+
* <picture> 暗色适配等安全子集)原样通过;
7+
* - DOMPurify 白名单消毒:仅放行 GitHub 常见安全标签/属性与 http/https/mailto/
8+
* data:image 协议,script/iframe/javascript: 等一律剔除;
59
* - 相对图片路径 → raw.githubusercontent.com/<login>/<login>/<branch>/ 绝对地址;
610
* - 相对链接路径 → github.com/<login>/<login>/blob/<branch>/ 绝对地址;
7-
* - 图片统一 loading="lazy",外链 target="_blank" rel="noopener"。
8-
* 404 / 网络错误 / 超时 / marked 加载失败 / 内容为空时模块保持 hidden,页面不留痕。
11+
* - 图片统一 loading="lazy",外链 target="_blank" rel="noopener noreferrer"。
12+
* 404 / 网络错误 / 超时 / vendor 缺失 / 内容为空时模块保持 hidden,页面不留痕。
913
*/
1014
(function () {
1115
var TIMEOUT_MS = 8000;
1216
var MAX_LENGTH = 40000;
1317
var BRANCHES = ['main', 'master'];
1418

19+
/* DOMPurify 白名单:对齐 GitHub Profile README 实际允许的标签子集 */
20+
var SANITIZE_CONFIG = {
21+
ALLOWED_TAGS: [
22+
'a', 'img', 'picture', 'source',
23+
'p', 'div', 'span', 'sub', 'sup', 'details', 'summary', 'br',
24+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
25+
'ul', 'ol', 'li', 'pre', 'code', 'blockquote',
26+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
27+
'strong', 'em', 'del', 'hr'
28+
],
29+
ALLOWED_ATTR: [
30+
'href', 'src', 'srcset', 'alt', 'title',
31+
'width', 'height', 'align', 'target', 'loading',
32+
'media', 'open'
33+
],
34+
/* 协议白名单:http/https/mailto + 图片 data:image;相对路径与锚点放行(随后改写) */
35+
ALLOWED_URI_REGEXP:
36+
/^(?:(?:https?|mailto):|data:image\/|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
37+
};
38+
1539
function ready(fn) {
1640
if (document.readyState === 'loading') {
1741
document.addEventListener('DOMContentLoaded', fn);
@@ -46,56 +70,22 @@
4670
);
4771
}
4872

49-
/* 由自身 script 标签推导站点根路径(兼容任意 base 路径),按需注入 vendored marked */
50-
function loadMarked(cb) {
51-
if (window.marked && typeof window.marked.parse === 'function') {
52-
cb(null);
53-
return;
54-
}
55-
var scripts = document.getElementsByTagName('script');
56-
var base = null;
57-
for (var i = 0; i < scripts.length; i++) {
58-
var src = scripts[i].src || '';
59-
var idx = src.indexOf('/js/detail-readme.js');
60-
if (idx !== -1) {
61-
base = src.slice(0, idx);
62-
break;
63-
}
64-
}
65-
if (!base) {
66-
cb(new Error('no base'));
67-
return;
68-
}
69-
var el = document.createElement('script');
70-
el.src = base + '/js/vendor/marked.min.js';
71-
el.onload = function () {
72-
if (window.marked && typeof window.marked.parse === 'function') {
73-
cb(null);
74-
} else {
75-
cb(new Error('marked broken'));
76-
}
77-
};
78-
el.onerror = function () {
79-
cb(new Error('marked load failed'));
80-
};
81-
document.head.appendChild(el);
82-
}
83-
84-
function escapeHtml(str) {
85-
return String(str)
86-
.replace(/&/g, '&amp;')
87-
.replace(/</g, '&lt;')
88-
.replace(/>/g, '&gt;')
89-
.replace(/"/g, '&quot;')
90-
.replace(/'/g, '&#39;');
73+
/* vendor 就绪检查:marked 与 DOMPurify 由 head.ejs 先于本脚本引入 */
74+
function vendorsReady() {
75+
return (
76+
window.marked &&
77+
typeof window.marked.parse === 'function' &&
78+
window.DOMPurify &&
79+
typeof window.DOMPurify.sanitize === 'function'
80+
);
9181
}
9282

9383
/* 相对路径判定:协议绝对 / 协议相对 / data / mailto / 纯锚点都不算相对 */
9484
function isRelativeUrl(href) {
9585
return !/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href);
9686
}
9787

98-
/* 协议白名单:javascript:/vbscript: 等可执行协议一律拒绝(marked 默认消毒被自定义 renderer 绕过,需自行把关) */
88+
/* 协议白名单(消毒后的二次把关):javascript:/vbscript: 等可执行协议一律拒绝 */
9989
function isSafeUrl(href, allowData) {
10090
if (isRelativeUrl(href)) return true; /* 相对路径会被改写为 github 绝对地址 */
10191
if (href.charAt(0) === '#' || href.indexOf('//') === 0) return true;
@@ -108,84 +98,70 @@
10898
return href.replace(/^\/+/, '').replace(/^(\.\/)+/, '');
10999
}
110100

111-
/* 配置 marked:转义原始 HTML,相对路径转绝对地址(raw / blob) */
112-
function configureMarked(login, branch) {
113-
var rawBase =
114-
'https://raw.githubusercontent.com/' +
115-
encodeURIComponent(login) +
116-
'/' +
117-
encodeURIComponent(login) +
118-
'/' +
119-
branch +
120-
'/';
121-
var blobBase =
122-
'https://github.com/' +
123-
encodeURIComponent(login) +
124-
'/' +
125-
encodeURIComponent(login) +
126-
'/blob/' +
127-
branch +
128-
'/';
129-
130-
window.marked.use({
131-
gfm: true,
132-
breaks: true,
133-
renderer: {
134-
/* 原始 HTML(块级与行内)整体转义为文本,杜绝注入 */
135-
html: function (token) {
136-
return escapeHtml(token && token.text ? token.text : token);
137-
},
138-
link: function (token) {
139-
var href = token && token.href ? token.href : '';
140-
var text = this.parser.parseInline(token.tokens);
141-
if (!isSafeUrl(href, false)) {
142-
return text; /* 危险协议:只保留链接文字,不输出 href */
143-
}
144-
var title = token.title
145-
? ' title="' + escapeHtml(token.title) + '"'
146-
: '';
147-
var external = '';
148-
if (isRelativeUrl(href)) {
149-
href = blobBase + stripLeadingDots(href);
150-
}
151-
if (href.charAt(0) !== '#') {
152-
external = ' target="_blank" rel="noopener"';
153-
}
154-
return (
155-
'<a href="' +
156-
escapeHtml(href) +
157-
'"' +
158-
title +
159-
external +
160-
'>' +
161-
text +
162-
'</a>'
163-
);
164-
},
165-
image: function (token) {
166-
var href = token && token.href ? token.href : '';
167-
var alt = token && token.text ? token.text : '';
168-
if (!isSafeUrl(href, true)) {
169-
return escapeHtml(alt); /* 危险协议:只保留 alt 文本 */
170-
}
171-
var title = token.title
172-
? ' title="' + escapeHtml(token.title) + '"'
173-
: '';
174-
if (isRelativeUrl(href)) {
175-
href = rawBase + stripLeadingDots(href);
176-
}
177-
return (
178-
'<img src="' +
179-
escapeHtml(href) +
180-
'" alt="' +
181-
escapeHtml(alt) +
182-
'"' +
183-
title +
184-
' loading="lazy">'
185-
);
101+
/* srcset 可能含多个候选("url 1x, url2 2x"),逐个改写相对 URL。
102+
* 注意:data: URI 内含逗号时朴素切分会拆坏候选——浏览器会丢弃非法候选并回退 src,可接受。 */
103+
function rewriteSrcset(srcset, rawBase) {
104+
return srcset
105+
.split(',')
106+
.map(function (candidate) {
107+
var parts = candidate.trim().split(/\s+/);
108+
if (parts[0] && isRelativeUrl(parts[0])) {
109+
parts[0] = rawBase + stripLeadingDots(parts[0]);
110+
}
111+
return parts.join(' ');
112+
})
113+
.join(', ');
114+
}
115+
116+
/* 渲染后 DOM 处理:相对路径转绝对、协议二次把关、外链新开标签、图片懒加载 */
117+
function postProcess(container, rawBase, blobBase) {
118+
var i, el, url;
119+
120+
var anchors = container.querySelectorAll('a[href]');
121+
for (i = 0; i < anchors.length; i++) {
122+
el = anchors[i];
123+
url = el.getAttribute('href');
124+
if (!isSafeUrl(url, false)) {
125+
el.removeAttribute('href');
126+
continue;
127+
}
128+
if (isRelativeUrl(url)) {
129+
el.setAttribute('href', blobBase + stripLeadingDots(url));
130+
url = el.getAttribute('href');
131+
}
132+
if (url.charAt(0) !== '#') {
133+
el.setAttribute('target', '_blank');
134+
el.setAttribute('rel', 'noopener noreferrer');
135+
}
136+
}
137+
138+
var images = container.querySelectorAll('img');
139+
for (i = 0; i < images.length; i++) {
140+
el = images[i];
141+
url = el.getAttribute('src');
142+
if (url) {
143+
if (!isSafeUrl(url, true)) {
144+
el.removeAttribute('src');
145+
} else if (isRelativeUrl(url)) {
146+
el.setAttribute('src', rawBase + stripLeadingDots(url));
186147
}
187148
}
188-
});
149+
var srcset = el.getAttribute('srcset');
150+
if (srcset) el.setAttribute('srcset', rewriteSrcset(srcset, rawBase));
151+
if (!el.getAttribute('loading')) el.setAttribute('loading', 'lazy');
152+
}
153+
154+
/* <picture><source media="(prefers-color-scheme: dark)">:保留 media,暗色主题自然命中 */
155+
var sources = container.querySelectorAll('source');
156+
for (i = 0; i < sources.length; i++) {
157+
el = sources[i];
158+
url = el.getAttribute('src');
159+
if (url && isRelativeUrl(url)) {
160+
el.setAttribute('src', rawBase + stripLeadingDots(url));
161+
}
162+
var ss = el.getAttribute('srcset');
163+
if (ss) el.setAttribute('srcset', rewriteSrcset(ss, rawBase));
164+
}
189165
}
190166

191167
ready(function () {
@@ -202,16 +178,37 @@
202178
lang === 'zh' ? '\n\n… [内容过长,已截断]' : '\n\n… [truncated]';
203179

204180
function render(text, branch) {
205-
loadMarked(function (err) {
206-
if (err) return; /* marked 加载失败:模块保持隐藏 */
207-
try {
208-
configureMarked(login, branch);
209-
container.innerHTML = window.marked.parse(text);
210-
box.hidden = false;
211-
} catch (e) {
212-
/* 渲染异常同样保持隐藏 */
213-
}
214-
});
181+
if (!vendorsReady()) return; /* vendor 缺失:模块保持隐藏 */
182+
try {
183+
var rawBase =
184+
'https://raw.githubusercontent.com/' +
185+
encodeURIComponent(login) +
186+
'/' +
187+
encodeURIComponent(login) +
188+
'/' +
189+
branch +
190+
'/';
191+
var blobBase =
192+
'https://github.com/' +
193+
encodeURIComponent(login) +
194+
'/' +
195+
encodeURIComponent(login) +
196+
'/blob/' +
197+
branch +
198+
'/';
199+
200+
window.marked.setOptions({ gfm: true, breaks: true });
201+
/* markdown → HTML(原始 HTML 放行)→ DOMPurify 白名单消毒 → DOM 后处理 */
202+
var html = window.DOMPurify.sanitize(
203+
window.marked.parse(text),
204+
SANITIZE_CONFIG
205+
);
206+
container.innerHTML = html;
207+
postProcess(container, rawBase, blobBase);
208+
box.hidden = false;
209+
} catch (e) {
210+
/* 渲染异常同样保持隐藏 */
211+
}
215212
}
216213

217214
function tryBranch(index) {

ossheroes/themes/OpenSourceWin/source/js/vendor/purify.min.js

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/favicon.svg

Lines changed: 4 additions & 0 deletions
Loading

public/og-image.jpg

176 KB
Loading

src/contexts/LanguageContext.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { createContext, useContext, useState, useEffect } from 'react';
2+
import { manifestoContent } from '../data/manifesto';
23

34
type Language = 'en' | 'zh';
45

@@ -28,6 +29,20 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
2829

2930
useEffect(() => {
3031
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
32+
33+
const { pageTitle, pageDescription } = manifestoContent.meta;
34+
document.title = pageTitle[language];
35+
const metaSelectors: Array<[string, string]> = [
36+
['meta[name="description"]', pageDescription[language]],
37+
['meta[property="og:title"]', pageTitle[language]],
38+
['meta[property="og:description"]', pageDescription[language]],
39+
['meta[name="twitter:title"]', pageTitle[language]],
40+
['meta[name="twitter:description"]', pageDescription[language]],
41+
];
42+
for (const [selector, content] of metaSelectors) {
43+
document.querySelector(selector)?.setAttribute('content', content);
44+
}
45+
3146
try {
3247
localStorage.setItem(STORAGE_KEY, language);
3348
} catch {

src/data/manifesto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
export const manifestoContent = {
55
meta: {
6+
pageTitle: {
7+
en: "OpenSource.Win — For Our Shared Future",
8+
zh: "OpenSource.Win 宣言 — 致我们共同的未来",
9+
},
10+
pageDescription: {
11+
en: "The OpenSource.Win Manifesto: Believing in Openness to Build a Co-Evolving Human-AI Civilization. Open Source Will Win the Future.",
12+
zh: "OpenSource.Win 宣言:相信开放的力量,构建人机共进的文明。开源必胜。",
13+
},
614
author: {
715
en: "Sunny Gao",
816
zh: "Sunny Gao (高阳)",

0 commit comments

Comments
 (0)