@lincyaw/web 1.0.0-rc.11 → 1.0.0-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/src/node/optimize.ts +65 -0
- package/src/node/site-build.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/web",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.13",
|
|
4
4
|
"description": "Presmium's web renderer: one prebuilt viewer that shows the web form in the workbench, in a dev server and as a built site, with search and form switching",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -41,21 +41,22 @@
|
|
|
41
41
|
"vite": "8.3.4",
|
|
42
42
|
"react": "18.3.1",
|
|
43
43
|
"react-dom": "18.3.1",
|
|
44
|
-
"
|
|
44
|
+
"sharp": "0.35.5",
|
|
45
|
+
"@lincyaw/design": "1.0.0-rc.13"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
|
-
"@lincyaw/host": "^1.0.0-rc.
|
|
48
|
-
"@lincyaw/viewer": "^1.0.0-rc.
|
|
49
|
-
"@lincyaw/kernel": "^1.0.0-rc.
|
|
48
|
+
"@lincyaw/host": "^1.0.0-rc.13",
|
|
49
|
+
"@lincyaw/viewer": "^1.0.0-rc.13",
|
|
50
|
+
"@lincyaw/kernel": "^1.0.0-rc.13"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@types/escape-html": "1.0.4",
|
|
53
54
|
"@types/send": "^1.2.1",
|
|
54
55
|
"@types/react": "18.3.28",
|
|
55
56
|
"@types/react-dom": "18.3.7",
|
|
56
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
57
|
-
"@lincyaw/
|
|
58
|
-
"@lincyaw/
|
|
59
|
-
"@lincyaw/
|
|
57
|
+
"@lincyaw/cli": "1.0.0-rc.13",
|
|
58
|
+
"@lincyaw/kernel": "1.0.0-rc.13",
|
|
59
|
+
"@lincyaw/viewer": "1.0.0-rc.13",
|
|
60
|
+
"@lincyaw/host": "1.0.0-rc.13"
|
|
60
61
|
}
|
|
61
62
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join, relative, sep } from 'node:path'
|
|
3
|
+
import sharp from 'sharp'
|
|
4
|
+
import { CODE_DIR } from './site-files.ts'
|
|
5
|
+
import { MEDIA } from './publish.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Raster images a site publishes (a document's media and the images its scenes import) are encoded again as WebP and
|
|
9
|
+
* no wider than any page shows them, when that saves at least a fifth: a paper's PNG figures and photographs are
|
|
10
|
+
* the largest files most pages load. References in the site's pages, data and scripts follow the new names; files
|
|
11
|
+
* the author publishes as they are (`public/`) are left alone.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const RASTER = new Set(['.png', '.jpg', '.jpeg'])
|
|
15
|
+
// Below this an image is small enough that a second encoding gains little.
|
|
16
|
+
const MIN_BYTES = 48 * 1024
|
|
17
|
+
// The widest an image is drawn: a full-width figure on a large screen at twice its pixels.
|
|
18
|
+
const MAX_WIDTH = 2400
|
|
19
|
+
const TEXT = new Set(['.html', '.json', '.js', '.css', '.xml', '.vtt'])
|
|
20
|
+
|
|
21
|
+
function files(dir: string): string[] {
|
|
22
|
+
if (!existsSync(dir)) return []
|
|
23
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? files(join(dir, entry.name)) : [join(dir, entry.name)])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A published image the build may encode again: in a media directory or among a code bundle's assets. */
|
|
27
|
+
function published(outDir: string, file: string): boolean {
|
|
28
|
+
const parts = relative(outDir, file).split(sep)
|
|
29
|
+
return parts.includes(MEDIA) || parts.some((part, i) => part === CODE_DIR && parts[i + 2] === 'assets')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function optimizeImages(outDir: string): Promise<{ images: number, saved: number }> {
|
|
33
|
+
const all = files(outDir)
|
|
34
|
+
const renamed = new Map<string, string>()
|
|
35
|
+
let saved = 0
|
|
36
|
+
for (const file of all) {
|
|
37
|
+
if (!RASTER.has(extname(file).toLowerCase()) || !published(outDir, file)) continue
|
|
38
|
+
const size = statSync(file).size
|
|
39
|
+
if (size < MIN_BYTES) continue
|
|
40
|
+
const target = join(dirname(file), `${basename(file, extname(file))}.webp`)
|
|
41
|
+
if (existsSync(target)) {
|
|
42
|
+
renamed.set(basename(file), basename(target))
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
const lossless = extname(file).toLowerCase() === '.png'
|
|
46
|
+
const image = sharp(file).rotate().resize({ width: MAX_WIDTH, withoutEnlargement: true })
|
|
47
|
+
// Figures and screenshots keep their sharp edges (near-lossless); photographs take ordinary lossy compression.
|
|
48
|
+
const bytes = await (lossless ? image.webp({ nearLossless: true, quality: 75, effort: 5 }) : image.webp({ quality: 80, effort: 5 })).toBuffer()
|
|
49
|
+
if (bytes.length > size * 0.8) continue
|
|
50
|
+
// The original stays beside it: a reference this pass cannot see (an encoded name) still finds a file.
|
|
51
|
+
writeFileSync(target, bytes)
|
|
52
|
+
renamed.set(basename(file), basename(target))
|
|
53
|
+
saved += size - bytes.length
|
|
54
|
+
}
|
|
55
|
+
if (renamed.size > 0) {
|
|
56
|
+
const names = new RegExp([...renamed.keys()].map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g')
|
|
57
|
+
for (const file of all) {
|
|
58
|
+
if (!TEXT.has(extname(file)) || !existsSync(file)) continue
|
|
59
|
+
const text = readFileSync(file, 'utf8')
|
|
60
|
+
const next = text.replace(names, name => renamed.get(name)!)
|
|
61
|
+
if (next !== text) writeFileSync(file, next)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { images: renamed.size, saved }
|
|
65
|
+
}
|
package/src/node/site-build.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { writeServiceWorker } from './offline.ts'
|
|
|
14
14
|
import { publishedDocument, publishedFile } from './publish.ts'
|
|
15
15
|
import { APP_DIR, CODE_DIR, contentKey, copyApp, copyPublic, copyRuntimeAssets, runtimeAssets, searchIndex } from './site-files.ts'
|
|
16
16
|
import { documentBase, documentPage, documentSiteConfig, homeDocument, homeDocuments, isSingle, landingConfig, landingDocument, languageLabel, LIST_PAGE, siteLanguage, siteLanguages, siteNeighbours, siteShellBelow, siteTitle, sitemapPages, translationOf } from './site-model.ts'
|
|
17
|
+
import { optimizeImages } from './optimize.ts'
|
|
17
18
|
import { publishedSite } from './site-assets.ts'
|
|
18
19
|
import { themedSite } from './site-theme.ts'
|
|
19
20
|
import { loadLanding, publishLanding, siteContent } from './landing.ts'
|
|
@@ -274,6 +275,11 @@ export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: r
|
|
|
274
275
|
])
|
|
275
276
|
await writeSitemap(join(ctx.outDir, 'sitemap.xml'), sitemap.hostname, ctx.base, pages)
|
|
276
277
|
}
|
|
278
|
+
await reportOptimized(ctx, await optimizeImages(ctx.outDir))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function reportOptimized(ctx: RenderContext, { images, saved }: { images: number, saved: number }): Promise<void> {
|
|
282
|
+
if (images > 0) ctx.log?.(`${images} images encoded as WebP, ${Math.round(saved / 1024)} KB smaller`)
|
|
277
283
|
}
|
|
278
284
|
|
|
279
285
|
/** The animation libraries a scene engine loads when its first scene is made, by the start of their file names. */
|
|
@@ -329,4 +335,5 @@ export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodeP
|
|
|
329
335
|
}
|
|
330
336
|
const preload = code ? scenePreloads(join(ctx.outDir, CODE_DIR, code), `${ctx.base}${CODE_DIR}/${code}/`, built) : []
|
|
331
337
|
writeFileSync(join(ctx.outDir, 'index.html'), pageHtml({ entry: appEntry(built.app), appUrl: built.appUrl, shared: built.app.manifest.shared, config, title: doc.meta.title, preload }))
|
|
338
|
+
await reportOptimized(ctx, await optimizeImages(ctx.outDir))
|
|
332
339
|
}
|