Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions api/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@ const FALLBACK_AVATAR = `data:image/svg+xml;base64,${Buffer.from(`<svg xmlns="ht
* - flag : Country data never changes; 24-hour cache maximises CDN hits.
*/
const CACHE_POLICIES = {
// Weather data - refresh every 30 minutes to avoid hitting API rate limits
weather: 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=600',

// Reading status - refresh every hour
book: 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=1200',

// Real-time widgets — refresh every 60 seconds
time: 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
clock: 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
timezone: 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
skyline: 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',

// Weather data - refresh every 30 minutes to avoid hitting API rate limits
weather: 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=600',

// Daily-change widgets — refresh every hour

date: 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600',
Expand Down
47 changes: 45 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
cursor:pointer;
transition:all .12s ease;
}
.tab-btn:hover{background:var(--paper-2);}
.tab-btn:hover{background:var(--paper-2); color:var(--ink);}
.tab-btn-active{background:var(--ink);color:var(--paper);}
.input-field{
background:var(--paper);
Expand Down Expand Up @@ -209,7 +209,7 @@
}
.note-box strong{font-weight:800;}
.embed-tabs .tab-btn{background:transparent;color:var(--paper);border:2px solid var(--paper);}
.embed-tabs .tab-btn:hover{background:rgba(244,241,234,0.12);}
.embed-tabs .tab-btn:hover{background:rgba(244,241,234,0.12); color:var(--paper);}
.embed-tabs .tab-btn-active{background:var(--paper);color:var(--ink);border-color:var(--paper);}
.preview-stage svg, .preview-card svg{max-width:100%;height:auto;}
@media (max-width:640px){
Expand Down Expand Up @@ -812,6 +812,9 @@ <h4 class="font-serif font-black text-3xl leading-none">Stylish Readme</h4>
city: 'Tokyo',
tempUnit: 'C',
weatherStyle: 'detailed',
bookPlatform: 'goodreads',
bookUsername: '12345678',
bookStatus: 'currently-reading',
eventName: 'Hacktoberfest',
targetDate: '2026-10-31',
// YouTube
Expand Down Expand Up @@ -1113,6 +1116,32 @@ <h4 class="font-serif font-black text-3xl leading-none">Stylish Readme</h4>
</div>
`;
}
else if (state.widget==='book'){
html += `
<div class="mb-5">
<label class="label-tag">Platform</label>
<select class="input-field" onchange="setField('bookPlatform',this.value)">
<option value="goodreads" ${state.bookPlatform==='goodreads'?'selected':''}>Goodreads</option>
</select>
</div>
<div class="mb-5">
<label class="label-tag">Profile Username/ID</label>
<input class="input-field" value="${state.bookUsername}" oninput="setField('bookUsername',this.value,false)" placeholder="e.g. 12345678"/>
</div>
<div class="mb-5">
<label class="label-tag">Shelf / Status</label>
<select class="input-field" onchange="setField('bookStatus',this.value)">
<option value="currently-reading" ${state.bookStatus==='currently-reading'?'selected':''}>Currently Reading</option>
<option value="read" ${state.bookStatus==='read'?'selected':''}>Read</option>
<option value="to-read" ${state.bookStatus==='to-read'?'selected':''}>To Read</option>
</select>
</div>
<div class="mb-5">
<label class="label-tag">Theme</label>
${themeSwatches()}
</div>
`;
}
else if (state.widget==='countdown'){
html += `
<div class="mb-5">
Expand Down Expand Up @@ -2826,6 +2855,18 @@ <h4 class="font-serif font-black text-3xl leading-none">Stylish Readme</h4>
}
}

function svgBook(){
const theme = themeObj(state.theme);
const borderStyle = theme.border ? `stroke="${theme.fg}" stroke-width="2"` : '';
const W = 340, H = 130;
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 statusLabel = (state.bookStatus || 'currently-reading').split('-').join(' ').toUpperCase();
return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="font-family:'JetBrains Mono',monospace;">
<rect x="1" y="1" width="${W-2}" height="${H-2}" rx="${state.radius||0}" ry="${state.radius||0}" fill="${theme.bg}" ${borderStyle}/>
<g transform="translate(15, 20)">${defaultCover}</g>
<text x="90" y="35" fill="${theme.fg}" font-size="10" font-weight="700" letter-spacing="2" opacity="0.65">${statusLabel}</text>
<text x="90" y="65" fill="${theme.fg}" font-size="18" font-weight="800" font-family="Fraunces,serif">Book Title Preview</text>
<text x="90" y="85" fill="${theme.fg}" font-size="13" font-weight="500" opacity="0.8">Author Name Preview</text>
function svgYoutube(){
const theme = themeObj(state.theme);
const borderStyle = theme.border?`stroke:${theme.fg};stroke-width:2;`:'';
Expand Down Expand Up @@ -2949,6 +2990,7 @@ <h4 class="font-serif font-black text-3xl leading-none">Stylish Readme</h4>
case 'glass': return svgGlass();
case 'countdown': return svgCountdown();
case 'weather': return svgWeather();
case 'book': return svgBook();
case 'youtube': return svgYoutube();
case 'extension': return svgExtension();
}
Expand Down Expand Up @@ -3063,6 +3105,7 @@ <h4 class="font-serif font-black text-3xl leading-none">Stylish Readme</h4>
{ 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'} }
Expand Down
90 changes: 90 additions & 0 deletions lib/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down Expand Up @@ -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:
Expand All @@ -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(/<item>([\\s\\S]*?)<\\/item>/);
if (!itemMatch) return null;
const item = itemMatch[1];

const titleMatch = item.match(/<title><!\\[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,
Expand Down