Skip to content
Merged
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ Bu kontrol, build çıktısında uzak script/style/module bağlantısı kalmadı

---

## PWA ve çevrimdışı kullanım

GitHub Pages sürümü installable PWA olarak yayınlanır. Manifest, uygulama ikonları ve service worker build sırasında `dist/` içine dahil edilir.

- `start_url` ve `scope` relative (`./`) tutulduğu için proje `/mathnote/` gibi GitHub Pages alt yollarında çalışır.
- İlk başarılı ziyarette uygulama shell'i, JavaScript/CSS, KaTeX fontları, manifest ve ikonlar precache edilir.
- Yeni build'de dosya içeriğinden yeni bir cache sürümü üretilir; service worker aktive olduğunda eski MathNote cache'leri temizlenir.
- Service worker yalnızca HTTP/HTTPS üzerinde kaydolur. Bu nedenle `dist/index.html` dosyasını doğrudan `file://` ile açma desteği korunur.
- Destekleyen tarayıcılar MathNote'u masaüstüne veya ana ekrana bağımsız uygulama olarak kurabilir.

---

## GitHub Pages

Repo, GitHub Pages üzerinde kaynak `index.html` dosyasını doğrudan yayınlamak yerine GitHub Actions ile build edilir. `.github/workflows/pages.yml` workflow'u:
Expand Down
6 changes: 6 additions & 0 deletions index.html

Large diffs are not rendered by default.

