hexo-theme-linen 1.0.21 → 1.0.23

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.
Files changed (46) hide show
  1. package/languages/en.yml +3 -0
  2. package/languages/zh-CN.yml +4 -1
  3. package/layout/footer.ejs +62 -0
  4. package/layout/layout.ejs +22 -9
  5. package/layout/partials/header.ejs +1 -1
  6. package/layout/partials/post-item.ejs +5 -5
  7. package/layout/partials/related-posts.ejs +1 -1
  8. package/layout/post.ejs +362 -24
  9. package/layout/series.ejs +277 -4
  10. package/package.json +1 -1
  11. package/scripts/font-version.js +5 -3
  12. package/scripts/helpers/tools.js +187 -191
  13. package/scripts/lazyload/client.js +11 -2
  14. package/scripts/lazyload/index.js +7 -5
  15. package/scripts/lazyload/inject.js +8 -0
  16. package/scripts/lazyload/process.js +202 -163
  17. package/scripts/markdown-it-image-grid.js +74 -10
  18. package/source/linen-theme/css/{mediaquery.scss → _mediaquery.scss} +183 -12
  19. package/source/linen-theme/css/atom.xsl +37 -0
  20. package/source/linen-theme/css/font-version.scss +1 -1
  21. package/source/linen-theme/css/images/layers-2x.png +0 -0
  22. package/source/linen-theme/css/images/layers.png +0 -0
  23. package/source/linen-theme/css/images/marker-icon-2x.png +0 -0
  24. package/source/linen-theme/css/images/marker-icon.png +0 -0
  25. package/source/linen-theme/css/images/marker-shadow.png +0 -0
  26. package/source/linen-theme/css/leaflet.css +661 -0
  27. package/source/linen-theme/css/linen.scss +1198 -114
  28. package/source/linen-theme/css/linkcard.scss +53 -11
  29. package/source/linen-theme/css/noscript.css +70 -0
  30. package/source/linen-theme/css/photoswipe-dynamic-caption-plugin.css +1 -1
  31. package/source/linen-theme/css/photoswipe.css +166 -1
  32. package/source/linen-theme/css/rss-en.xsl +1 -1
  33. package/source/linen-theme/css/rss-zh.xsl +1 -1
  34. package/source/linen-theme/css/vars.scss +1 -1
  35. package/source/linen-theme/img/pattern-randomized.svg +1 -0
  36. package/source/linen-theme/img/travel-map-fallback.svg +30 -0
  37. package/source/linen-theme/js/hdr.js +42 -12
  38. package/source/linen-theme/js/lazyload.js +11 -2
  39. package/source/linen-theme/js/leaflet.js +6 -0
  40. package/source/linen-theme/js/linen.js +114 -83
  41. package/source/linen-theme/js/photoswipe/photoswipe.esm.min.js +13 -0
  42. package/source/linen-theme/js/replace-emoji.js +2 -4
  43. package/source/linen-theme/js/toc.js +71 -121
  44. package/layout/partials/footer.ejs +0 -12
  45. package/source/linen-theme/img/icons/backref.svg +0 -1
  46. package/source/linen-theme/img/icons/more.svg +0 -4
