From 17164f42d0081640c695f4ef5751b8a357e61a75 Mon Sep 17 00:00:00 2001
From: Cell <1024@lruihao.cn>
Date: Sun, 31 May 2026 17:54:02 +0800
Subject: [PATCH] fix(assets): rewrite service worker with two-strategy caching
(#774)
- Replace legacy service-worker.js (imported from DoIt) with a clean
rewrite using Hugo's ExecuteAsTemplate for fingerprinted URL injection
- Cache-first for static assets (immutable/fingerprinted), network-first
for HTML pages with LRU eviction (max 100 entries)
- Fix LRU eviction to protect precached URLs using absolute URL comparison
- Add Fingerprint param to js-build.html partial to reduce boilerplate
- Extract to-css.html partial for reusable SCSS-to-CSS pipeline
- Support pre-built Resource param in script.html and style.html
- Store mainCSS and mainJS in Site.Store to avoid duplicate builds
- Remove explicit scope from serviceWorker.register() (defaults to /)
Closes #298
---
.vscode/settings.json | 2 +
assets/js/modules/misc.ts | 2 +-
assets/js/service-worker.js | 267 -----------------------
assets/js/service-worker.template.js | 165 ++++++++++++++
eslint.config.js | 3 +-
layouts/_partials/base/assets.html | 23 +-
layouts/_partials/base/head/index.html | 17 +-
layouts/_partials/function/js-build.html | 4 +
layouts/_partials/function/to-css.html | 35 +++
layouts/_partials/plugin/script.html | 29 ++-
layouts/_partials/plugin/style.html | 63 +++---
11 files changed, 278 insertions(+), 332 deletions(-)
delete mode 100644 assets/js/service-worker.js
create mode 100644 assets/js/service-worker.template.js
create mode 100644 layouts/_partials/function/to-css.html
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b039f432..ad679cf9 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,6 +9,8 @@
"vercel.json": "build.sh"
},
"files.associations": {
+ "*.template.ts": "gots",
+ "*.template.js": "gojs",
"*.template.scss": "gocss",
"*.template.svg": "html",
"**/plugin/script.html": "gohtml",
diff --git a/assets/js/modules/misc.ts b/assets/js/modules/misc.ts
index 4ab4866b..98617b27 100644
--- a/assets/js/modules/misc.ts
+++ b/assets/js/modules/misc.ts
@@ -53,7 +53,7 @@ export class MiscModule implements MiscService {
initServiceWorker() {
if (this.core.config.PWA?.enable && 'serviceWorker' in navigator) {
navigator.serviceWorker
- .register(this.core.config.PWA.serviceWorkerURL, { scope: '/' })
+ .register(this.core.config.PWA.serviceWorkerURL)
.then((_registration) => {
// console.log('Service Worker Registered');
})
diff --git a/assets/js/service-worker.js b/assets/js/service-worker.js
deleted file mode 100644
index d3d41688..00000000
--- a/assets/js/service-worker.js
+++ /dev/null
@@ -1,267 +0,0 @@
-/**
- * Service Worker
- * imported from https://github.com/HEIGE-PCloud/DoIt/blob/v0.2.11/src/js/sw.js
- * [todo] rewrite with TypeScript and fixes issue #298
- */
-const CACHE_VERSION = 1;
-
-const BASE_CACHE_FILES = [
- '/css/style.min.css',
- '/js/theme.min.js',
- '/site.webmanifest',
-];
-
-const OFFLINE_CACHE_FILES = [
- '/css/style.min.css',
- '/js/theme.min.js',
- '/site.webmanifest',
- '/offline/'
-];
-
-const NOT_FOUND_CACHE_FILES = [
- '/css/style.min.css',
- '/js/theme.min.js',
- '/site.webmanifest',
- '/404.html'
-];
-
-const OFFLINE_PAGE = '/offline/';
-const NOT_FOUND_PAGE = '/404.html';
-
-const CACHE_VERSIONS = {
- assets: 'assets-v' + CACHE_VERSION,
- content: 'content-v' + CACHE_VERSION,
- offline: 'offline-v' + CACHE_VERSION,
- notFound: '404-v' + CACHE_VERSION
-};
-
-// Define MAX_TTL's in SECONDS for specific file extensions
-const MAX_TTL = {
- '/': 3600,
- html: 3600,
- json: 86400,
- js: 86400,
- css: 86400
-};
-
-const CACHE_BLACKLIST = [
- (str) => {
- return !str.startsWith('http://localhost');
- }
-];
-
-const SUPPORTED_METHODS = ['GET'];
-
-/**
- * isBlackListed
- * @param {string} url
- * @returns {boolean}
- */
-function isBlacklisted(url) {
- return CACHE_BLACKLIST.length > 0
- ? !CACHE_BLACKLIST.filter((rule) => {
- if (typeof rule === 'function') {
- return !rule(url);
- } else {
- return false;
- }
- }).length
- : false;
-}
-
-/**
- * getFileExtension
- * @param {string} url
- * @returns {string}
- */
-function getFileExtension(url) {
- const extension = url.split('.').reverse()[0].split('?')[0];
- return extension.endsWith('/') ? '/' : extension;
-}
-
-/**
- * getTTL
- * @param {string} url
- */
-function getTTL(url) {
- if (typeof url === 'string') {
- const extension = getFileExtension(url);
- if (typeof MAX_TTL[extension] === 'number') {
- return MAX_TTL[extension];
- } else {
- return null;
- }
- } else {
- return null;
- }
-}
-
-/**
- * installServiceWorker
- * @returns {Promise}
- */
-function installServiceWorker() {
- return Promise.all([
- caches.open(CACHE_VERSIONS.assets).then((cache) => {
- return cache.addAll(BASE_CACHE_FILES);
- }),
- caches.open(CACHE_VERSIONS.offline).then((cache) => {
- return cache.addAll(OFFLINE_CACHE_FILES);
- }),
- caches.open(CACHE_VERSIONS.notFound).then((cache) => {
- return cache.addAll(NOT_FOUND_CACHE_FILES);
- })
- ]).then(() => {
- return self.skipWaiting();
- });
-}
-
-/**
- * cleanupLegacyCache
- * @returns {Promise}
- */
-function cleanupLegacyCache() {
- const currentCaches = Object.keys(CACHE_VERSIONS).map((key) => {
- return CACHE_VERSIONS[key];
- });
-
- return new Promise((resolve, reject) => {
- caches
- .keys()
- .then((keys) => {
- return keys.filter((key) => {
- return !~currentCaches.indexOf(key);
- });
- })
- .then((legacy) => {
- if (legacy.length) {
- Promise.all(
- legacy.map((legacyKey) => {
- return caches.delete(legacyKey);
- })
- )
- .then(() => {
- resolve();
- })
- .catch((err) => {
- reject(err);
- });
- } else {
- resolve();
- }
- })
- .catch((err) => {
- reject(err);
- });
- });
-}
-
-self.addEventListener('install', (event) => {
- event.waitUntil(Promise.all([installServiceWorker(), self.skipWaiting()]));
-});
-
-// The activate handler takes care of cleaning up old caches.
-self.addEventListener('activate', (event) => {
- event.waitUntil(
- Promise.all([
- cleanupLegacyCache(),
- self.clients.claim(),
- self.skipWaiting()
- ]).catch((err) => {
- console.warn(err);
- self.skipWaiting();
- })
- );
-});
-
-self.addEventListener('fetch', (event) => {
- event.respondWith(
- caches.open(CACHE_VERSIONS.content).then((cache) => {
- return cache
- .match(event.request)
- .then((response) => {
- if (response) {
- const headers = response.headers.entries();
- let date = null;
-
- for (const pair of headers) {
- if (pair[0] === 'date') {
- date = new Date(pair[1]);
- }
- }
- if (date) {
- const age = parseInt(
- (new Date().getTime() - date.getTime()) / 1000
- );
- const ttl = getTTL(event.request.url);
-
- if (ttl && age > ttl) {
- return new Promise((resolve) => {
- return fetch(event.request.clone())
- .then((updatedResponse) => {
- if (updatedResponse) {
- cache.put(event.request, updatedResponse.clone());
- resolve(updatedResponse);
- } else {
- resolve(response);
- }
- })
- .catch(() => {
- resolve(response);
- });
- }).catch((err) => {
- console.warn(err);
- return response;
- });
- } else {
- return response;
- }
- } else {
- return response;
- }
- } else {
- return null;
- }
- })
- .then((response) => {
- if (response) {
- return response;
- } else {
- return fetch(event.request.clone())
- .then((response) => {
- if (response.status < 400) {
- if (
- ~SUPPORTED_METHODS.indexOf(event.request.method) &&
- !isBlacklisted(event.request.url) &&
- event.request.url.slice(0, 4) === 'http'
- ) {
- cache.put(event.request, response.clone());
- }
- return response;
- } else {
- return caches.open(CACHE_VERSIONS.notFound).then((cache) => {
- return cache.match(NOT_FOUND_PAGE);
- });
- }
- })
- .then((response) => {
- if (response) {
- return response;
- }
- })
- .catch(() => {
- return caches
- .open(CACHE_VERSIONS.offline)
- .then((offlineCache) => {
- return offlineCache.match(OFFLINE_PAGE);
- });
- });
- }
- })
- .catch((error) => {
- console.error(' Error in fetch handler:', error);
- throw error;
- });
- })
- );
-});
diff --git a/assets/js/service-worker.template.js b/assets/js/service-worker.template.js
new file mode 100644
index 00000000..e1474fda
--- /dev/null
+++ b/assets/js/service-worker.template.js
@@ -0,0 +1,165 @@
+/* eslint-disable no-restricted-globals -- `self` is the standard Service Worker global */
+
+/**
+ * Service Worker
+ * @description Two-strategy caching with the native Cache API:
+ * - Static assets (fingerprinted CSS/JS): cache-first, immutable
+ * - HTML pages: network-first, fallback to cache when offline
+ * Asset URLs are injected at build time via Hugo's ExecuteAsTemplate,
+ * ensuring they match fingerprinted paths when fingerprint is enabled.
+ * @see https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook
+ */
+
+/* ========== Constants ========== */
+
+const CACHE_NAME = 'fixit-v1'
+
+/** URLs to pre-cache during the install event (injected by Hugo). */
+const PRECACHE_URLS = [
+ '{{ .mainCSSURL }}',
+ '{{ .mainJSURL }}',
+ '{{ .relURL }}site.webmanifest',
+ '{{ .offlineURL }}',
+ '{{ .relURL }}404.html',
+]
+
+const OFFLINE_PAGE = '{{ .offlineURL }}'
+const NOT_FOUND_PAGE = '{{ .relURL }}404.html'
+
+/** Maximum number of HTML pages to keep in cache (LRU eviction). */
+const MAX_HTML_CACHE_ENTRIES = 100
+
+/** Static asset extensions — these are fingerprinted by Hugo and safe to cache forever. */
+const STATIC_EXTENSIONS = ['css', 'js', 'woff2', 'woff', 'ttf', 'eot', 'svg', 'webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'ico']
+
+/* ========== Helpers ========== */
+
+/** Cached cache instance — avoids repeated caches.open() calls. */
+const cachePromise = caches.open(CACHE_NAME)
+
+/**
+ * Check if a URL is a same-origin static asset (fingerprinted by Hugo).
+ * Returns false for HTML pages and extensionless paths.
+ */
+function isStaticAsset(url) {
+ if (!url.startsWith(self.location.origin))
+ return false
+ const pathname = new URL(url).pathname
+ const dotIndex = pathname.lastIndexOf('.')
+ if (dotIndex === -1)
+ return false
+ const ext = pathname.slice(dotIndex + 1).toLowerCase()
+ return STATIC_EXTENSIONS.includes(ext)
+}
+
+/**
+ * Evict oldest HTML cache entries when the limit is exceeded.
+ * Operates on cache insertion order (Cache API preserves insertion order for keys()).
+ * Skips pre-cached URLs (offline/404 pages) to ensure they are never evicted.
+ */
+async function evictOldEntries() {
+ const cache = await cachePromise
+ const keys = await cache.keys()
+ // PRECACHE_URLS contains relative paths from Hugo; resolve them to absolute URLs for comparison.
+ const precacheSet = new Set(PRECACHE_URLS.map(url => new URL(url, self.location.origin).href))
+ const htmlKeys = keys.filter(req => !isStaticAsset(req.url) && !precacheSet.has(req.url))
+ const excess = htmlKeys.length - MAX_HTML_CACHE_ENTRIES
+ if (excess > 0) {
+ await Promise.all(htmlKeys.slice(0, excess).map(req => cache.delete(req)))
+ }
+}
+
+/* ========== Lifecycle ========== */
+
+/** Pre-cache core assets on install (individual calls for resilience). */
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ cachePromise
+ .then(cache => Promise.allSettled(
+ PRECACHE_URLS.filter(Boolean).map(url => cache.add(url)),
+ ))
+ .then(() => self.skipWaiting()),
+ )
+})
+
+/** Clean up old caches, evict stale HTML entries, and take control immediately. */
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys()
+ .then(keys => Promise.all(
+ keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)),
+ ))
+ .then(() => evictOldEntries())
+ .then(() => self.clients.claim()),
+ )
+})
+
+/* ========== Fetch Strategies ========== */
+
+/** Route same-origin GET requests to the appropriate caching strategy. */
+self.addEventListener('fetch', (event) => {
+ const { request } = event
+
+ // Cache API only supports GET
+ if (request.method !== 'GET' || !request.url.startsWith(self.location.origin))
+ return
+
+ event.respondWith(
+ isStaticAsset(request.url)
+ ? cacheFirst(request)
+ : networkFirst(request),
+ )
+})
+
+/**
+ * Cache-first strategy for fingerprinted static assets.
+ * These are immutable — a content change produces a new URL,
+ * so cached responses are always valid.
+ */
+async function cacheFirst(request) {
+ const cache = await cachePromise
+ const cached = await cache.match(request)
+ if (cached)
+ return cached
+
+ try {
+ const response = await fetch(request)
+ if (response.ok) {
+ cache.put(request, response.clone())
+ }
+ return response
+ }
+ catch {
+ return new Response('Offline', { status: 503 })
+ }
+}
+
+/**
+ * Network-first strategy for HTML pages.
+ * Always tries the network for fresh content, falls back to cache on failure.
+ */
+async function networkFirst(request) {
+ try {
+ const response = await fetch(request)
+ if (response.ok) {
+ const cache = await cachePromise
+ cache.put(request, response.clone())
+ // Evict oldest HTML entries to keep cache bounded
+ evictOldEntries()
+ }
+ // Server error (4xx/5xx) — try 404 page from cache
+ if (response.status >= 400) {
+ return (await caches.match(NOT_FOUND_PAGE)) || response
+ }
+ return response
+ }
+ catch {
+ // Network failure — try cached page, then offline fallback
+ try {
+ return (await caches.match(request)) || (await caches.match(OFFLINE_PAGE)) || new Response('Offline', { status: 503 })
+ }
+ catch {
+ return new Response('Offline', { status: 503 })
+ }
+ }
+}
diff --git a/eslint.config.js b/eslint.config.js
index 8c276afc..1ea744bb 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -9,7 +9,6 @@ export default antfu({
'layouts/**/*.json',
'layouts/**/*.xml',
'layouts/**/*.md',
- // ignore temporarily
- 'assets/js/service-worker.js',
+ '**/*.template.*',
],
})
diff --git a/layouts/_partials/base/assets.html b/layouts/_partials/base/assets.html
index 28429040..58433099 100644
--- a/layouts/_partials/base/assets.html
+++ b/layouts/_partials/base/assets.html
@@ -290,12 +290,16 @@
{{- /* PWA */ -}}
{{- if not hugo.IsServer | and .Site.Params.enablePWA | and hugo.IsProduction -}}
- {{- $serviceWorker := dict "Resource" (resources.Get "js/service-worker.js") "Build" true | partial "function/js-build.html" -}}
- {{- with $fingerprint -}}
- {{- $serviceWorker = $serviceWorker | fingerprint . -}}
+ {{- $offlineURL := "" -}}
+ {{- with .Site.Home.OutputFormats.Get "offline" -}}
+ {{- $offlineURL = .RelPermalink -}}
{{- end -}}
- {{- $serviceWorkerURL := $serviceWorker.RelPermalink -}}
- {{- $config = dict "PWA" (dict "enable" .Site.Params.enablePWA "serviceWorkerURL" $serviceWorkerURL) | merge $config -}}
+ {{- $mainCSS := .Site.Store.Get "mainCSS" -}}
+ {{- $mainJS := .Site.Store.Get "mainJS" -}}
+ {{- $swBuildCtx := dict "mainCSSURL" $mainCSS.RelPermalink "mainJSURL" $mainJS.RelPermalink "offlineURL" $offlineURL "relURL" (relURL "") "Page" . -}}
+ {{- $serviceWorker := resources.Get "js/service-worker.template.js" | resources.ExecuteAsTemplate "js/service-worker.template.js" $swBuildCtx -}}
+ {{- $serviceWorker = dict "Resource" $serviceWorker "Build" (dict "targetPath" "sw.js") "Fingerprint" $fingerprint | partial "function/js-build.html" -}}
+ {{- $config = dict "PWA" (dict "enable" .Site.Params.enablePWA "serviceWorkerURL" $serviceWorker.RelPermalink) | merge $config -}}
{{- end -}}
{{- /* Auto Bookmark */ -}}
@@ -366,10 +370,7 @@
{{- $layoutLoaders := $mermaid.layoutloaders | default slice -}}
{{- $options := dict "format" "esm" -}}
- {{- $mermaidModule := dict "Resource" (resources.Get "js/lib/mermaid.ts") "Build" $options | partial "function/js-build.html" -}}
- {{- with $fingerprint -}}
- {{- $mermaidModule = $mermaidModule | fingerprint . -}}
- {{- end -}}
+ {{- $mermaidModule := dict "Resource" (resources.Get "js/lib/mermaid.ts") "Build" $options "Fingerprint" $fingerprint | partial "function/js-build.html" -}}
{{- $mermaidModuleURL := $mermaidModule.RelPermalink -}}
{{- $bootstrapJS := dict
@@ -384,8 +385,8 @@
{{- dict "Content" $bootstrapJS "Path" "js/lib/mermaid-bootstrap.js" "Minify" hugo.IsProduction "Attr" `type="module"` "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}}
{{- end -}}
-{{- /* Theme main bundle */ -}}
-{{- dict "Source" "js/main.ts" "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}}
+{{- /* Theme main bundle (built once in head/index.html) */ -}}
+{{- dict "Resource" (.Site.Store.Get "mainJS") "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}}
{{- /* Custom assets block */ -}}
{{- block "custom-assets" . }}{{ end -}}
diff --git a/layouts/_partials/base/head/index.html b/layouts/_partials/base/head/index.html
index 3d92a7ab..68ec12ed 100644
--- a/layouts/_partials/base/head/index.html
+++ b/layouts/_partials/base/head/index.html
@@ -75,8 +75,7 @@
{{- $title = .Site.Title -}}
{{- end -}}
-{{- /* Favicon links */ -}}
-{{/* [todo] 先判断有图片资源时才加载,没有时提示引导 https://realfavicongenerator.net/ */}}
+{{- /* Favicon links (static resources, generated via https://realfavicongenerator.net/) */ -}}
{{- if not .Site.Params.app.noFavicon -}}
{{- $relURL := relURL "" -}}
{{- with .Site.Params.app.svgFavicon -}}
@@ -125,12 +124,14 @@
-}}
{{- /* Theme main CSS */ -}}
-{{- dict
- "Source" "scss/main.scss"
+{{- $mainCSS := dict
+ "Resource" (resources.Get "scss/main.scss")
"ToCSS" true
"Fingerprint" $fingerprint
- | partial "plugin/style.html"
+ | partial "function/to-css.html"
-}}
+{{- .Site.Store.Set "mainCSS" $mainCSS -}}
+{{- dict "Resource" $mainCSS | partial "plugin/style.html" -}}
{{- /* Font Awesome Icons */ -}}
{{- $source := $cdn.fontawesomeFreeCSS | default "lib/fontawesome-free/all.min.css" -}}
@@ -143,10 +144,8 @@
{{- partial "plugin/style.html" $options -}}
{{- /* Preload main theme bundle */ -}}
-{{- $mainResource := dict "Resource" (resources.Get "js/main.ts") "Build" true | partial "function/js-build.html" -}}
-{{- with $fingerprint -}}
- {{- $mainResource = $mainResource | fingerprint . -}}
-{{- end -}}
+{{- $mainResource := dict "Resource" (resources.Get "js/main.ts") "Build" true "Fingerprint" $fingerprint | partial "function/js-build.html" -}}
+{{- .Site.Store.Set "mainJS" $mainResource -}}
{{- /* ========== SEO: Structured Data (Schema.org) ========== */ -}}
diff --git a/layouts/_partials/function/js-build.html b/layouts/_partials/function/js-build.html
index 426ec2bd..7fb1e532 100644
--- a/layouts/_partials/function/js-build.html
+++ b/layouts/_partials/function/js-build.html
@@ -3,6 +3,7 @@
@param {resource.Resource} .Resource Hugo resource to process
@param {Object|Boolean} [.Build] true for defaults, dict for custom options, false/omit to skip js.Build
@param {Boolean} [.Minify] extra minify flag; final minify is Minify OR Build.minify
+ @param {String} [.Fingerprint] fingerprint algorithm (e.g. "sha256")
@return {resource.Resource}
*/ -}}
@@ -25,4 +26,7 @@
{{- if $minify -}}
{{- $resource = $resource | minify -}}
{{- end -}}
+{{- with .Fingerprint -}}
+ {{- $resource = $resource | fingerprint . -}}
+{{- end -}}
{{- return $resource -}}
diff --git a/layouts/_partials/function/to-css.html b/layouts/_partials/function/to-css.html
new file mode 100644
index 00000000..b30a9bdf
--- /dev/null
+++ b/layouts/_partials/function/to-css.html
@@ -0,0 +1,35 @@
+{{- /*
+ Compile SCSS to CSS via dartsass, optionally minify and fingerprint.
+ @param {resource.Resource} .Resource SCSS resource
+ @param {Object|Boolean} [.ToCSS] true for defaults, dict for custom toCSS options, false/omit to skip
+ @param {Boolean} [.Minify] whether to minify; defaults to hugo.IsProduction when ToCSS is used
+ @param {String} [.Fingerprint] fingerprint algorithm (e.g. "sha256")
+ @return {resource.Resource}
+*/ -}}
+
+{{- $resource := .Resource -}}
+{{- $minify := .Minify -}}
+{{- with .ToCSS -}}
+ {{- $targetPath := replaceRE `\.(template\.)?scss$` ".css" $resource.Name -}}
+ {{- $targetPath = replace $targetPath "scss" "css" -}}
+ {{- $defaultOptions := dict
+ "transpiler" "dartsass"
+ "enableSourceMap" hugo.IsDevelopment
+ "outputStyle" (cond hugo.IsDevelopment "expanded" "compressed")
+ "targetPath" $targetPath
+ -}}
+ {{- $options := . -}}
+ {{- if eq $options true -}}
+ {{- $options = dict -}}
+ {{- end -}}
+ {{- $options = $options | merge $defaultOptions -}}
+ {{- $resource = $resource | toCSS $options -}}
+ {{- $minify = $minify | default hugo.IsProduction -}}
+{{- end -}}
+{{- if $minify -}}
+ {{- $resource = $resource | minify -}}
+{{- end -}}
+{{- with .Fingerprint -}}
+ {{- $resource = $resource | fingerprint . -}}
+{{- end -}}
+{{- return $resource -}}
diff --git a/layouts/_partials/plugin/script.html b/layouts/_partials/plugin/script.html
index f3c141df..4599b3f9 100644
--- a/layouts/_partials/plugin/script.html
+++ b/layouts/_partials/plugin/script.html
@@ -1,8 +1,9 @@
{{- /*
Script tag renderer.
- - Accepts direct script HTML or builds script source from local/remote resources.
+ - Accepts direct script HTML, a pre-built resource, or builds from source path.
- Supports template execution, minify/build/fingerprint pipeline, and extra attrs.
- @param {String|Resource} .Source script source URL/path or Hugo resource; if string starts with "