diff --git a/api/widget.js b/api/widget.js index 38d3361..711dead 100644 --- a/api/widget.js +++ b/api/widget.js @@ -26,15 +26,18 @@ const FALLBACK_AVATAR = `data:image/svg+xml;base64,${Buffer.from(`Stylish Readme city: 'Tokyo', tempUnit: 'C', weatherStyle: 'detailed', + bookPlatform: 'goodreads', + bookUsername: '12345678', + bookStatus: 'currently-reading', eventName: 'Hacktoberfest', targetDate: '2026-10-31', // YouTube @@ -1113,6 +1116,32 @@

Stylish Readme

`; } + else if (state.widget==='book'){ + html += ` +
+ + +
+
+ + +
+
+ + +
+
+ + ${themeSwatches()} +
+ `; + } else if (state.widget==='countdown'){ html += `
@@ -2826,6 +2855,18 @@

Stylish Readme

} } + function svgBook(){ + const theme = themeObj(state.theme); + const borderStyle = theme.border ? `stroke="${theme.fg}" stroke-width="2"` : ''; + const W = 340, H = 130; + const defaultCover = ``; + const statusLabel = (state.bookStatus || 'currently-reading').split('-').join(' ').toUpperCase(); + return ` + + ${defaultCover} + ${statusLabel} + Book Title Preview + Author Name Preview function svgYoutube(){ const theme = themeObj(state.theme); const borderStyle = theme.border?`stroke:${theme.fg};stroke-width:2;`:''; @@ -2949,6 +2990,7 @@

Stylish Readme

case 'glass': return svgGlass(); case 'countdown': return svgCountdown(); case 'weather': return svgWeather(); + case 'book': return svgBook(); case 'youtube': return svgYoutube(); case 'extension': return svgExtension(); } @@ -3063,6 +3105,7 @@

Stylish Readme

{ title:'Glass Profile', w:'glass', overrides:{glassStyle:'card',glassColor:'coral',name:'Sanjay',role:'Full-Stack Developer',bio:'Building cool things with code. Open-source enthusiast.',skills:'HTML,JS,REACT,NODE,PYTHON,GIT,SQL',handle:'cu-sanjay',avatar:'https://github.com/github.png'} }, { title:'Hacktoberfest', w:'countdown', overrides:{theme:'terminal',eventName:'Hacktoberfest',targetDate:'2026-10-31'} }, { title:'Live Weather', w:'weather', overrides:{theme:'retro',city:'London',tempUnit:'C',weatherStyle:'detailed'} }, + { title:'Currently Reading', w:'book', overrides:{theme:'retro',bookPlatform:'goodreads',bookUsername:'12345678',bookStatus:'currently-reading'} }, { title:'YouTube Preview', w:'youtube', overrides:{videoId:'dQw4w9WgXcQ',theme:'classic',showTitle:true,showChannel:true} }, { title:'Chrome Badge', w:'extension', overrides:{extensionName:'Stylish Readme',extensionPlatform:'chrome',extensionId:'jdfhbghjdfg',theme:'classic'} }, { title:'Firefox Badge', w:'extension', overrides:{extensionName:'Stylish Readme',extensionPlatform:'firefox',extensionId:'stylish-readme',theme:'terminal'} } diff --git a/lib/widgets.js b/lib/widgets.js index e13530c..b19b146 100644 --- a/lib/widgets.js +++ b/lib/widgets.js @@ -2611,6 +2611,9 @@ function normalizeParams(q) { skills: q.skills || 'HTML,CSS,JS,GIT,SQL,REACT,NODE,PYTHON', handle: q.handle || '', + bookPlatform: q.bookPlatform || 'goodreads', + bookUsername: q.bookUsername || '', + bookStatus: q.bookStatus || 'currently-reading' // YouTube Widget Params videoId: q.videoId || '', @@ -2855,6 +2858,7 @@ async function renderWidget(type, query) { case 'marker': return await renderMarker(p); case 'glass': return await renderGlass(p); case 'countdown': return renderCountdown(p); + case 'book': return await renderBook(p); case 'youtube': return await renderYoutube(p); case 'extension': return svgWrap(320, 80, renderExtension(p), "'JetBrains Mono', ui-monospace, monospace"); default: @@ -2865,6 +2869,92 @@ async function renderWidget(type, query) { } } +function fetchText(urlStr) { + return new Promise((resolve) => { + try { + const u = new URL(urlStr); + const lib = u.protocol === 'https:' ? require('node:https') : require('node:http'); + const req = lib.get(u, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return resolve(fetchText(new URL(res.headers.location, urlStr).toString())); + } + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(data)); + }); + req.on('error', () => resolve(null)); + req.setTimeout(5000, () => { req.destroy(); resolve(null); }); + } catch { + resolve(null); + } + }); +} + +async function fetchBook(username, status) { + if (!username) return null; + const shelf = status || 'currently-reading'; + const xml = await fetchText(`https://www.goodreads.com/review/list_rss/${username}?shelf=${shelf}`); + if (!xml) return null; + + const itemMatch = xml.match(/([\\s\\S]*?)<\\/item>/); + if (!itemMatch) return null; + const item = itemMatch[1]; + + const titleMatch = item.match(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/title>/) || item.match(/<title>([\\s\\S]*?)<\\/title>/); + const authorMatch = item.match(/<author_name><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/author_name>/) || item.match(/<author_name>([\\s\\S]*?)<\\/author_name>/); + const imgMatch = item.match(/<book_image_url><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/book_image_url>/) || item.match(/<book_image_url>([\\s\\S]*?)<\\/book_image_url>/); + + if (!titleMatch) return null; + + const title = titleMatch[1].trim(); + const author = authorMatch ? authorMatch[1].trim() : 'Unknown'; + let imageUrl = imgMatch ? imgMatch[1].trim() : ''; + + if (imageUrl && imageUrl.startsWith('http:')) { + imageUrl = imageUrl.replace('http:', 'https:'); + } + + let coverB64 = ''; + if (imageUrl) { + coverB64 = await fetchAvatarDataUrl(imageUrl, 3000); + } + + return { title, author, cover: coverB64 }; +} + +async function renderBook(p) { + const theme = themeObj(p.theme); + const borderStyle = theme.border ? `stroke="${theme.fg}" stroke-width="2"` : ''; + const username = p.bookUsername; + const status = p.bookStatus || 'currently-reading'; + + const bookData = await fetchBook(username, status); + + const statusLabel = status.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + + const title = bookData ? escXml(truncateStr(bookData.title, 40)) : 'No book found'; + const author = bookData ? escXml(truncateStr(bookData.author, 30)) : 'Or invalid username'; + + const defaultCover = `<svg viewBox="0 0 24 24" width="60" height="90" fill="none" stroke="${theme.fg}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`; + + const coverSvg = bookData && bookData.cover + ? `<image href="${bookData.cover}" x="15" y="20" width="60" height="90" preserveAspectRatio="xMidYMid slice" clip-path="url(#coverClip)"/>` + : `<g transform="translate(15, 20)">${defaultCover}</g>`; + + return svgWrap(340, 130, ` + <defs> + <clipPath id="coverClip"> + <rect x="15" y="20" width="60" height="90" rx="4" ry="4"/> + </clipPath> + </defs> + <rect x="1" y="1" width="338" height="128" rx="${p.radius||0}" ry="${p.radius||0}" fill="${p.bgColor||theme.bg}" ${borderStyle}/> + ${coverSvg} + <text x="90" y="35" fill="${theme.fg}" font-size="10" font-weight="700" letter-spacing="2" opacity="0.65" font-family="'JetBrains Mono',monospace">${escXml(statusLabel).toUpperCase()}</text> + <text x="90" y="65" fill="${theme.fg}" font-size="18" font-weight="800" font-family="Fraunces,serif">${title}</text> + <text x="90" y="85" fill="${theme.fg}" font-size="13" font-weight="500" opacity="0.8">${author}</text> + `); +} + module.exports = { renderWidget, normalizeParams,