package/layout/series.ejs CHANGED
@@ -1,5 +1,50 @@
1
- <div class="series-layout">
2
- <% if (page.seriesInfo?.cover) { %>
1
+ <%
2
+ const isTravelSeries = ['旅行', 'Travel'].includes(page.series);
3
+ const parseMapCoordinate = (post) => {
4
+ if (post?.mapPlace?.coordinate?.length === 2) {
5
+ return { lat: Number(post.mapPlace.coordinate[0]), lng: Number(post.mapPlace.coordinate[1]) };
6
+ }
7
+
8
+ const mapUrl = post?.mapPlace?.url || post?.coverInfo?.url;
9
+ if (!mapUrl) return null;
10
+
11
+ try {
12
+ const url = new URL(mapUrl);
13
+ const value = url.searchParams.get('coordinate') || url.searchParams.get('ll');
14
+ if (!value) return null;
15
+
16
+ const [lat, lng] = value.split(',').map(Number);
17
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
18
+ return { lat, lng };
19
+ }
20
+ } catch (error) {
21
+ return null;
22
+ }
23
+
24
+ return null;
25
+ };
26
+ const travelPlaces = isTravelSeries ? page.seriesPosts.map((post, index) => {
27
+ const coordinate = parseMapCoordinate(post);
28
+ if (!coordinate) return null;
29
+ return {
30
+ id: `travel-place-${index}`,
31
+ title: post.title,
32
+ subTitle: post.subTitle || '',
33
+ path: url_for(post.path),
34
+ date: post.date && !date(post.date).startsWith('1970') ? getPostDate(post) : '',
35
+ location: post?.mapPlace?.location || post?.coverInfo?.location || post.title,
36
+ excerpt: post?.excerpt || '',
37
+ cover: post.thumbnail || post.cover || '',
38
+ mapUrl: post?.mapPlace?.url || post?.coverInfo?.url || '',
39
+ lat: coordinate.lat,
40
+ lng: coordinate.lng,
41
+ };
42
+ }).filter(Boolean) : [];
43
+ const travelPlacesJson = encodeURIComponent(JSON.stringify(travelPlaces));
44
+ %>
45
+
46
+ <div class="series-layout<%=isTravelSeries && travelPlaces.length ? ' travel-series-layout' : ''%>">
47
+ <% if (page.seriesInfo?.cover && !isTravelSeries) { %>
3
48
  <div class="series-cover-img-wrap" style="<%=page.seriesInfo?.coverPlaceholder ? `background-image: url(${page.seriesInfo?.coverPlaceholder})`: ''%>">
4
49
  <div class="series-cover-img-placeholder"></div>
5
50
  <script>
@@ -41,12 +86,239 @@
41
86
  </div>
42
87
  <% } %>
43
88
  </div>
44
- <% } else { %>
89
+ <% } else if (!isTravelSeries) { %>
45
90
  <h1><%=__('common.series') + ': ' + page.series %></h1>
46
91
  <% if (page?.seriesInfo?.description) { %>
47
92
  <div class="series-description"><%=page.seriesInfo.description||''%> <i class="post-count-icon"></i><%=__('common.total')%> <%=page?.seriesPosts?.length%> <%=__('common.seriesPostCount')%></div>
48
93
  <% } %>
49
94
  <% } %>
