@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/web",
3
- "version": "1.0.0-rc.11",
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
- "@lincyaw/design": "1.0.0-rc.11"
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.11",
48
- "@lincyaw/viewer": "^1.0.0-rc.11",
49
- "@lincyaw/kernel": "^1.0.0-rc.11"
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.11",
57
- "@lincyaw/host": "1.0.0-rc.11",
58
- "@lincyaw/kernel": "1.0.0-rc.11",
59
- "@lincyaw/viewer": "1.0.0-rc.11"
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
+ }
@@ -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
  }