-
Notifications
You must be signed in to change notification settings - Fork 694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Create and use a standard utility library for handling zip files in the frontend #11539
Merged
Merged
Changes from 1 commit
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
95925d8
Create generic fflate wrapper utility.
rtibbles 2bdcd4b
Simplify zip file loading to only need a URL.
rtibbles 00df0d2
Move blob url creation into kolibri-zip package.
rtibbles 2efb025
Migrate file path substitution logic into kolibri-zip package.
rtibbles d559cc3
Extend mimetypes.
rtibbles de14043
Create mapper class.
rtibbles 9bdec4b
Add href substitution to xml and html unzipping.
rtibbles 8336bbb
Add url caching and cleanup behaviour to kolibri-zip utility.
rtibbles 122d1e9
Update perseus renderer to use kolibri-zip functionality.
rtibbles File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
export function getAbsoluteFilePath(baseFilePath, relativeFilePath) { | ||
// Construct a URL with a dummy base so that we can concatenate the | ||
// dependency URL with the URL relative to the dependency | ||
// and then read the pathname to get the new path. | ||
// Take substring to remove the leading slash to match the reference file paths | ||
// in packageFiles. | ||
try { | ||
return new URL(relativeFilePath, new URL(baseFilePath, 'http://b.b/')).pathname.substring(1); | ||
} catch (e) { | ||
console.debug('Error during URL handling', e); // eslint-disable-line no-console | ||
} | ||
return null; | ||
} | ||
|
||
// Looks for any URLs referenced inside url() | ||
// Handle any query parameters separately. | ||
const cssPathRegex = /(url\(['"]?)([^?"')]+)?(\?[^'"]+)?(['"]?\))/g; | ||
|
||
export function getCSSPaths(fileContents) { | ||
return Array.from(fileContents.matchAll(cssPathRegex), ([, , p2]) => p2); | ||
} | ||
|
||
export function replaceCSSPaths(fileContents, packageFiles) { | ||
return fileContents.replace(cssPathRegex, function(match, p1, p2, p3, p4) { | ||
try { | ||
// Look to see if there is a URL in our packageFiles mapping that | ||
// that has this as the source path. | ||
const newUrl = packageFiles[p2]; | ||
if (newUrl) { | ||
// If so, replace the instance with the new URL. | ||
return `${p1}${newUrl}${p4}`; | ||
} | ||
} catch (e) { | ||
console.debug('Error during URL handling', e); // eslint-disable-line no-console | ||
} | ||
// Otherwise just return the match so that it is unchanged. | ||
return match; | ||
}); | ||
} | ||
|
||
export const defaultFilePathMappers = { | ||
css: { | ||
getPaths: getCSSPaths, | ||
replacePaths: replaceCSSPaths, | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,67 +1,127 @@ | ||
import { unzip, strFromU8 } from 'fflate'; | ||
import { unzip, strFromU8, strToU8 } from 'fflate'; | ||
import isPlainObject from 'lodash/isPlainObject'; | ||
import loadBinary from './loadBinary'; | ||
import mimetypes from './mimetypes.json'; | ||
import { getAbsoluteFilePath, defaultFilePathMappers } from './fileUtils'; | ||
|
||
class File { | ||
class ExtractedFile { | ||
marcellamaki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
constructor(name, obj) { | ||
this.name = name; | ||
this.obj = obj; | ||
} | ||
|
||
get fileNameExt() { | ||
return (this.name.split('.').slice(-1)[0] || '').toLowerCase(); | ||
} | ||
|
||
get mimeType() { | ||
return mimetypes[this.fileNameExt] || ''; | ||
} | ||
|
||
toString() { | ||
return strFromU8(this.obj); | ||
} | ||
|
||
toUrl(fileName = null) { | ||
fileName = fileName || this.name; | ||
let type = ''; | ||
const fileNameExt = fileName.split('.').slice(-1)[0]; | ||
if (fileNameExt) { | ||
const ext = fileNameExt.toLowerCase(); | ||
type = mimetypes[ext]; | ||
} | ||
const blob = new Blob([this.obj.buffer], { type }); | ||
toUrl() { | ||
const blob = new Blob([this.obj.buffer], { type: this.mimeType }); | ||
return URL.createObjectURL(blob); | ||
} | ||
} | ||
|
||
export default class ZipFile { | ||
constructor(url) { | ||
constructor(url, { filePathMappers } = { filePathMappers: defaultFilePathMappers }) { | ||
this._loadingError = null; | ||
this._extractedFileCache = {}; | ||
this._fileLoadingPromise = loadBinary(url) | ||
.then(data => { | ||
this.zipData = new Uint8Array(data); | ||
}) | ||
.catch(err => { | ||
this._loadingError = err; | ||
}); | ||
this.filePathMappers = isPlainObject(filePathMappers) ? filePathMappers : {}; | ||
} | ||
|
||
_getFiles(filter) { | ||
if (this._loadingError) { | ||
return Promise.reject(this._loadingError); | ||
/* | ||
* @param {ExtractedFile} file - The file to carry out replacement of references in | ||
* @param {Object} visitedPaths - A map of paths that have already been visited to prevent a loop | ||
* @return {Promise[ExtractedFile]} - A promise that resolves to the file with references replaced | ||
*/ | ||
_replaceFiles(file, visitedPaths) { | ||
const mapper = this.filePathMappers[file.fileNameExt]; | ||
if (!mapper) { | ||
return Promise.resolve(file); | ||
} | ||
visitedPaths = { ...visitedPaths }; | ||
visitedPaths[file.name] = true; | ||
const fileContents = file.toString(); | ||
// Filter out any paths that are in our already visited paths, as that means we are in a | ||
// referential loop where one file has pointed us to another, which is now point us back | ||
// to the source. | ||
// Because we need to modify the file before we generate the URL, we can't resolve this loop. | ||
const paths = mapper | ||
.getPaths(fileContents) | ||
.filter(path => !visitedPaths[getAbsoluteFilePath(file.name, path)]); | ||
const absolutePathsMap = paths.reduce((acc, path) => { | ||
acc[getAbsoluteFilePath(file.name, path)] = path; | ||
return acc; | ||
}, {}); | ||
return this._getFiles(file => absolutePathsMap[file.name], visitedPaths).then( | ||
replacementFiles => { | ||
const replacementFileMap = replacementFiles.reduce((acc, replacementFile) => { | ||
acc[absolutePathsMap[replacementFile.name]] = replacementFile.toUrl(); | ||
return acc; | ||
}, {}); | ||
const newFileContents = mapper.replacePaths(fileContents, replacementFileMap); | ||
file.obj = strToU8(newFileContents); | ||
return file; | ||
} | ||
); | ||
} | ||
|
||
_getFiles(filterPredicate, visitedPaths = {}) { | ||
const filter = file => !this._extractedFileCache[file.name] && filterPredicate(file); | ||
return this._fileLoadingPromise.then(() => { | ||
return new Promise((resolve, reject) => { | ||
unzip(this.zipData, { filter }, (err, unzipped) => { | ||
if (err) { | ||
reject(err); | ||
return; | ||
} | ||
if (!unzipped) { | ||
const alreadyUnzipped = Object.values(this._extractedFileCache).filter(filterPredicate); | ||
if (!unzipped && !alreadyUnzipped.length) { | ||
reject('No files found'); | ||
return; | ||
} | ||
resolve(Object.entries(unzipped).map(([name, obj]) => new File(name, obj))); | ||
Promise.all( | ||
Object.entries(unzipped).map(([name, obj]) => { | ||
const extractedFile = new ExtractedFile(name, obj); | ||
return this._replaceFiles(extractedFile, visitedPaths).then(extractedFile => { | ||
this._extractedFileCache[name] = extractedFile; | ||
return extractedFile; | ||
}); | ||
}) | ||
).then(extractedFiles => { | ||
resolve(extractedFiles.concat(alreadyUnzipped)); | ||
}); | ||
}); | ||
}); | ||
}); | ||
} | ||
|
||
file(filename) { | ||
if (this._loadingError) { | ||
return Promise.reject(this._loadingError); | ||
} | ||
if (this._extractedFileCache[filename]) { | ||
return Promise.resolve(this._extractedFileCache[filename]); | ||
} | ||
return this._getFiles(file => file.name === filename).then(files => files[0]); | ||
} | ||
files(path) { | ||
if (this._loadingError) { | ||
return Promise.reject(this._loadingError); | ||
} | ||
return this._getFiles(file => file.name.startsWith(path)); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I realize this was pre-existing and it's just moving it, so non-blocking.
This comment feels rather hard to wrap my brain around, mostly the
that we can concatenate the dependency URL with the URL relative to the dependency
partThe second "URL" (i.e. "the URL relative to the dependency") is where I get lost.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically, we are using the JS URL utilities to resolve a relative file path (by turning it into a resolution of a relative URL against a dummy base URL).
So the file that we are currently looking at in the zip file: e.g.
style/images.css
references another file relatively../images/cool.png
, this will resolve the reference to an absolute file path from the root of the zip file - soimages/cool.png
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bit confusing for me as well but I played w/ it a little bit and I think that the thing that isn't clear is that the
URL
constructor auto-magically resolves the relative path against the second argument.I played w/ it in the terminal a bit and this kind of made it make sense to me as I kept adding
..
s to the relative path:I think that maybe the comment could use some clarification or an example like the one you gave, Richard, re:
styles/header/logo.css
has a reference to../../assets/logo.svg
so we need to get a pathnameassets/logo.svg
out of that. Maybe that's more complicated though 😄