95
+
96
+ <% if (isTravelSeries && travelPlaces.length) { %>
97
+ <noscript>
98
+ <link rel="stylesheet" href="<%-url_for('/css/leaflet.css')%>" />
99
+ </noscript>
100
+ <section class="travel-map-section" aria-label="<%=page.series === 'Travel' ? 'Travel map' : '旅行地图'%>">
101
+ <div class="travel-map-panel">
102
+ <div class="travel-map-copy">
103
+ <div class="travel-map-eyebrow"><%=page.series === 'Travel' ? 'Travel Map' : '旅行地图'%></div>
104
+ <h2><%=page.series === 'Travel' ? 'Places I have been' : '去过的地方'%></h2>
105
+ <div class="travel-map-stats">
106
+ <span><%=travelPlaces.length%></span>
107
+ <%=page.series === 'Travel' ? 'mapped trips' : '次旅行足迹'%>
108
+ </div>
109
+ </div>
110
+ <div id="travel-map" class="travel-map-canvas" data-places="<%=travelPlacesJson%>">
111
+ <div class="travel-map-loading"><%=page.series === 'Travel' ? 'Loading map...' : '地图加载中...'%></div>
112
+ <noscript>
113
+ <img
114
+ class="travel-map-fallback-img"
115
+ src="<%=url_for('/linen-theme/img/travel-map-fallback.svg')%>"
116
+ alt="<%=page.series === 'Travel' ? 'Static travel map' : '旅行地图静态图'%>"
117
+ />
118
+ </noscript>
119
+ </div>
120
+ </div>
121
+ <div class="travel-place-list">
122
+ <% travelPlaces.forEach((place, index) => { %>
123
+ <article class="travel-place-card" data-travel-place="<%=place.id%>">
124
+ <a href="<%=place.path%>" class="travel-place-cover" aria-label="<%=place.title%>">
125
+ <span class="travel-place-index"><%=String(travelPlaces.length - index).padStart(2, '0')%></span>
126
+ <% if (place.cover) { %>
127
+ <div class="cover-img">
128
+ <img src="<%=place.cover%>" alt="" loading="lazy" />
129
+ </div>
130
+ <% } %>
131
+ </a>
132
+ <div class="travel-place-body">
133
+ <a class="travel-place-title" href="<%=place.path%>"><%=place.title%><% if (place.subTitle) { %><span class="sub-title"><%=place.subTitle%></span><% } %></a>
134
+ <div class="travel-place-meta"><%=place.location%><% if (place.date) { %><span><%=place.date%></span><% } %></div>
135
+ <div class="travel-place-excerpt"><%-place.excerpt%></div>
136
+ </div>
137
+ </article>
138
+ <% }) %>
139
+ </div>
140
+ </section>
141
+ <script>
142
+ (() => {
143
+ const mapEl = document.getElementById('travel-map');
144
+ if (!mapEl) return;
145
+
146
+ // Leaflet assets will be loaded asynchronously below.
147
+
148
+ const initMap = () => {
149
+ const places = JSON.parse(decodeURIComponent(mapEl.dataset.places || '%5B%5D'));
150
+ const items = document.querySelectorAll('[data-travel-place]');
151
+ const markers = new Map();
152
+ const setActive = (id, active) => {
153
+ items.forEach((item) => {
154
+ if (item.dataset.travelPlace === id) item.classList.toggle('active', active);
155
+ });
156
+ };
157
+
158
+ if (!window.L) {
159
+ mapEl.innerHTML = '<div class="travel-map-loading"><%=page.series === "Travel" ? "Map failed to load." : "地图加载失败。"%></div>';
160
+ return;
161
+ }
162
+
163
+ mapEl.innerHTML = '';
164
+ const mapLanguage = '<%=String(page.language || config.language || "").startsWith("zh") ? "zh" : "en"%>';
165
+ const tileConfig = mapLanguage === 'zh'
166
+ ? {
167
+ url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
168
+ options: {
169
+ maxZoom: 18,
170
+ subdomains: ['1', '2', '3', '4'],
171
+ attribution: '&copy; 高德地图',
172
+ },
173
+ }
174
+ : {
175
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
176
+ options: {
177
+ maxZoom: 18,
178
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
179
+ },
180
+ };
181
+ const map = L.map(mapEl, {
182
+ scrollWheelZoom: false,
183
+ attributionControl: true,
184
+ tap: false,
185
+ });
186
+ L.tileLayer(tileConfig.url, tileConfig.options).addTo(map);
187
+
188
+ const createMarkerIcon = (count = 1) => L.divIcon({
189
+ className: 'travel-leaflet-marker',
190
+ html: `<span data-place-count="${count}">${count > 1 ? `<strong>${count}</strong>` : ''}</span>`,
191
+ iconSize: [18, 18],
192
+ iconAnchor: [9, 9],
193
+ popupAnchor: [0, -10],
194
+ });
195
+ const bounds = [];
196
+ const placeGroups = places.reduce((groups, place) => {
197
+ const key = `${place.lat.toFixed(3)},${place.lng.toFixed(3)}`;
198
+ if (!groups.has(key)) {
199
+ groups.set(key, {
200
+ lat: place.lat,
201
+ lng: place.lng,
202
+ places: [],
203
+ });
204
+ }
205
+ groups.get(key).places.push(place);
206
+ return groups;
207
+ }, new Map());
208
+
209
+ const escapeHtml = (value = '') => String(value)
210
+ .replace(/&/g, '&amp;')
211
+ .replace(/</g, '&lt;')
212
+ .replace(/>/g, '&gt;')
213
+ .replace(/"/g, '&quot;')
214
+ .replace(/'/g, '&#39;');
215
+
216
+ const getPopupHtml = (group) => {
217
+ const items = group.places
218
+ .map((place) => {
219
+ const subTitle = place.subTitle ? `<small class="travel-map-popup-subtitle">${escapeHtml(place.subTitle)}</small>` : '';
220
+ const coverImg = place.cover ? `<img class="travel-map-popup-cover" src="${escapeHtml(place.cover)}" alt="" />` : '';
221
+ return `<a class="travel-map-popup-record" href="${escapeHtml(place.path)}">${coverImg}<div class="travel-map-popup-content"><span>${escapeHtml(place.title)}${subTitle}</span><small>${escapeHtml(place.date || place.location)}</small></div></a>`;
222
+ })
223
+ .join('');
224
+ return `<div class="travel-map-popup-list">${items}</div>`;
225
+ };
226
+
227
+ placeGroups.forEach((group) => {
228
+ const placeCount = group.places.length;
229
+ const marker = L.marker([group.lat, group.lng], { icon: createMarkerIcon(placeCount) })
230
+ .addTo(map)
231
+ .bindPopup(getPopupHtml(group));
232
+ marker.placeCount = placeCount;
233
+ group.places.forEach((place) => markers.set(place.id, marker));
234
+ bounds.push([group.lat, group.lng]);
235
+
236
+ marker.on('mouseover', () => {
237
+ const el = marker.getElement();
238
+ if (el) el.classList.add('active');
239
+ group.places.forEach((place) => setActive(place.id, true));
240
+ });
241
+ marker.on('mouseout', () => {
242
+ const el = marker.getElement();
243
+ if (el) el.classList.remove('active');
244
+ group.places.forEach((place) => setActive(place.id, false));
245
+ });
246
+ });
247
+
248
+ if (bounds.length) {
249
+ map.fitBounds(bounds, { padding: [28, 28] });
250
+ map.setMinZoom(map.getZoom());
251
+ }
252
+
253
+ items.forEach((item) => {
254
+ const id = item.dataset.travelPlace;
255
+ const marker = markers.get(id);
256
+ item.addEventListener('mouseenter', () => {
257
+ setActive(id, true);
258
+ if (marker) {
259
+ const el = marker.getElement();
260
+ if (el) el.classList.add('active');
261
+ }
262
+ });
263
+ item.addEventListener('mouseleave', () => {
264
+ setActive(id, false);
265
+ if (marker) {
266
+ const el = marker.getElement();
267
+ if (el) el.classList.remove('active');
268
+ }
269
+ });
270
+ item.addEventListener('focus', () => {
271
+ setActive(id, true);
272
+ if (marker) {
273
+ const el = marker.getElement();
274
+ if (el) el.classList.add('active');
275
+ }
276
+ });
277
+ item.addEventListener('blur', () => {
278
+ setActive(id, false);
279
+ if (marker) {
280
+ const el = marker.getElement();
281
+ if (el) el.classList.remove('active');
282
+ }
283
+ });
284
+ });
285
+ };
286
+
287
+ const loadResources = () => {
288
+ // 1. Dynamic load of Leaflet CSS if not already loaded
289
+ if (!document.querySelector('link[href*="leaflet.css"]')) {
290
+ const link = document.createElement('link');
291
+ link.rel = 'stylesheet';
292
+ link.href = '<%-url_for("/css/leaflet.css")%>';
293
+ document.head.appendChild(link);
294
+ }
295
+
296
+ // 2. Load Leaflet script dynamically
297
+ if (window.L) {
298
+ initMap();
299
+ } else {
300
+ const script = document.createElement('script');
301
+ script.src = '<%-url_for("/js/leaflet.js")%>';
302
+ script.async = true;
303
+ script.onload = initMap;
304
+ script.onerror = () => {
305
+ mapEl.innerHTML = '<div class="travel-map-loading"><%=page.series === "Travel" ? "Map failed to load." : "地图加载失败。"%></div>';
306
+ };
307
+ document.body.appendChild(script);
308
+ }
309
+ };
310
+
311
+ // Use requestIdleCallback or setTimeout to yield the main thread and prevent blocking initial paint/layout of the place list
312
+ if (window.requestIdleCallback) {
313
+ window.requestIdleCallback(() => loadResources());
314
+ } else {
315
+ setTimeout(loadResources, 100);
316
+ }
317
+ })();
318
+ </script>
319
+ <% } %>
320
+
321
+ <% if (!isTravelSeries) { %>
50
322
  <div class="posts-info-layout">
51
323
  <div class="posts-wrap">
52
324
  <div id="recent-posts">
@@ -56,4 +328,5 @@
56
328
  </div>
57
329
  <div class="info-wrap"><%-partial('partials/site-info')%></div>
58
330
  </div>
59
- </div>
331
+ <% } %>
332
+ </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hexo-theme-linen",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "A clean Hexo theme",
5
5
  "main": "index.js",
6
6
  "homepage": "https://github.com/lynanbreeze/hexo-theme-linen",
@@ -9,6 +9,8 @@ hexo.extend.filter.register('before_generate', function () {
9
9
  const content = fs.readFileSync(lettersFilePath);
10
10
  const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
11
11
 
12
- const scssPath = path.join(hexo.base_dir, 'themes', hexo.config.theme, 'source', 'css', 'font-version.scss');
13
- fs.writeFileSync(scssPath, `$font-version: "${hash}";\n`);
14
- });
12
+ const scssPath = path.join(hexo.theme_dir, "source/linen-theme/css/font-version.scss");
13
+ const scssContent = `$font-version: "${hash}";\n`;
14
+ if (fs.existsSync(scssPath) && fs.readFileSync(scssPath, 'utf8') === scssContent) return;
15
+ fs.writeFileSync(scssPath, scssContent);
16
+ });