Binary file added public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "MathNote – Interactive Mathematics Notebook",
"short_name": "MathNote",
"description": "Offline-capable interactive mathematics notebook",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1971c2",
"orientation": "any",
"icons": [
{
"src": "./icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
51 changes: 48 additions & 3 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { build } from 'esbuild';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { relative } from 'node:path';

const outdir = new URL('../dist/', import.meta.url);
const templateUrl = new URL('../index.html', import.meta.url);
const publicDir = new URL('../public/', import.meta.url);
const serviceWorkerTemplateUrl = new URL('../src/sw.js', import.meta.url);

await rm(outdir, { recursive: true, force: true });
await mkdir(outdir, { recursive: true });
Expand All @@ -25,14 +29,16 @@ await build({
}
});

await cp(publicDir, outdir, { recursive: true });

let html = await readFile(templateUrl, 'utf8');
html = html.replace(
'<!-- MATHNOTE_BUILD_CSS -->',
'<link rel="stylesheet" href="./app.css">'
);
html = html.replace(
'<!-- MATHNOTE_BUILD_JS -->',
'<script defer src="./app.js"><\/script>'
'<script defer src="./app.js"><\\/script>'
);

if (html.includes('MATHNOTE_BUILD_')) {
Expand All @@ -41,4 +47,43 @@ if (html.includes('MATHNOTE_BUILD_')) {

await writeFile(new URL('../dist/index.html', import.meta.url), html, 'utf8');
await writeFile(new URL('../dist/.nojekyll', import.meta.url), '', 'utf8');
console.log('MathNote offline bundle written to dist/.');

async function collectFiles(dirUrl, rootUrl = dirUrl) {
const entries = await readdir(dirUrl, { withFileTypes: true });
const files = [];

for (const entry of entries) {
const childUrl = new URL(entry.name + (entry.isDirectory() ? '/' : ''), dirUrl);
if (entry.isDirectory()) {
files.push(...await collectFiles(childUrl, rootUrl));
continue;
}

if (entry.name === 'sw.js' || entry.name === '.nojekyll') continue;

files.push({
url: childUrl,
path: './' + relative(rootUrl.pathname, childUrl.pathname).replaceAll('\\\\', '/')
});
}

return files;
}

const precacheFiles = (await collectFiles(outdir)).sort((a, b) => a.path.localeCompare(b.path));
const hash = createHash('sha256');

for (const file of precacheFiles) {
hash.update(file.path);
hash.update(await readFile(file.url));
}

const cacheName = 'mathnote-' + hash.digest('hex').slice(0, 12);
let serviceWorker = await readFile(serviceWorkerTemplateUrl, 'utf8');
serviceWorker = serviceWorker
.replace('__CACHE_NAME__', cacheName)
.replace('__PRECACHE_ASSETS__', JSON.stringify(precacheFiles.map(file => file.path), null, 2));

await writeFile(new URL('../dist/sw.js', import.meta.url), serviceWorker, 'utf8');

console.log(`MathNote offline/PWA bundle written to dist/ using cache ${cacheName}.`);
47 changes: 42 additions & 5 deletions scripts/verify-offline.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { readdir, readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { extname } from 'node:path';

const root = new URL('../dist/', import.meta.url);
const required = ['index.html', 'app.js', 'app.css'];
const required = [
'index.html',
'app.js',
'app.css',
'manifest.webmanifest',
'sw.js',
'icons/icon-192.png',
'icons/icon-512.png'
];

for (const name of required) {
await readFile(new URL(name, root));
}

const textExtensions = new Set(['.html', '.js', '.css', '.json', '.svg', '.txt']);
const textExtensions = new Set(['.html', '.js', '.css', '.json', '.webmanifest', '.svg', '.txt']);
const violations = [];
let fontCount = 0;

Expand Down Expand Up @@ -48,14 +56,43 @@ for (const forbidden of ['cdn.jsdelivr.net', 'esm.sh', 'type="module"', 'type="i
if (html.includes(forbidden)) violations.push(`index.html: contains ${forbidden}`);
}

if (!html.includes('rel="manifest" href="./manifest.webmanifest"')) {
violations.push('index.html: manifest link missing or not relative');
}

const app = await readFile(new URL('app.js', root), 'utf8');
if (!app.includes("register('./sw.js'") && !app.includes('register("./sw.js"')) {
violations.push('app.js: relative service worker registration missing');
}

const manifest = JSON.parse(await readFile(new URL('manifest.webmanifest', root), 'utf8'));
if (manifest.start_url !== './') violations.push('manifest: start_url must be ./');
if (manifest.scope !== './') violations.push('manifest: scope must be ./');
if (manifest.display !== 'standalone') violations.push('manifest: display must be standalone');

const iconSizes = new Set((manifest.icons || []).map(icon => icon.sizes));
if (!iconSizes.has('192x192')) violations.push('manifest: 192x192 icon missing');
if (!iconSizes.has('512x512')) violations.push('manifest: 512x512 icon missing');

const sw = await readFile(new URL('sw.js', root), 'utf8');
if (sw.includes('__CACHE_NAME__') || sw.includes('__PRECACHE_ASSETS__')) {
violations.push('sw.js: build placeholders were not replaced');
}
if (!/mathnote-[a-f0-9]{12}/.test(sw)) {
violations.push('sw.js: content-derived cache version missing');
}
for (const asset of ['./index.html', './app.js', './app.css', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png']) {
if (!sw.includes(asset)) violations.push(`sw.js: precache entry missing for ${asset}`);
}

if (fontCount === 0) {
violations.push('KaTeX fonts were not emitted into dist/.');
}

if (violations.length) {
console.error('Offline verification failed:');
console.error('Offline/PWA verification failed:');
for (const item of violations) console.error(`- ${item}`);
process.exit(1);
}

console.log(`Offline verification passed. ${fontCount} local font asset(s) found and no remote runtime dependency detected.`);
console.log(`Offline/PWA verification passed. ${fontCount} local font asset(s), install manifest and versioned service worker verified.`);
20 changes: 19 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4598,4 +4598,22 @@ document.getElementById('insert-modal').addEventListener('click', e => {
// ================================================================

renderTabs();
initEditor(files[activeIdx].content);
initEditor(files[activeIdx].content);

// ================================================================
// PWA / SERVICE WORKER
// ================================================================

function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
if (!['http:', 'https:'].includes(window.location.protocol)) return;

window.addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js', { scope: './' })
.then(registration => registration.update().catch(() => {}))
.catch(error => console.warn('MathNote service worker registration failed:', error));
});
}

registerServiceWorker();
53 changes: 53 additions & 0 deletions src/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const CACHE_PREFIX = 'mathnote-';
const CACHE_NAME = '__CACHE_NAME__';
const PRECACHE_ASSETS = __PRECACHE_ASSETS__;
const APP_SHELL = new URL('./index.html', self.registration.scope).toString();

const scopedUrl = path => new URL(path, self.registration.scope).toString();

self.addEventListener('install', event => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS.map(scopedUrl)))
.then(() => self.skipWaiting())
);
});

self.addEventListener('activate', event => {
event.waitUntil(
caches
.keys()
.then(keys =>
Promise.all(
keys
.filter(key => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.map(key => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});

self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET') return;

const url = new URL(request.url);
if (url.origin !== self.location.origin) return;

if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then(response => response)
.catch(() => caches.match(APP_SHELL))
);
return;
}

event.respondWith(
caches
.match(request, { ignoreSearch: true })
.then(cached => cached || fetch(request))
);
});
Loading