본문으로 건너뛰기
Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
src/shared/novel-reader.ts
공개 가능
1 // Shared with HinanaStudioNovel. Synchronize using the Novel integration script.
2 import { READER_SCRIPT, READER_SCRIPT_HASH } from './novel-reader-runtime';
3 export const fonts = {
4 serif: '"Iowan Old Style", "AppleMyungjo", "Batang", serif',
5 sans: '-apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif',
6 mono: '"SFMono-Regular", Consolas, monospace',
7 custom: '"NovelCustom", serif'
8 };
9 export const themes = { paper: { bg: '#faf8f3', fg: '#35322e', muted: '#8e877c', line: '#e7e2d9' }, sepia: { bg: '#eee3cf', fg: '#4a3b2b', muted: '#88745d', line: '#d7c9af' }, night: { bg: '#202127', fg: '#d4d0c7', muted: '#8b8991', line: '#383940' } };
10 export function createProject(sample = false) {
11 return { format: 'hinana-novel', version: 1, id: crypto.randomUUID(), description: '', title: sample ? '오후의 페이지' : '이름 없는 작품', author: '', chapters: [{ id: crypto.randomUUID(), title: sample ? '창가에 남겨 둔 오후' : '첫 번째 글', text: sample ? '비가 그친 뒤의 도시는 조금 느리게 움직였다.\n\n나는 창가에 앉아 아직 마르지 않은 거리를 바라보았다. 유리 너머로 지나가는 사람들은 저마다 다른 속도의 오후를 가지고 있었다. 누군가는 약속을 향해 서둘렀고, 누군가는 아무 데도 가지 않는 것처럼 천천히 걸었다.\n\n책장을 넘기자 종이 사이에 끼워 두었던 작은 잎이 떨어졌다. 언제, 어디에서 주웠는지는 기억나지 않았다. 다만 그날에도 지금처럼 창문이 열려 있었을 것 같았다.\n\n“같은 문장도 어디에서 읽느냐에 따라 달라질까.”\n\n그렇게 적고 나서 한동안 다음 문장을 쓰지 못했다. 손바닥만 한 화면과 넓은 책상 위의 화면. 그 사이에서 문장은 길어지기도, 짧아지기도 했다. 그러나 그 문장이 머무르는 시간만큼은 온전히 나의 것이었다.\n\n나는 다시 첫 페이지를 펼쳤다. 이번에는 조금 더 천천히 읽기로 했다.' : '' }], settings: { font: 'serif', fontSize: 18, lineHeight: 1.9, letterSpacing: 0, width: 640, padding: 40, theme: 'paper', writing: 'horizontal', reading: 'scroll', spread: 'auto', align: 'left', indent: false }, customFont: null };
12 }
13 const fail = message => { throw new Error(message); };
14 export function parseProject(raw) {
15 if (typeof raw !== 'string' || raw.length > 28 * 1024 * 1024) fail('프로젝트는 28MB 이하여야 합니다.');
16 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
17 if (!p || p.format !== 'hinana-novel' || p.version !== 1) fail('지원하지 않는 Novel 프로젝트 형식입니다.');
18 const str = (v, max) => typeof v === 'string' && v.length <= max;
19 if (!str(p.title, 300) || !str(p.author, 300) || !Array.isArray(p.chapters) || !p.chapters.length || p.chapters.length > 500) fail('작품 정보가 올바르지 않습니다.');
20 const ids = new Set();
21 const chapters = p.chapters.map(c => {
22 if (!c || !str(c.id, 100) || !c.id || ids.has(c.id) || !str(c.title, 300) || !str(c.text, 2_000_000)) fail('글의 형식 또는 분량이 올바르지 않습니다.');
23 ids.add(c.id); return { id: c.id, title: c.title, text: c.text };
24 });
25 const s = p.settings;
26 if (!s || !Object.prototype.hasOwnProperty.call(fonts, s.font) || !Object.prototype.hasOwnProperty.call(themes, s.theme) || !['horizontal', 'vertical'].includes(s.writing) || !['left', 'justify'].includes(s.align) || typeof s.indent !== 'boolean') fail('독서 설정이 올바르지 않습니다.');
27 if (s.reading !== undefined && !['scroll', 'pages'].includes(s.reading)) fail('읽기 방식이 올바르지 않습니다.');
28 if (s.spread !== undefined && !['auto', 'single', 'double'].includes(s.spread)) fail('페이지 보기 방식이 올바르지 않습니다.');
29 for (const [key, min, max] of [['fontSize', 12, 40], ['lineHeight', 1.2, 2.8], ['letterSpacing', -1, 3], ['width', 320, 1000], ['padding', 16, 80]]) if (!Number.isFinite(s[key]) || s[key] < min || s[key] > max) fail('독서 설정의 범위를 확인해 주세요.');
30 let customFont = null;
31 if (p.customFont != null) {
32 if (!str(p.customFont.name, 300) || !str(p.customFont.data, 20_000_000) || !/^data:font\/(woff2?|ttf|otf);base64,[A-Za-z0-9+/]+={0,2}$/.test(p.customFont.data)) fail('글꼴 데이터가 올바르지 않습니다.');
33 customFont = { name: p.customFont.name, data: p.customFont.data };
34 }
35 if (s.font === 'custom' && !customFont) fail('사용자 글꼴 파일이 없습니다.');
36 if (p.description !== undefined && !str(p.description, 500)) fail('작품 소개는 500자 이하여야 합니다.');
37 if (p.id !== undefined && (!str(p.id, 100) || !p.id)) fail('작품 식별자가 올바르지 않습니다.');
38 return { format: 'hinana-novel', version: 1, id: p.id || chapters[0].id, description: p.description || '', title: p.title, author: p.author, chapters, settings: { font: s.font, theme: s.theme, writing: s.writing, reading: s.reading || 'scroll', spread: s.spread || 'auto', align: s.align, indent: s.indent, fontSize: s.fontSize, lineHeight: s.lineHeight, letterSpacing: s.letterSpacing, width: s.width, padding: s.padding }, customFont };
39 }
40 export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
41 export function readerPolicy() { return `default-src 'none'; style-src 'unsafe-inline'; font-src data:; script-src 'sha256-${READER_SCRIPT_HASH}'; base-uri 'none'; form-action 'none'`; }
42 export function readerHTML(input, chapterId = undefined) {
43 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
44 const chapters = chapterId ? p.chapters.filter(c => c.id === chapterId) : p.chapters;
45 return `<!doctype html>
46 <html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="${s.theme === 'night' ? 'dark' : 'light'}"><meta http-equiv="Content-Security-Policy" content="${readerPolicy()}"><title>${escapeHTML(p.title)}</title><style>
47 ${p.customFont ? `@font-face{font-family:NovelCustom;src:url('${p.customFont.data}');font-display:swap}` : ''}
48 *{box-sizing:border-box}html,body{margin:0;background:${t.bg};color:${t.fg};height:100%;overflow:hidden;}body{font-family:${fonts.sans}}header{height:60px;padding:0 24px;border-bottom:1px solid ${t.line};display:flex;align-items:center;justify-content:space-between;gap:16px;position:relative;z-index:2;background:${t.bg};font-size:12px}header>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${t.muted}}details{position:relative;flex-shrink:0}summary{cursor:pointer;color:${t.muted}}nav{position:absolute;right:0;top:32px;padding:16px;min-width:220px;max-width:80vw;max-height:70vh;overflow:auto;box-shadow:0 8px 32px #0002;background:${t.bg};border:1px solid ${t.line};border-radius:8px}nav a{display:block;padding:10px 0;color:${t.fg};text-decoration:none;overflow-wrap:anywhere}.scroll{position:absolute;inset:60px 0 0;overflow:auto;scroll-behavior:smooth}.book{font-family:${fonts[s.font]};font-size:${s.fontSize}px;line-height:${s.lineHeight};letter-spacing:${s.letterSpacing}px;text-align:${s.align};max-width:${s.width + s.padding * 2}px;margin:0 auto;padding:${s.padding}px;overflow-wrap:anywhere}section{margin-bottom:4em;scroll-margin:24px}h1{font-size:1.65em;font-weight:500;line-height:1.5;letter-spacing:-.02em;margin:1em 0 1.8em}p{margin:0 0 1em;white-space:pre-wrap;${s.indent ? 'text-indent:1em;' : ''}}.byline{color:${t.muted};font:11px ${fonts.sans};margin-bottom:40px}.end{font:11px ${fonts.sans};color:${t.muted};border-top:1px solid ${t.line};padding-top:24px;text-align:center}
49 ${s.writing === 'vertical' ? `.scroll{direction:rtl}.book{direction:ltr;writing-mode:vertical-rl;text-orientation:mixed;max-width:none;width:max-content;min-width:100%;height:100%;margin:0;padding:${s.padding}px}section{margin-bottom:0;margin-block-end:4em;max-inline-size:100%}h1{margin:0 0 0 1.8em}p{margin:0 0 0 1em}.byline{margin:0 0 0 40px}.end{border-top:0;border-right:1px solid ${t.line};padding:0 24px 0 0}` : '@media(max-width:480px){.book{padding:24px;}}'}
50 [hidden]{display:none!important}header{gap:10px}.reader-tools{display:flex;align-items:center;gap:12px;flex-shrink:0}button,select{font:12px ${fonts.sans};color:${t.muted};border:1px solid ${t.line};background:${t.bg};border-radius:6px;padding:7px 10px;cursor:pointer}button:disabled{opacity:.35;cursor:default}button:focus-visible,a:focus-visible,summary:focus-visible,select:focus-visible{outline:2px solid ${t.muted};outline-offset:3px}#page-stage{position:absolute;inset:60px 0 64px;overflow:hidden;touch-action:pan-y;user-select:text}.page-spread{height:100%;width:100%;display:flex;justify-content:flex-start;position:relative;flex-direction:${s.writing === 'vertical' ? 'row-reverse' : 'row'}}.page-spread:has(.reader-page:nth-child(2)):after{content:'';position:absolute;inset:20px auto 20px 50%;width:1px;background:${t.line};pointer-events:none}.reader-page{flex-shrink:0;background:${t.bg};display:grid;place-items:center}.page-content{font-family:${fonts[s.font]};font-size:${s.fontSize}px;line-height:${s.lineHeight};letter-spacing:${s.letterSpacing}px;text-align:${s.align};overflow:hidden;overflow-wrap:anywhere;display:flow-root;${s.writing === 'vertical' ? 'writing-mode:vertical-rl;text-orientation:mixed;' : ''}}.page-content h1{margin:0 0 1em;font-size:1.65em;line-height:1.5;font-weight:500}.page-content p{margin:0 0 1em;white-space:pre-wrap;${s.indent ? 'text-indent:1em;' : ''}}.page-content [data-continued]{text-indent:0}.page-content .byline{margin:0 0 2em}.page-content .end{margin-top:1em}.page-cache{position:fixed;left:-100000px;top:0;width:1px;height:1px;overflow:hidden;visibility:hidden;pointer-events:none}${s.writing === 'vertical' ? '.page-content h1{margin:0 0 0 1em}.page-content p{margin:0 0 0 1em}.page-content .byline{margin:0 0 0 2em}' : ''}#page-controls{position:absolute;inset:auto 0 0;height:64px;${s.writing === 'vertical' ? 'flex-direction:row-reverse;' : ''}border-top:1px solid ${t.line};display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 24px;background:${t.bg};color:${t.muted};font-size:11px}.page-progress{display:flex;align-items:center;justify-content:center;gap:10px;min-width:0}progress{width:80px;height:3px;accent-color:${t.muted}}#page-status{white-space:nowrap;font-variant-numeric:tabular-nums}#reading-percent{font-variant-numeric:tabular-nums}@media(max-width:600px){header{padding:0 14px}.reader-tools{gap:8px}header>span{max-width:28%}header button,header select{font-size:10px;padding:7px 6px}#page-controls{padding:0 12px;gap:6px}.page-progress{gap:7px}.page-progress progress{display:none}#reading-percent{display:none}}@media(prefers-reduced-motion:reduce){.scroll{scroll-behavior:auto}}
51 </style></head><body data-book="${escapeHTML(p.id)}" data-writing="${s.writing}" data-reading="${s.reading}" data-padding="${s.padding}" data-width="${s.width}"><header><span>${escapeHTML(p.title)}${p.author ? ' · ' + escapeHTML(p.author) : ''}</span><div class="reader-tools"><button id="reading-mode" type="button" aria-pressed="${s.reading === 'pages'}">${s.reading === 'pages' ? '스크롤 읽기' : '책처럼 읽기'}</button><select id="page-layout" aria-label="페이지 보기" ${s.reading === 'pages' ? '' : 'hidden'}>${[['auto','자동 펼침'],['single','한 쪽 보기'],['double','두 쪽 보기']].map(([value,label])=>`<option value="${value}" ${s.spread === value ? 'selected' : ''}>${label}</option>`).join('')}</select><details><summary>목차</summary><nav>${chapters.map((c, i) => `<a href="#chapter-${i}">${escapeHTML(c.title || '제목 없는 글')}</a>`).join('')}</nav></details></div></header><main class="scroll"><div class="book">${chapters.map((c, i) => `<section id="chapter-${i}"><h1>${escapeHTML(c.title || '제목 없는 글')}</h1>${i === 0 && p.author ? `<div class="byline">${escapeHTML(p.author)}</div>` : ''}${c.text.split(/\n\s*\n/).map(text => `<p>${escapeHTML(text)}</p>`).join('')}</section>`).join('')}<div class="end">${escapeHTML(p.title)} · 끝</div></div></main><div id="page-stage" ${s.reading === 'pages' ? '' : 'hidden'} aria-label="책 페이지"><div id="page-spread" class="page-spread"></div></div><footer id="page-controls" ${s.reading === 'pages' ? '' : 'hidden'}><button id="page-prev" type="button" aria-label="이전 페이지" disabled>← 이전</button><div class="page-progress"><span id="page-status" role="status" aria-live="polite">페이지 계산 중…</span><progress id="reading-progress" max="1" value="0" aria-label="독서 진행률"></progress><span id="reading-percent">0%</span></div><button id="page-next" type="button" aria-label="다음 페이지" disabled>다음 →</button></footer><script data-novel-runtime>${READER_SCRIPT}</script></body></html>`;
52 }
53