methanol 0.0.21 → 0.0.22

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.
@@ -0,0 +1,221 @@
1
+ /* Copyright Yukino Song, SudoMaker Ltd.
2
+ *
3
+ * Licensed to the Apache Software Foundation (ASF) under one
4
+ * or more contributor license agreements. See the NOTICE file
5
+ * distributed with this work for additional information
6
+ * regarding copyright ownership. The ASF licenses this file
7
+ * to you under the Apache License, Version 2.0 (the
8
+ * "License"); you may not use this file except in compliance
9
+ * with the License. You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing,
14
+ * software distributed under the License is distributed on an
15
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ * KIND, either express or implied. See the License for the
17
+ * specific language governing permissions and limitations
18
+ * under the License.
19
+ */
20
+
21
+ import { writeFile, mkdir, rm } from 'fs/promises'
22
+ import { resolve, dirname, relative, posix } from 'path'
23
+ import { normalizePath } from 'vite'
24
+ import { state } from '../state.js'
25
+ import { hashMd5 } from './utils.js'
26
+
27
+ const ensureDir = async (dir) => {
28
+ await mkdir(dir, { recursive: true })
29
+ }
30
+
31
+ const METHANOL_DIR = '.methanol'
32
+ const ENTRY_DIR = 'entries'
33
+
34
+ const resolveMethanolDir = () => resolve(state.PAGES_DIR, METHANOL_DIR)
35
+
36
+ export async function scanHtmlEntries(entries, preScan = null, options = null) {
37
+ const methanolDir = resolveMethanolDir()
38
+ const entriesDir = resolve(methanolDir, ENTRY_DIR)
39
+ const assetsEntryPath = resolve(methanolDir, 'assets-entry.js')
40
+ await rm(entriesDir, { recursive: true, force: true })
41
+ await ensureDir(entriesDir)
42
+ const assetUrls = new Set()
43
+ const entryModules = []
44
+ const scriptCounts = new Map()
45
+ const scriptOrder = new Map()
46
+ let scriptIndex = 0
47
+ const stylePaths = new Set()
48
+ let commonScriptEntry = null
49
+ const commonScripts = new Set()
50
+ let pagesWithScripts = 0
51
+
52
+ const createEntryModule = async (kind, publicPath, contentOverride = null, extraImports = null) => {
53
+ const hash = hashMd5(`${kind}:${publicPath || contentOverride || ''}`)
54
+ const filename = `${kind}-${hash}.js`
55
+ const fsPath = resolve(entriesDir, filename)
56
+ const manifestKey = normalizePath(relative(state.PAGES_DIR, fsPath))
57
+ const lines = []
58
+ if (contentOverride) {
59
+ lines.push(contentOverride)
60
+ } else if (publicPath) {
61
+ lines.push(`import ${JSON.stringify(publicPath)}`)
62
+ }
63
+ if (Array.isArray(extraImports) && extraImports.length) {
64
+ for (const entry of extraImports) {
65
+ if (!entry) continue
66
+ lines.push(`import ${JSON.stringify(entry)}`)
67
+ }
68
+ }
69
+ const content = lines.join('\n')
70
+ await writeFile(fsPath, content)
71
+ const entryInfo = {
72
+ kind,
73
+ publicPath,
74
+ fsPath,
75
+ manifestKey,
76
+ publicUrl: `/${METHANOL_DIR}/${ENTRY_DIR}/${filename}`
77
+ }
78
+ entryModules.push(entryInfo)
79
+ return entryInfo
80
+ }
81
+
82
+ // assetUrls are collected from worker scan results
83
+
84
+ const parseSrcset = (value = '') =>
85
+ value
86
+ .split(',')
87
+ .map((entry) => entry.trim())
88
+ .filter(Boolean)
89
+ .map((entry) => {
90
+ const [url, ...rest] = entry.split(/\s+/)
91
+ return { url, descriptor: rest.join(' ') }
92
+ })
93
+
94
+ const reportProgress = typeof options?.onProgress === 'function'
95
+ ? options.onProgress
96
+ : null
97
+ const totalEntries = entries.filter((entry) => entry.source !== 'static').length
98
+ let processedEntries = 0
99
+
100
+ const sortedEntries = [...entries].sort((a, b) => {
101
+ const left = a?.stagePath || a?.name || ''
102
+ const right = b?.stagePath || b?.name || ''
103
+ return left.localeCompare(right)
104
+ })
105
+ for (const entry of sortedEntries) {
106
+ if (entry.source === 'static') {
107
+ continue
108
+ }
109
+ if (preScan && preScan.has(entry.stagePath)) {
110
+ const scanned = preScan.get(entry.stagePath)
111
+ const scripts = Array.isArray(scanned?.scripts) ? scanned.scripts : []
112
+ const styles = Array.isArray(scanned?.styles) ? scanned.styles : []
113
+ const assets = Array.isArray(scanned?.assets) ? scanned.assets : []
114
+ if (scripts.length > 0) {
115
+ pagesWithScripts++
116
+ }
117
+ for (const script of scripts) {
118
+ if (!scriptOrder.has(script)) {
119
+ scriptOrder.set(script, scriptIndex++)
120
+ }
121
+ scriptCounts.set(script, (scriptCounts.get(script) || 0) + 1)
122
+ }
123
+ for (const style of styles) {
124
+ stylePaths.add(style)
125
+ }
126
+ for (const asset of assets) {
127
+ assetUrls.add(asset)
128
+ }
129
+ }
130
+
131
+ processedEntries += 1
132
+ if (reportProgress) {
133
+ reportProgress(processedEntries, totalEntries)
134
+ }
135
+ }
136
+
137
+ if (pagesWithScripts === 0) {
138
+ return { entryModules: [], assetsEntryPath: null, commonScriptEntry: null, commonScripts: [] }
139
+ }
140
+
141
+ const commonScriptCandidates = Array.from(scriptCounts.entries())
142
+ .filter(([, count]) => count === pagesWithScripts)
143
+ .map(([script]) => script)
144
+ .sort((a, b) => (scriptOrder.get(a) || 0) - (scriptOrder.get(b) || 0))
145
+
146
+ const assetsEntryPublicUrl = assetUrls.size ? `/${METHANOL_DIR}/assets-entry.js` : null
147
+ const extraImports = []
148
+
149
+ for (const style of stylePaths) {
150
+ const styleEntry = await createEntryModule('style', style)
151
+ extraImports.push(styleEntry.publicUrl)
152
+ }
153
+ if (assetsEntryPublicUrl) {
154
+ extraImports.push(assetsEntryPublicUrl)
155
+ }
156
+
157
+ if (commonScriptCandidates.length) {
158
+ const commonImports = commonScriptCandidates
159
+ .map((script) => `import ${JSON.stringify(script)}`)
160
+ .join('\n')
161
+ commonScriptEntry = await createEntryModule('script-common', null, commonImports, extraImports)
162
+ for (const script of commonScriptCandidates) {
163
+ commonScripts.add(script)
164
+ }
165
+ }
166
+
167
+ for (const [script] of scriptCounts) {
168
+ if (commonScripts.has(script)) continue
169
+ if (!commonScriptEntry && extraImports.length) {
170
+ await createEntryModule('script', script, null, extraImports)
171
+ extraImports.length = 0
172
+ continue
173
+ }
174
+ await createEntryModule('script', script)
175
+ }
176
+
177
+ await ensureDir(dirname(assetsEntryPath))
178
+ const assetLines = Array.from(assetUrls)
179
+ .sort()
180
+ .map((url) => `import ${JSON.stringify(url)};`)
181
+ if (assetLines.length) {
182
+ const assetEntry = `${assetLines.join('\n')}`
183
+ await writeFile(assetsEntryPath, assetEntry)
184
+ } else {
185
+ await rm(assetsEntryPath, { force: true })
186
+ }
187
+
188
+ return {
189
+ entryModules,
190
+ commonScripts: Array.from(commonScripts),
191
+ commonScriptEntry,
192
+ assetsEntryPath: assetLines.length ? assetsEntryPath : null
193
+ }
194
+ }
195
+
196
+ const resolveManifestEntry = (manifest, key) => {
197
+ if (!manifest || !key) return null
198
+ if (manifest[key]) return manifest[key]
199
+ if (manifest[`/${key}`]) return manifest[`/${key}`]
200
+ const normalized = posix.normalize(key)
201
+ if (manifest[normalized]) return manifest[normalized]
202
+ if (manifest[`/${normalized}`]) return manifest[`/${normalized}`]
203
+ return null
204
+ }
205
+
206
+ export async function rewriteHtmlEntries(entries, manifest, scanResult = null, options = null) {
207
+ const reportProgress = typeof options?.onProgress === 'function'
208
+ ? options.onProgress
209
+ : null
210
+ const totalEntries = entries.filter((entry) => entry.source !== 'static').length
211
+ let processedEntries = 0
212
+ for (const entry of entries) {
213
+ if (entry.source === 'static') {
214
+ continue
215
+ }
216
+ processedEntries += 1
217
+ if (reportProgress) {
218
+ reportProgress(processedEntries, totalEntries)
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,125 @@
1
+ /* Copyright Yukino Song, SudoMaker Ltd.
2
+ *
3
+ * Licensed to the Apache Software Foundation (ASF) under one
4
+ * or more contributor license agreements. See the NOTICE file
5
+ * distributed with this work for additional information
6
+ * regarding copyright ownership. The ASF licenses this file
7
+ * to you under the Apache License, Version 2.0 (the
8
+ * "License"); you may not use this file except in compliance
9
+ * with the License. You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing,
14
+ * software distributed under the License is distributed on an
15
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ * KIND, either express or implied. See the License for the
17
+ * specific language governing permissions and limitations
18
+ * under the License.
19
+ */
20
+
21
+ import { createHash } from 'crypto'
22
+
23
+ export const hashMd5 = (value) =>
24
+ createHash('md5').update(value).digest('hex')
25
+
26
+ export const splitUrlParts = (value) => {
27
+ if (!value) return { path: '', suffix: '' }
28
+ const hashIndex = value.indexOf('#')
29
+ const queryIndex = value.indexOf('?')
30
+ let end = value.length
31
+ if (hashIndex >= 0) end = Math.min(end, hashIndex)
32
+ if (queryIndex >= 0) end = Math.min(end, queryIndex)
33
+ const path = value.slice(0, end)
34
+ const suffix = value.slice(end)
35
+ return { path, suffix }
36
+ }
37
+
38
+ export const isExternalUrl = (value) => {
39
+ if (!value) return false
40
+ const trimmed = value.trim().toLowerCase()
41
+ if (!trimmed) return false
42
+ return (
43
+ trimmed.startsWith('http://') ||
44
+ trimmed.startsWith('https://') ||
45
+ trimmed.startsWith('//') ||
46
+ trimmed.startsWith('data:') ||
47
+ trimmed.startsWith('mailto:') ||
48
+ trimmed.startsWith('tel:') ||
49
+ trimmed.startsWith('javascript:')
50
+ )
51
+ }
52
+
53
+ export const resolvePageBase = (routePath) => {
54
+ if (!routePath || routePath === '/') return '/'
55
+ if (routePath.endsWith('/')) return routePath
56
+ const index = routePath.lastIndexOf('/')
57
+ if (index <= 0) return '/'
58
+ return `${routePath.slice(0, index)}/`
59
+ }
60
+
61
+ export const stripBasePrefix = (value, basePrefix) => {
62
+ if (!value || !basePrefix || basePrefix === '/') return value
63
+ const trimmedBase = basePrefix.endsWith('/') ? basePrefix.slice(0, -1) : basePrefix
64
+ if (!trimmedBase) return value
65
+ if (value === trimmedBase) return '/'
66
+ if (value.startsWith(`${trimmedBase}/`)) {
67
+ const next = value.slice(trimmedBase.length)
68
+ return next.startsWith('/') ? next : `/${next}`
69
+ }
70
+ return value
71
+ }
72
+
73
+ export const joinBasePrefix = (basePrefix, value) => {
74
+ if (!value) return value
75
+ if (!basePrefix || basePrefix === '/') {
76
+ return value.startsWith('/') ? value : `/${value}`
77
+ }
78
+ const trimmedBase = basePrefix.endsWith('/') ? basePrefix.slice(0, -1) : basePrefix
79
+ const normalized = value.startsWith('/') ? value : `/${value}`
80
+ return `${trimmedBase}${normalized}`
81
+ }
82
+
83
+ export const resolveManifestKey = (value, basePrefix, pageRoutePath) => {
84
+ if (!value || isExternalUrl(value)) return null
85
+ const { path } = splitUrlParts(value)
86
+ if (!path) return null
87
+ const withoutBase = stripBasePrefix(path, basePrefix)
88
+ const resolvedPath = withoutBase.startsWith('/')
89
+ ? withoutBase
90
+ : new URL(withoutBase, `http://methanol${resolvePageBase(pageRoutePath)}`).pathname
91
+ const key = resolvedPath.startsWith('/') ? resolvedPath.slice(1) : resolvedPath
92
+ return { key, resolvedPath }
93
+ }
94
+
95
+ export const getAttr = (node, name) => {
96
+ const attrs = node.attrs || []
97
+ const attr = attrs.find((item) => item.name === name)
98
+ return attr ? attr.value : null
99
+ }
100
+
101
+ export const setAttr = (node, name, value) => {
102
+ const attrs = node.attrs || []
103
+ const existing = attrs.find((item) => item.name === name)
104
+ if (existing) {
105
+ existing.value = value
106
+ } else {
107
+ attrs.push({ name, value })
108
+ }
109
+ node.attrs = attrs
110
+ }
111
+
112
+ export const getTextContent = (node) => {
113
+ if (!node.childNodes) return ''
114
+ return node.childNodes
115
+ .map((child) => (child.nodeName === '#text' ? child.value : ''))
116
+ .join('')
117
+ }
118
+
119
+ export const walkNodes = async (node, visitor) => {
120
+ await visitor(node)
121
+ if (!node.childNodes) return
122
+ for (const child of node.childNodes) {
123
+ await walkNodes(child, visitor)
124
+ }
125
+ }