diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5be218..96a8ef2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,59 +59,23 @@ jobs: - name: Install pinned tools env: + GITHUB_TOKEN: ${{ github.token }} MISE_YES: "1" run: | set -euo pipefail mise trust mise.toml mise install + mise exec -- rustup component add rustfmt clippy - - name: Run tests + - name: Run production verification run: | set -euo pipefail - mise run test + mise run verify - - name: Build release app + - name: Verify release app run: | set -euo pipefail - mise run clean - mise run build - - - name: Verify Apple Silicon artifacts - run: | - set -euo pipefail - - APP_PATH="build/Build/Products/Release/Glance.app" - test -d "$APP_PATH" - lipo -info HTMLConverter/htmlconverter.a | tee /tmp/htmlconverter-lipo.txt - if grep -q "x86_64" /tmp/htmlconverter-lipo.txt; then - echo "HTMLConverter archive contains x86_64" >&2 - exit 1 - fi - if ! grep -q "arm64" /tmp/htmlconverter-lipo.txt; then - echo "HTMLConverter archive does not contain arm64" >&2 - exit 1 - fi - - found_macho=0 - while IFS= read -r -d '' candidate; do - if info="$(lipo -info "$candidate" 2>/dev/null)"; then - found_macho=1 - echo "$info" - if echo "$info" | grep -q "x86_64"; then - echo "Bundle artifact contains x86_64: $candidate" >&2 - exit 1 - fi - if ! echo "$info" | grep -q "arm64"; then - echo "Bundle artifact does not contain arm64: $candidate" >&2 - exit 1 - fi - fi - done < <(find "$APP_PATH" -type f -print0) - - if [ "$found_macho" -eq 0 ]; then - echo "No Mach-O artifacts found in $APP_PATH" >&2 - exit 1 - fi + mise run verify:app - name: Package DMG env: @@ -155,11 +119,24 @@ jobs: DMG_PATH="dist/Glance-${VERSION}.dmg" MOUNT_DIR="$(mktemp -d)" + (cd dist && shasum -a 256 -c "Glance-${VERSION}.dmg.sha256") hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_DIR" -nobrowse -quiet trap 'hdiutil detach "$MOUNT_DIR" -quiet || true; rmdir "$MOUNT_DIR" || true' EXIT test -d "$MOUNT_DIR/Glance.app" test -d "$MOUNT_DIR/Glance.app/Contents/PlugIns/QLPlugin.appex" + test "$(plutil -extract CFBundleShortVersionString raw -o - "$MOUNT_DIR/Glance.app/Contents/Info.plist")" = "$VERSION" + codesign --verify --deep --strict --verbose=2 "$MOUNT_DIR/Glance.app" + QL_EXECUTABLE="$MOUNT_DIR/Glance.app/Contents/PlugIns/QLPlugin.appex/Contents/MacOS/QLPlugin" + lipo -info "$QL_EXECUTABLE" | grep -q "arm64" + if lipo -info "$QL_EXECUTABLE" | grep -q "x86_64"; then + echo "Published Quick Look executable contains x86_64" >&2 + exit 1 + fi + if strings "$QL_EXECUTABLE" | grep -Eq 'go\.buildid|runtime\.morestack|github\.com/alecthomas/chroma|github\.com/yuin/goldmark'; then + echo "Published Quick Look executable contains the removed Go runtime" >&2 + exit 1 + fi hdiutil detach "$MOUNT_DIR" -quiet trap - EXIT @@ -178,4 +155,4 @@ jobs: "dist/Glance-${VERSION}.dmg.sha256" \ --repo "$GITHUB_REPOSITORY" \ --title "Glance ${VERSION}" \ - --notes "Glance ${VERSION} for Apple silicon Macs running macOS 26 or later. This DMG is unsigned and unnotarized, so you may need to remove quarantine after installing. Verify the download with the attached SHA-256 checksum." + --notes-file RELEASE_NOTES.md diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..3a9610c --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,61 @@ +name: Production Verification + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verify-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Rust, Xcode, and release gates + runs-on: macos-26 + timeout-minutes: 45 + + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Assert macOS 26 Apple Silicon toolchain + run: | + set -euo pipefail + test "$(uname -m)" = "arm64" + test "$(sw_vers -productVersion | cut -d. -f1)" = "26" + test "$(xcodebuild -version | awk 'NR == 1 { split($2, version, "."); print version[1] }')" = "26" + + - name: Install checksum-verified mise + run: | + set -euo pipefail + MISE_VERSION="2026.6.0" + MISE_TARBALL="mise-v${MISE_VERSION}-macos-arm64.tar.gz" + curl -fsSLO "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/${MISE_TARBALL}" + echo "8830cf720025583bfbc5ca77bcf15958bf6efb4743fb84be6b572fd85d3a6cd0 ${MISE_TARBALL}" | shasum -a 256 -c - + tar -xzf "$MISE_TARBALL" + mkdir -p "$HOME/.local/bin" + cp mise/bin/mise "$HOME/.local/bin/mise" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install pinned tools + env: + GITHUB_TOKEN: ${{ github.token }} + MISE_YES: "1" + run: | + set -euo pipefail + mise trust mise.toml + mise install + mise exec -- rustup component add rustfmt clippy + + - name: Run production verification + run: | + set -euo pipefail + mise run verify + mise run verify:app diff --git a/.gitignore b/.gitignore index 34aa670..4115e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,9 @@ # Xcode project.xcworkspace/ -!Glance.xcodeproj/project.xcworkspace/ -!Glance.xcodeproj/project.xcworkspace/xcshareddata/ -!Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ -!Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved xcuserdata/ build/ .DS_Store -# `go build` output -HTMLConverter/htmlconverter*.a -HTMLConverter/htmlconverter*.h +# Rust build output +PreviewCore/build/ +PreviewCore/target/ diff --git a/AppStore/Listing/Description.txt b/AppStore/Listing/Description.txt index 8987317..ea19ce7 100644 --- a/AppStore/Listing/Description.txt +++ b/AppStore/Listing/Description.txt @@ -2,7 +2,7 @@ See what's in your files without opening them! Glance boosts your productivity by providing Quick Look previews for files that macOS doesn't support out of the box. -Version 1.5.9 is built for Apple silicon Macs running macOS 26 or later. +Version 1.6.0 is built for Apple silicon Macs running macOS 26 or later. Features: • Beautiful file previews for various file types diff --git a/Glance.xcodeproj/project.pbxproj b/Glance.xcodeproj/project.pbxproj index c287dcb..2fd0fc6 100644 --- a/Glance.xcodeproj/project.pbxproj +++ b/Glance.xcodeproj/project.pbxproj @@ -9,9 +9,11 @@ /* Begin PBXBuildFile section */ 102D56C526206A36000B5C0E /* MainVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102D56C426206A36000B5C0E /* MainVC.swift */; }; 103654F42F726241006F2538 /* SevenZipPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103654F32F72623D006F2538 /* SevenZipPreview.swift */; }; - 10779BC42621502C008A903C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BC32621502C008A903C /* ZIPFoundation */; }; - 10779BEB26217F25008A903C /* SWCompression in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BEA26217F25008A903C /* SWCompression */; }; - 10779C50262198EA008A903C /* htmlconverter.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* htmlconverter.a */; }; + 10779C50262198EA008A903C /* libglance_preview_core.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* libglance_preview_core.a */; }; + A5A000000000000000000003 /* PreviewCoreBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000001 /* PreviewCoreBridge.swift */; }; + A5A000000000000000000004 /* PreviewCoreBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000001 /* PreviewCoreBridge.swift */; }; + A5A000000000000000000005 /* PreviewExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000002 /* PreviewExecutor.swift */; }; + A5A000000000000000000006 /* PreviewExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000002 /* PreviewExecutor.swift */; }; A19F4D4226218B2B008A903C /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4526218B2B008A903C /* PreviewSupport.swift */; }; A19F4D4326218B2B008A903C /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4526218B2B008A903C /* PreviewSupport.swift */; }; A19F4D4426218B2B008A903C /* PreviewSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4626218B2B008A903C /* PreviewSupportTests.swift */; }; @@ -20,10 +22,7 @@ C0DE00000000000000000203 /* PreviewFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000103 /* PreviewFactoryTests.swift */; }; C0DE00000000000000000204 /* PreviewSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000104 /* PreviewSmokeTests.swift */; }; C0DE00000000000000000205 /* PlistCoverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000105 /* PlistCoverageTests.swift */; }; - C0DE00000000000000000206 /* SwiftCSV in Frameworks */ = {isa = PBXBuildFile; productRef = 7E616BAF244997420043F7AB /* SwiftCSV */; }; - C0DE00000000000000000207 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BC32621502C008A903C /* ZIPFoundation */; }; - C0DE00000000000000000208 /* SWCompression in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BEA26217F25008A903C /* SWCompression */; }; - C0DE00000000000000000209 /* htmlconverter.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* htmlconverter.a */; }; + C0DE00000000000000000209 /* libglance_preview_core.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* libglance_preview_core.a */; }; C0DE00000000000000000301 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4526218B2B008A903C /* PreviewSupport.swift */; }; C0DE00000000000000000302 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE10F82244F6774007214D8 /* Log.swift */; }; C0DE00000000000000000303 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E21C89024390E3100818EB2 /* Date.swift */; }; @@ -87,7 +86,6 @@ 7E458AF0243944810091BD0F /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E458AEF243944810091BD0F /* URL.swift */; }; 7E458AF52439481F0091BD0F /* Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E458AF42439481F0091BD0F /* Menu.swift */; }; 7E45F899244DA45500BFB869 /* FileTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E45F898244DA45500BFB869 /* FileTree.swift */; }; - 7E616BB0244997420043F7AB /* SwiftCSV in Frameworks */ = {isa = PBXBuildFile; productRef = 7E616BAF244997420043F7AB /* SwiftCSV */; }; 7E616BB2244998650043F7AB /* MainVC.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7E616BB1244998650043F7AB /* MainVC.xib */; }; 7E6EF1FD240CC802009E4199 /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E6EF1FC240CC802009E4199 /* Quartz.framework */; }; 7E6EF208240CC802009E4199 /* QLPlugin.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7E6EF1FA240CC802009E4199 /* QLPlugin.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -191,6 +189,8 @@ 10779C1126218B2B008A903C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A19F4D4526218B2B008A903C /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = ""; }; A19F4D4626218B2B008A903C /* PreviewSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupportTests.swift; sourceTree = ""; }; + A5A000000000000000000001 /* PreviewCoreBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewCoreBridge.swift; sourceTree = ""; }; + A5A000000000000000000002 /* PreviewExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewExecutor.swift; sourceTree = ""; }; C0DE00000000000000000101 /* FileTreeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeTests.swift; sourceTree = ""; }; C0DE00000000000000000102 /* FileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTests.swift; sourceTree = ""; }; C0DE00000000000000000103 /* PreviewFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFactoryTests.swift; sourceTree = ""; }; @@ -223,8 +223,8 @@ 7E21C89024390E3100818EB2 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 7E3DF744242CBE7F00DE7CD6 /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; 7E413F7F2418DD6200CFBB1D /* TSVPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSVPreview.swift; sourceTree = ""; }; - 7E42B61C245857D9008D7116 /* htmlconverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = htmlconverter.h; path = HTMLConverter/htmlconverter.h; sourceTree = ""; }; - 7E42B61D245857D9008D7116 /* htmlconverter.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = htmlconverter.a; path = HTMLConverter/htmlconverter.a; sourceTree = ""; }; + 7E42B61C245857D9008D7116 /* glance_preview_core.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = glance_preview_core.h; path = PreviewCore/include/glance_preview_core.h; sourceTree = ""; }; + 7E42B61D245857D9008D7116 /* libglance_preview_core.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libglance_preview_core.a; path = "PreviewCore/build/$(CONFIGURATION)/libglance_preview_core.a"; sourceTree = ""; }; 7E458AEF243944810091BD0F /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 7E458AF42439481F0091BD0F /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = ""; }; 7E45F898244DA45500BFB869 /* FileTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree.swift; sourceTree = ""; }; @@ -288,10 +288,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C0DE00000000000000000208 /* SWCompression in Frameworks */, - C0DE00000000000000000209 /* htmlconverter.a in Frameworks */, - C0DE00000000000000000206 /* SwiftCSV in Frameworks */, - C0DE00000000000000000207 /* ZIPFoundation in Frameworks */, + C0DE00000000000000000209 /* libglance_preview_core.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -299,11 +296,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7E616BB0244997420043F7AB /* SwiftCSV in Frameworks */, - 10779C50262198EA008A903C /* htmlconverter.a in Frameworks */, + 10779C50262198EA008A903C /* libglance_preview_core.a in Frameworks */, 7E6EF1FD240CC802009E4199 /* Quartz.framework in Frameworks */, - 10779BEB26217F25008A903C /* SWCompression in Frameworks */, - 10779BC42621502C008A903C /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -343,6 +337,8 @@ 7EAC01EB240D220B009505D0 /* File.swift */, 7E45F898244DA45500BFB869 /* FileTree.swift */, 7E1B16912455D93E00E2B84D /* HTMLRenderer.swift */, + A5A000000000000000000001 /* PreviewCoreBridge.swift */, + A5A000000000000000000002 /* PreviewExecutor.swift */, 7E9F0D7924168870007F1008 /* WebAsset */, ); path = Utils; @@ -449,8 +445,8 @@ 7E6EF1FB240CC802009E4199 /* Frameworks */ = { isa = PBXGroup; children = ( - 7E42B61D245857D9008D7116 /* htmlconverter.a */, - 7E42B61C245857D9008D7116 /* htmlconverter.h */, + 7E42B61D245857D9008D7116 /* libglance_preview_core.a */, + 7E42B61C245857D9008D7116 /* glance_preview_core.h */, 7E6EF1FC240CC802009E4199 /* Quartz.framework */, ); name = Frameworks; @@ -606,9 +602,6 @@ ); name = GlanceTests; packageProductDependencies = ( - 7E616BAF244997420043F7AB /* SwiftCSV */, - 10779BC32621502C008A903C /* ZIPFoundation */, - 10779BEA26217F25008A903C /* SWCompression */, ); productName = GlanceTests; productReference = 10779C0D26218B2B008A903C /* GlanceTests.xctest */; @@ -630,9 +623,6 @@ ); name = QLPlugin; packageProductDependencies = ( - 7E616BAF244997420043F7AB /* SwiftCSV */, - 10779BC32621502C008A903C /* ZIPFoundation */, - 10779BEA26217F25008A903C /* SWCompression */, ); productName = QLPlugin; productReference = 7E6EF1FA240CC802009E4199 /* QLPlugin.appex */; @@ -692,9 +682,6 @@ ); mainGroup = 7ECC8CE5240CB4CC000D6970; packageReferences = ( - 7E616BAE244997420043F7AB /* XCRemoteSwiftPackageReference "SwiftCSV" */, - 10779BC22621502C008A903C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 10779BE926217F25008A903C /* XCRemoteSwiftPackageReference "SWCompression" */, ); productRefGroup = 7ECC8CEF240CB4CC000D6970 /* Products */; projectDirPath = ""; @@ -775,19 +762,29 @@ ); inputPaths = ( "$(PROJECT_DIR)/mise.toml", - "$(PROJECT_DIR)/HTMLConverter/go.mod", - "$(PROJECT_DIR)/HTMLConverter/go.sum", - "$(PROJECT_DIR)/HTMLConverter/htmlconverter.go", + "$(PROJECT_DIR)/PreviewCore/Cargo.toml", + "$(PROJECT_DIR)/PreviewCore/Cargo.lock", + "$(PROJECT_DIR)/PreviewCore/build-xcode.sh", + "$(PROJECT_DIR)/PreviewCore/src/error.rs", + "$(PROJECT_DIR)/PreviewCore/src/ffi.rs", + "$(PROJECT_DIR)/PreviewCore/src/highlight.rs", + "$(PROJECT_DIR)/PreviewCore/src/lib.rs", + "$(PROJECT_DIR)/PreviewCore/src/markdown.rs", + "$(PROJECT_DIR)/PreviewCore/src/model.rs", + "$(PROJECT_DIR)/PreviewCore/src/notebook.rs", + "$(PROJECT_DIR)/PreviewCore/src/sevenzip.rs", + "$(PROJECT_DIR)/PreviewCore/src/tar.rs", + "$(PROJECT_DIR)/PreviewCore/src/tsv.rs", + "$(PROJECT_DIR)/PreviewCore/src/zip.rs", ); outputFileListPaths = ( ); outputPaths = ( - "$(PROJECT_DIR)/HTMLConverter/htmlconverter.a", - "$(PROJECT_DIR)/HTMLConverter/htmlconverter.h", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)/libglance_preview_core.a", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$PROJECT_DIR/HTMLConverter\"\nset -e\nif [ \"$(uname -m)\" != \"arm64\" ]; then\n\techo \"Apple Silicon is required to build HTMLConverter\" >&2\n\texit 1\nfi\n[ ! -d /opt/homebrew ] || eval \"$(/opt/homebrew/bin/brew shellenv)\"\nMISE_BIN=\"${MISE_BIN:-}\"\nif [ -z \"$MISE_BIN\" ]; then\n\tif command -v mise >/dev/null 2>&1; then\n\t\tMISE_BIN=\"$(command -v mise)\"\n\telif [ -x \"$HOME/.local/bin/mise\" ]; then\n\t\tMISE_BIN=\"$HOME/.local/bin/mise\"\n\telif [ -x \"/opt/homebrew/bin/mise\" ]; then\n\t\tMISE_BIN=\"/opt/homebrew/bin/mise\"\n\telif [ -x \"/usr/local/bin/mise\" ]; then\n\t\tMISE_BIN=\"/usr/local/bin/mise\"\n\tfi\nfi\nif [ -z \"$MISE_BIN\" ]; then\n\techo \"mise is required to build HTMLConverter with the pinned Go toolchain\" >&2\n\texit 1\nfi\nexport MISE_TRUSTED_CONFIG_PATHS=\"$PROJECT_DIR${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}\"\nrm -f htmlconverter*.a htmlconverter*.h\nexport MACOSX_DEPLOYMENT_TARGET=\"${MACOSX_DEPLOYMENT_TARGET:-26.0}\"\nexport CGO_CFLAGS=\"-mmacosx-version-min=${MACOSX_DEPLOYMENT_TARGET} ${CGO_CFLAGS}\"\nexport CGO_LDFLAGS=\"-mmacosx-version-min=${MACOSX_DEPLOYMENT_TARGET} ${CGO_LDFLAGS}\"\n# Force rebuilds so cached Go objects use the selected macOS deployment target.\nGOOS=darwin GOARCH=arm64 CGO_ENABLED=1 \"$MISE_BIN\" exec -- go build -a --buildmode=c-archive -ldflags \"-s -w\" -o ./htmlconverter.a\n"; + shellScript = "\"$PROJECT_DIR/PreviewCore/build-xcode.sh\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -799,6 +796,8 @@ D1A000000000000000000003 /* DirectoryPreview.swift in Sources */, D1A000000000000000000005 /* DirectoryPreviewTests.swift in Sources */, D2A000000000000000000003 /* DirectoryThumbnailLoader.swift in Sources */, + A5A000000000000000000004 /* PreviewCoreBridge.swift in Sources */, + A5A000000000000000000006 /* PreviewExecutor.swift in Sources */, D2A000000000000000000005 /* DirectoryThumbnailTests.swift in Sources */, D4A000000000000000000003 /* NestedPreviewProvider.swift in Sources */, D4A000000000000000000005 /* NestedPreviewTests.swift in Sources */, @@ -849,6 +848,8 @@ files = ( D1A000000000000000000002 /* DirectoryPreview.swift in Sources */, D2A000000000000000000002 /* DirectoryThumbnailLoader.swift in Sources */, + A5A000000000000000000003 /* PreviewCoreBridge.swift in Sources */, + A5A000000000000000000005 /* PreviewExecutor.swift in Sources */, D4A000000000000000000002 /* NestedPreviewProvider.swift in Sources */, D5A000000000000000000002 /* OpenWithService.swift in Sources */, D6A000000000000000000002 /* OpenWithBridge.swift in Sources */, @@ -936,7 +937,7 @@ "@loader_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.GlanceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -964,7 +965,7 @@ "@loader_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.GlanceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -982,7 +983,7 @@ CODE_SIGN_ENTITLEMENTS = QLPlugin/QLPlugin.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -994,9 +995,9 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.QLPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1013,7 +1014,7 @@ CODE_SIGN_ENTITLEMENTS = QLPlugin/QLPlugin.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1025,9 +1026,9 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.QLPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1168,7 +1169,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1177,7 +1178,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1194,7 +1195,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1203,7 +1204,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1252,50 +1253,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 10779BC22621502C008A903C /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/weichsel/ZIPFoundation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.9.20; - }; - }; - 10779BE926217F25008A903C /* XCRemoteSwiftPackageReference "SWCompression" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tsolomko/SWCompression"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.9.1; - }; - }; - 7E616BAE244997420043F7AB /* XCRemoteSwiftPackageReference "SwiftCSV" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/swiftcsv/SwiftCSV"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.10.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 10779BC32621502C008A903C /* ZIPFoundation */ = { - isa = XCSwiftPackageProductDependency; - package = 10779BC22621502C008A903C /* XCRemoteSwiftPackageReference "ZIPFoundation" */; - productName = ZIPFoundation; - }; - 10779BEA26217F25008A903C /* SWCompression */ = { - isa = XCSwiftPackageProductDependency; - package = 10779BE926217F25008A903C /* XCRemoteSwiftPackageReference "SWCompression" */; - productName = SWCompression; - }; - 7E616BAF244997420043F7AB /* SwiftCSV */ = { - isa = XCSwiftPackageProductDependency; - package = 7E616BAE244997420043F7AB /* XCRemoteSwiftPackageReference "SwiftCSV" */; - productName = SwiftCSV; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 7ECC8CE6240CB4CC000D6970 /* Project object */; } diff --git a/Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 84fcde2..0000000 --- a/Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,42 +0,0 @@ -{ - "originHash" : "1ceabeb9031cb69f64f4605e5958335a150d92b9923c53b8a4be2e9470ae4e11", - "pins" : [ - { - "identity" : "bitbytedata", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tsolomko/BitByteData", - "state" : { - "revision" : "e1a5443be67daf0833cbb5f4fa3a06a265ca3105", - "version" : "2.1.0" - } - }, - { - "identity" : "swcompression", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tsolomko/SWCompression", - "state" : { - "revision" : "4896ffae1c1803cd89869b1c9f8b68bfa18a2119", - "version" : "4.9.1" - } - }, - { - "identity" : "swiftcsv", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftcsv/SwiftCSV", - "state" : { - "revision" : "6ab5d0fe9b6ef3c79d717eefa70862df389e74d9", - "version" : "0.10.0" - } - }, - { - "identity" : "zipfoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/weichsel/ZIPFoundation", - "state" : { - "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", - "version" : "0.9.20" - } - } - ], - "version" : 3 -} diff --git a/Glance/Credits.rtf b/Glance/Credits.rtf index 53dc845..7782928 100644 --- a/Glance/Credits.rtf +++ b/Glance/Credits.rtf @@ -32,11 +32,12 @@ Portions of this software may utilize the following copyrighted material, the us \ \pard\pardeftab720\partightenfactor0 -\f0\b \cf2 Chroma\ +\f0\b \cf2 two-face and syntect\ \pard\pardeftab720\partightenfactor0 \f1\b0 \cf2 \ -Copyright (C) 2017 Alec Thomas\ +Copyright (c) 2023-2025 The two-face developers\ +Copyright (c) 2017 Tristan Hume, Keith Hall, Google Inc and other contributors\ \ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ \ @@ -64,30 +65,32 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI \ \pard\pardeftab720\partightenfactor0 -\f0\b \cf2 goldmark\ +\f0\b \cf2 Comrak\ \pard\pardeftab720\partightenfactor0 \f1\b0 \cf2 \ -MIT License\ +Copyright (c) 2017-2025, Comrak contributors\ \ -Copyright (c) 2019 Yusuke Inuzuka\ +All rights reserved.\ \ -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\ \ -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\ \ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\ +\ +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ \ \pard\pardeftab720\partightenfactor0 -\f0\b \cf2 nbtohtml\ +\f0\b \cf2 Ammonia\ \ \pard\pardeftab720\partightenfactor0 \f1\b0 \cf2 MIT License\ \ -Copyright (c) 2020 Samuel Meuli\ +Copyright (c) 2015-2022 The ammonia Developers\ \ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ \ @@ -97,73 +100,10 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI \ \ -\f0\b \cf0 \kerning1\expnd0\expndtw0 SWCompression -\f1\b0 \cf2 \expnd0\expndtw0\kerning0 -\ -\ -MIT License\ -\ -Copyright (c) 2021 Timofey Solomko\ -\ -Permission is hereby granted, free of charge, to any person obtaining a copy\ -of this software and associated documentation files (the "Software"), to deal\ -in the Software without restriction, including without limitation the rights\ -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ -copies of the Software, and to permit persons to whom the Software is\ -furnished to do so, subject to the following conditions:\ -\ -The above copyright notice and this permission notice shall be included in all\ -copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ -SOFTWARE.\ -\ -\ -\pard\pardeftab720\partightenfactor0 - -\f0\b \cf2 SwiftCSV\ +\f0\b \cf2 PreviewCore parser and support libraries\ \pard\pardeftab720\partightenfactor0 \f1\b0 \cf2 \ -The MIT License (MIT)\ -\ -Copyright (c) 2014 Naoto Kaneko\ -\ -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ -\ -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ -\ -\ - -\f0\b \cf0 \kerning1\expnd0\expndtw0 ZIPFoundation -\f1\b0 \cf2 \expnd0\expndtw0\kerning0 -\ -\ -MIT License\ +Glance's Rust preview core directly uses ansi-to-html (MIT), base64 (MIT or Apache-2.0), csv (Unlicense or MIT), flate2 (MIT or Apache-2.0), serde and serde_json (MIT or Apache-2.0), sevenz-rust2 (Apache-2.0), and zip (MIT). The complete locked dependency graph and license identifiers are documented in PreviewCore/THIRD_PARTY_LICENSES.md.\ \ -Copyright (c) 2017-2021 Thomas Zoechling (https://www.peakstep.com)\ -\ -Permission is hereby granted, free of charge, to any person obtaining a copy\ -of this software and associated documentation files (the "Software"), to deal\ -in the Software without restriction, including without limitation the rights\ -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ -copies of the Software, and to permit persons to whom the Software is\ -furnished to do so, subject to the following conditions:\ -\ -The above copyright notice and this permission notice shall be included in all\ -copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ -SOFTWARE.} \ No newline at end of file +The MIT License text reproduced above applies where selected. Apache-2.0 licensed components are distributed under the Apache License, Version 2.0, available at https://www.apache.org/licenses/LICENSE-2.0. Components offering the Unlicense are available under https://unlicense.org/.} diff --git a/Glance/Shared/Utils/SupportedPreviewRegistry.swift b/Glance/Shared/Utils/SupportedPreviewRegistry.swift index 3452cf0..7748e53 100644 --- a/Glance/Shared/Utils/SupportedPreviewRegistry.swift +++ b/Glance/Shared/Utils/SupportedPreviewRegistry.swift @@ -45,7 +45,7 @@ struct SupportedPreviewType: Equatable { let searchTokens: [String] let previewFileType: PreviewFileType - /// The Chroma lexer name to use for syntax highlighting. Only meaningful for `.code` entries. + /// The syntax name or alias to use for highlighting. Only meaningful for `.code` entries. /// When `nil`, `getCodeLexer` falls back to the file extension or `"autodetect"`. let codeLexer: String? diff --git a/GlanceTests/DirectoryPreviewTests.swift b/GlanceTests/DirectoryPreviewTests.swift index d3c4cdd..9a9ee58 100644 --- a/GlanceTests/DirectoryPreviewTests.swift +++ b/GlanceTests/DirectoryPreviewTests.swift @@ -23,7 +23,7 @@ final class DirectoryPreviewTests: XCTestCase { try super.tearDownWithError() } - func testPreviewBuildsNestedTreeWithMetadataAndExpandsAllRows() throws { + func testPreviewBuildsNestedTreeWithMetadataAndExpandsAllRows() async throws { let rootURL = try makeDirectory(named: "root") let nestedURL = try makeDirectory(named: "root/nested") let modificationDate = Date(timeIntervalSince1970: 1_700_000_000) @@ -36,7 +36,7 @@ final class DirectoryPreviewTests: XCTestCase { ofItemAtPath: visibleURL.path ) - let previewVC = try makePreview(for: rootURL) + let previewVC = try await makePreview(for: rootURL) let visibleNode = try XCTUnwrap(node(named: "visible.txt", in: previewVC.rootNodes)) let nestedNode = try XCTUnwrap( node(named: nestedURL.lastPathComponent, in: previewVC.rootNodes) @@ -57,32 +57,32 @@ final class DirectoryPreviewTests: XCTestCase { XCTAssertEqual(outlineView.numberOfRows, 3) } - func testPreviewUsesDeterministicItemLimitAndTruncationLabel() throws { + func testPreviewUsesDeterministicItemLimitAndTruncationLabel() async throws { let rootURL = try makeDirectory(named: "limited") _ = try writeFile(named: "limited/c.txt", contents: "c") _ = try writeFile(named: "limited/a.txt", contents: "a") _ = try writeFile(named: "limited/b.txt", contents: "b") - let previewVC = try makePreview(for: rootURL, maxItemCount: 2) + let previewVC = try await makePreview(for: rootURL, maxItemCount: 2) XCTAssertEqual(Set(previewVC.rootNodes.map(\.name)), Set(["a.txt", "b.txt"])) previewVC.loadViewIfNeeded() XCTAssertEqual(previewVC.previewStatusText, "2+ items") } - func testPreviewStopsAtConfiguredDepth() throws { + func testPreviewStopsAtConfiguredDepth() async throws { let rootURL = try makeDirectory(named: "depth") _ = try makeDirectory(named: "depth/level-1/level-2") _ = try writeFile(named: "depth/level-1/level-2/level-3.txt", contents: "deep") - let previewVC = try makePreview(for: rootURL, maxDepth: 2) + let previewVC = try await makePreview(for: rootURL, maxDepth: 2) XCTAssertNotNil(node(named: "level-1", in: previewVC.rootNodes)) XCTAssertNotNil(node(named: "level-2", in: previewVC.rootNodes)) XCTAssertNil(node(named: "level-3.txt", in: previewVC.rootNodes)) } - func testPreviewDoesNotRecurseIntoSymbolicLinksOrPackages() throws { + func testPreviewDoesNotRecurseIntoSymbolicLinksOrPackages() async throws { let rootURL = try makeDirectory(named: "boundaries") let packageURL = try makeDirectory(named: "boundaries/Sample.app/Contents") _ = try writeFile( @@ -92,7 +92,7 @@ final class DirectoryPreviewTests: XCTestCase { let loopURL = rootURL.appendingPathComponent("loop", isDirectory: true) try FileManager.default.createSymbolicLink(at: loopURL, withDestinationURL: rootURL) - let previewVC = try makePreview(for: rootURL) + let previewVC = try await makePreview(for: rootURL) let packageNode = try XCTUnwrap(node(named: "Sample.app", in: previewVC.rootNodes)) let loopNode = try XCTUnwrap(node(named: "loop", in: previewVC.rootNodes)) @@ -102,12 +102,13 @@ final class DirectoryPreviewTests: XCTestCase { XCTAssertNil(node(named: "inside.txt", in: previewVC.rootNodes)) } - func testDefaultPreviewDeclinesTemporaryDirectories() throws { + func testDefaultPreviewDeclinesTemporaryDirectories() async throws { let rootURL = try makeDirectory(named: "temporary") - XCTAssertThrowsError( - try DirectoryPreview().createPreviewVC(file: File(url: rootURL)) - ) { error in + do { + _ = try await DirectoryPreview().createPreviewVC(file: File(url: rootURL)) + XCTFail("Expected temporary directories to be declined") + } catch { guard let directoryError = error as? DirectoryPreviewError else { return XCTFail("Unexpected error: \(error)") } @@ -121,15 +122,14 @@ final class DirectoryPreviewTests: XCTestCase { for directoryURL: URL, maxItemCount: Int = DirectoryPreview.defaultMaxItemCount, maxDepth: Int = DirectoryPreview.defaultMaxDepth - ) throws -> OutlinePreviewVC { - try XCTUnwrap( - DirectoryPreview( - fileManager: .default, - maxItemCount: maxItemCount, - maxDepth: maxDepth, - excludedRootURLs: [] - ).createPreviewVC(file: File(url: directoryURL)) as? OutlinePreviewVC - ) + ) async throws -> OutlinePreviewVC { + let generatedPreview = try await DirectoryPreview( + fileManager: .default, + maxItemCount: maxItemCount, + maxDepth: maxDepth, + excludedRootURLs: [] + ).createPreviewVC(file: File(url: directoryURL)) + return try XCTUnwrap(generatedPreview as? OutlinePreviewVC) } private func makeDirectory(named name: String) throws -> URL { diff --git a/GlanceTests/NestedPreviewTests.swift b/GlanceTests/NestedPreviewTests.swift index df3b33d..c0de86f 100644 --- a/GlanceTests/NestedPreviewTests.swift +++ b/GlanceTests/NestedPreviewTests.swift @@ -49,7 +49,7 @@ final class NestedPreviewTests: XCTestCase { ))) } - func testProviderBuildsSupportedAndNativeControllers() throws { + func testProviderBuildsSupportedAndNativeControllers() async throws { let markdownURL = try writeFile(named: "README.md", contents: "# Nested") let imageURL = try writeFile(named: "image.png", contents: "") let provider = DefaultNestedPreviewProvider() @@ -58,16 +58,18 @@ final class NestedPreviewTests: XCTestCase { let imageNode = node(named: imageURL.lastPathComponent, type: .png) imageNode.fileURL = imageURL - XCTAssertTrue(try provider.makePreviewController(for: markdownNode) is WebPreviewVC) + let generatedMarkdownPreview = try await provider.makePreviewController(for: markdownNode) + XCTAssertTrue(generatedMarkdownPreview is WebPreviewVC) + let generatedNativePreview = try await provider.makePreviewController(for: imageNode) let nativePreview = try XCTUnwrap( - provider.makePreviewController(for: imageNode) as? NativePreviewVC + generatedNativePreview as? NativePreviewVC ) XCTAssertEqual(nativePreview.fileURL, imageURL) XCTAssertNotNil(nativePreview.previewView) XCTAssertFalse(try XCTUnwrap(nativePreview.previewView).shouldCloseWithWindow) } - func testProviderRejectsOversizedSupportedNestedFiles() throws { + func testProviderRejectsOversizedSupportedNestedFiles() async throws { let markdownURL = try writeFile(named: "Oversized.md", contents: "") let fileHandle = try FileHandle(forWritingTo: markdownURL) try fileHandle.truncate(atOffset: UInt64(PreviewPolicy.maximumFileSize + 1)) @@ -75,9 +77,10 @@ final class NestedPreviewTests: XCTestCase { let markdownNode = node(named: markdownURL.lastPathComponent, type: .plainText) markdownNode.fileURL = markdownURL - XCTAssertThrowsError( - try DefaultNestedPreviewProvider().makePreviewController(for: markdownNode) - ) { error in + do { + _ = try await DefaultNestedPreviewProvider().makePreviewController(for: markdownNode) + XCTFail("Expected an oversized nested file to be rejected") + } catch { guard case let PreviewError.fileSizeError(path) = error else { return XCTFail("Expected the shared preview size error, got \(error)") } @@ -121,7 +124,7 @@ final class NestedPreviewTests: XCTestCase { XCTAssertIdentical(interactionDelegate.previewedNode, package) } - func testBackRestoresTheRetainedFolderControllerAndItsState() throws { + func testBackRestoresTheRetainedFolderControllerAndItsState() async throws { let folderURL = try makeDirectory(named: "folder") let fileURL = try writeFile(named: "folder/file.txt", contents: "nested") let fileNode = node(named: fileURL.lastPathComponent, type: .plainText) @@ -152,6 +155,7 @@ final class NestedPreviewTests: XCTestCase { let retainedScrollOrigin = clipView.bounds.origin mainVC.outlinePreview(previewVC, requestPreviewOf: try XCTUnwrap(retainedSelection)) + try await waitUntil { mainVC.currentPreviewController === nestedController } XCTAssertIdentical(mainVC.currentPreviewController, nestedController) XCTAssertFalse(mainVC.backButton.isHidden) @@ -176,7 +180,7 @@ final class NestedPreviewTests: XCTestCase { XCTAssertEqual(mainVC.statusLabel.stringValue, "1 items") } - func testFailedNestedPreviewLeavesFolderVisibleAndShowsNonmodalError() throws { + func testFailedNestedPreviewLeavesFolderVisibleAndShowsNonmodalError() async throws { let folderURL = try makeDirectory(named: "failure-folder") let fileURL = try writeFile(named: "failure-folder/file.bin", contents: "data") let fileNode = node(named: fileURL.lastPathComponent, type: .data) @@ -190,6 +194,7 @@ final class NestedPreviewTests: XCTestCase { mainVC.installTopLevelPreview(previewVC, file: try File(url: folderURL)) mainVC.outlinePreview(previewVC, requestPreviewOf: fileNode) + try await waitUntil { mainVC.statusLabel.stringValue == "Couldn’t preview file.bin" } XCTAssertIdentical(mainVC.currentPreviewController, previewVC) XCTAssertNil(mainVC.nestedPreviewController) @@ -287,6 +292,20 @@ final class NestedPreviewTests: XCTestCase { try contents.write(to: fileURL, atomically: true, encoding: .utf8) return fileURL } + + private func waitUntil( + timeout: Duration = .seconds(2), + condition: @escaping @MainActor () -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() { + guard clock.now < deadline else { + throw TestNestedPreviewError.timedOut + } + try await Task.sleep(for: .milliseconds(5)) + } + } } @MainActor @@ -311,7 +330,7 @@ private final class StubNestedPreviewProvider: NestedPreviewProviding { self.result = result } - func makePreviewController(for _: FileTreeNode) throws -> PreviewVC { + func makePreviewController(for _: FileTreeNode) async throws -> PreviewVC { try result.get() } } @@ -343,4 +362,5 @@ private final class StubPreviewVC: NSViewController, PreviewVC, PreviewStatusPro private enum TestNestedPreviewError: Error { case failed + case timedOut } diff --git a/GlanceTests/PlistCoverageTests.swift b/GlanceTests/PlistCoverageTests.swift index 8f369e8..5eaec57 100644 --- a/GlanceTests/PlistCoverageTests.swift +++ b/GlanceTests/PlistCoverageTests.swift @@ -8,6 +8,7 @@ final class PlistCoverageTests: XCTestCase { "org.7-zip.7-zip-archive", "com.sun.java-archive", "com.sun.web-application-archive", + "org.gnu.gnu-zip-archive", "org.gnu.gnu-zip-tar-archive", "public.tar-archive", "public.zip-archive", @@ -71,9 +72,15 @@ final class PlistCoverageTests: XCTestCase { .appendingPathComponent("release.yml"), encoding: .utf8 ) + let rustBuildScriptContents = try String( + contentsOf: repositoryRoot() + .appendingPathComponent("PreviewCore", isDirectory: true) + .appendingPathComponent("build-xcode.sh"), + encoding: .utf8 + ) XCTAssertTrue(projectContents.contains("MACOSX_DEPLOYMENT_TARGET = 26.0;")) - XCTAssertTrue(projectContents.contains("MACOSX_DEPLOYMENT_TARGET:-26.0")) + XCTAssertTrue(rustBuildScriptContents.contains("MACOSX_DEPLOYMENT_TARGET:-26.0")) XCTAssertFalse(projectContents.contains("MACOSX_DEPLOYMENT_TARGET = 15.0;")) XCTAssertTrue(workflowContents.contains("runs-on: macos-26")) XCTAssertTrue(workflowContents.contains("Release builds require Xcode 26")) @@ -85,13 +92,13 @@ final class PlistCoverageTests: XCTestCase { encoding: .utf8 ) - XCTAssertTrue(miseContents.contains("go = \"1.26.5\"")) + XCTAssertTrue(miseContents.contains("rust = \"1.97.1\"")) XCTAssertTrue(miseContents.contains("swiftformat = \"0.61.1\"")) XCTAssertTrue(miseContents.contains("swiftlint = \"0.63.3\"")) XCTAssertFalse(miseContents.contains("= \"latest\"")) } - func testReleaseMetadataUsesVersion1_5_9Build19() throws { + func testReleaseMetadataUsesVersion1_6_0Build20() throws { let projectContents = try String( contentsOf: repositoryRoot() .appendingPathComponent("Glance.xcodeproj", isDirectory: true) @@ -111,16 +118,16 @@ final class PlistCoverageTests: XCTestCase { ) XCTAssertEqual( - projectContents.components(separatedBy: "MARKETING_VERSION = 1.5.9;").count - 1, + projectContents.components(separatedBy: "MARKETING_VERSION = 1.6.0;").count - 1, 4 ) XCTAssertEqual( - projectContents.components(separatedBy: "CURRENT_PROJECT_VERSION = 19;").count - 1, + projectContents.components(separatedBy: "CURRENT_PROJECT_VERSION = 20;").count - 1, 4 ) - XCTAssertTrue(readmeContents.contains("Version 1.5.9 (build 19)")) - XCTAssertTrue(readmeContents.contains("Version **1.5.9** (build **19**)")) - XCTAssertTrue(listingContents.contains("Version 1.5.9")) + XCTAssertTrue(readmeContents.contains("Version 1.6.0 (build 20)")) + XCTAssertTrue(readmeContents.contains("Version **1.6.0** (build **20**)")) + XCTAssertTrue(listingContents.contains("Version 1.6.0")) } func testUserFacingRepositoryLinksPointToMaintainedFork() throws { diff --git a/GlanceTests/PreviewSmokeTests.swift b/GlanceTests/PreviewSmokeTests.swift index f189639..f5c453d 100644 --- a/GlanceTests/PreviewSmokeTests.swift +++ b/GlanceTests/PreviewSmokeTests.swift @@ -4,7 +4,8 @@ import XCTest @MainActor final class PreviewSmokeTests: XCTestCase { - nonisolated(unsafe) private var temporaryDirectory: URL! + // swiftlint:disable:next modifier_order + private nonisolated(unsafe) var temporaryDirectory: URL! override func setUpWithError() throws { try super.setUpWithError() @@ -23,15 +24,79 @@ final class PreviewSmokeTests: XCTestCase { try super.tearDownWithError() } - func testCodePreviewHandlesEmptyAndUnicodeSource() throws { + func testCodePreviewHandlesEmptyAndUnicodeSource() async throws { let fileURL = try writeFile(named: "unicode.swift", contents: "let cafe = \"\u{2615}\"\n") - let previewVC = try CodePreview().createPreviewVC(file: File(url: fileURL)) + let previewVC = try await CodePreview().createPreviewVC(file: File(url: fileURL)) XCTAssertTrue(previewVC is WebPreviewVC) } - func testMarkdownPreviewHandlesFrontMatterAndRawHTML() throws { + func testHTMLRendererPreservesBinarySafeUnicodeAndEmptyInputs() throws { + let html = try HTMLRenderer.renderCode("let cafe = \"\u{2615}\"\n", lexer: "swift") + + XCTAssertTrue(html.hasPrefix(#"
"#))
+		XCTAssertTrue(html.contains("\u{2615}"))
+		XCTAssertEqual(try HTMLRenderer.renderMarkdown(""), "")
+	}
+
+	func testHTMLRendererProducesSafeGFMAndNotebookDOM() throws {
+		let markdown = """
+		---
+		title: Fixture
+		---
+
+		| one | two |
+		| --- | --- |
+		| yes | no |
+
+		- [x] done
+
+		
+		[bad](javascript:alert("bad"))
+		"""
+		let markdownHTML = try HTMLRenderer.renderMarkdown(markdown)
+		let notebook = """
+		{"cells":[{"cell_type":"code","execution_count":1,"metadata":{},"source":["print('ok')"],"outputs":[{"name":"stdout","output_type":"stream","text":["ok\\n"]}]}],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5}
+		"""
+		let notebookHTML = try HTMLRenderer.renderNotebook(notebook)
+
+		XCTAssertTrue(markdownHTML.contains(#"
"#))
+		XCTAssertTrue(markdownHTML.contains(""))
+		XCTAssertTrue(markdownHTML.contains(#"type="checkbox""#))
+		XCTAssertFalse(markdownHTML.lowercased().contains(" Void
+	) async throws {
+		try await operation()
+		var durations = [UInt64]()
+		durations.reserveCapacity(iterations)
+		for _ in 0 ..< iterations {
+			let start = DispatchTime.now().uptimeNanoseconds
+			try await operation()
+			durations.append(DispatchTime.now().uptimeNanoseconds - start)
+		}
+		durations.sort()
+		let median = durations[durations.count / 2]
+		let p95Index = (durations.count * 95 + 99) / 100 - 1
+		let p95 = durations[p95Index]
+		print(
+			String(
+				format: "PARSER %@ median=%.3fms p95=%.3fms",
+				name,
+				Double(median) / 1_000_000,
+				Double(p95) / 1_000_000
+			)
+		)
+	}
+
+	private func XCTAssertThrowsErrorAsync(
+		_ operation: () async throws -> Void,
+		errorHandler: (Error) -> Void = { _ in },
+		file: StaticString = #filePath,
+		line: UInt = #line
+	) async {
+		do {
+			try await operation()
+			XCTFail("Expected operation to throw", file: file, line: line)
+		} catch {
+			errorHandler(error)
 		}
 	}
 
@@ -406,7 +651,7 @@ final class PreviewSmokeTests: XCTestCase {
 		let expression = try NSRegularExpression(pattern: #"url\(([^)]+)\)"#)
 		let matches = expression.matches(
 			in: stylesheet,
-			range: NSRange(stylesheet.startIndex.. URL? in
@@ -422,13 +667,17 @@ final class PreviewSmokeTests: XCTestCase {
 		}
 	}
 
-	private func tarHeader(name: String, sizeField: [UInt8], typeFlag: UInt8 = UInt8(ascii: "0")) -> Data {
+	private func tarHeader(
+		name: String,
+		sizeField: [UInt8],
+		typeFlag: UInt8 = UInt8(ascii: "0")
+	) -> Data {
 		var header = Data(repeating: 0, count: 512)
 		write(Array(name.utf8), to: &header, at: 0, maxLength: 100)
 		write(sizeField, to: &header, at: 124, maxLength: 12)
 		header[156] = typeFlag
 
-		for index in 148..<156 {
+		for index in 148 ..< 156 {
 			header[index] = UInt8(ascii: " ")
 		}
 		let checksum = header.reduce(0) { $0 + Int($1) }
@@ -441,34 +690,13 @@ final class PreviewSmokeTests: XCTestCase {
 		var bytes = [UInt8](repeating: 0, count: 12)
 		var remaining = UInt64(bitPattern: value)
 		for index in stride(from: 11, through: 0, by: -1) {
-			bytes[index] = UInt8(remaining & 0xff)
+			bytes[index] = UInt8(remaining & 0xFF)
 			remaining >>= 8
 		}
 		bytes[0] |= 0x80
 		return bytes
 	}
 
-	private func sevenZipUnencodedFileInfoHeader(numFiles: UInt8) -> Data {
-		sevenZipSignatureHeader(nextHeader: Data([0x01, 0x05, numFiles]))
-	}
-
-	private func sevenZipSignatureHeader(nextHeader: Data) -> Data {
-		var data = Data([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04])
-		data.append(Data(repeating: 0, count: 4))
-		appendLittleEndianUInt64(0, to: &data)
-
-		appendLittleEndianUInt64(UInt64(nextHeader.count), to: &data)
-		data.append(Data(repeating: 0, count: 4))
-		data.append(nextHeader)
-		return data
-	}
-
-	private func appendLittleEndianUInt64(_ value: UInt64, to data: inout Data) {
-		for index in 0..<8 {
-			data.append(UInt8((value >> (8 * index)) & 0xff))
-		}
-	}
-
 	private func write(_ bytes: [UInt8], to data: inout Data, at offset: Int, maxLength: Int) {
 		for (index, byte) in bytes.prefix(maxLength).enumerated() {
 			data[offset + index] = byte
diff --git a/GlanceTests/TestFiles/archives/encrypted.7z b/GlanceTests/TestFiles/archives/encrypted.7z
new file mode 100644
index 0000000..4add411
Binary files /dev/null and b/GlanceTests/TestFiles/archives/encrypted.7z differ
diff --git a/GlanceTests/TestFiles/archives/example.7z b/GlanceTests/TestFiles/archives/example.7z
new file mode 100644
index 0000000..c5aa3f1
Binary files /dev/null and b/GlanceTests/TestFiles/archives/example.7z differ
diff --git a/HTMLConverter/.golangci.yml b/HTMLConverter/.golangci.yml
deleted file mode 100644
index a9e6f69..0000000
--- a/HTMLConverter/.golangci.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-version: "2"
-linters:
-  default: all
-  disable:
-    - depguard
-    - dupword
-    - exhaustruct
-    - funlen
-    - gochecknoglobals
-    - gocognit
-    - godot
-    - nlreturn
-    - nolintlint
-    - nonamedreturns
-    - paralleltest
-    - revive
-    - testifylint
-    - testpackage
-    - varnamelen
-    - wsl
-  settings:
-    lll:
-      line-length: 100
-      tab-width: 2
-  exclusions:
-    generated: lax
-    rules:
-      - path: (.+)\.go$
-        text: Line contains TODO/BUG/FIXME
-    paths:
-      - third_party$
-      - builtin$
-      - examples$
-issues:
-  max-issues-per-linter: 0
-  max-same-issues: 0
-formatters:
-  exclusions:
-    generated: lax
-    paths:
-      - third_party$
-      - builtin$
-      - examples$
diff --git a/HTMLConverter/go.mod b/HTMLConverter/go.mod
deleted file mode 100644
index 0c10780..0000000
--- a/HTMLConverter/go.mod
+++ /dev/null
@@ -1,28 +0,0 @@
-module github.com/chamburr/glance
-
-go 1.26
-
-require (
-	github.com/alecthomas/chroma/v2 v2.27.0
-	github.com/samuelmeuli/nbtohtml v0.5.0
-	github.com/stretchr/testify v1.11.1
-	github.com/tdewolff/minify/v2 v2.24.14
-	github.com/yuin/goldmark v1.8.5
-	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
-)
-
-require (
-	github.com/alecthomas/chroma v0.10.0 // indirect
-	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/buildkite/terminal-to-html v3.2.0+incompatible // indirect
-	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/dlclark/regexp2 v1.12.0 // indirect
-	github.com/dlclark/regexp2/v2 v2.2.1 // indirect
-	github.com/gorilla/css v1.0.1 // indirect
-	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
-	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/tdewolff/parse/v2 v2.8.14 // indirect
-	github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 // indirect
-	golang.org/x/net v0.56.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
-)
diff --git a/HTMLConverter/go.sum b/HTMLConverter/go.sum
deleted file mode 100644
index 587c02f..0000000
--- a/HTMLConverter/go.sum
+++ /dev/null
@@ -1,108 +0,0 @@
-github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
-github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
-github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
-github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
-github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
-github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
-github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/chroma v0.7.2-0.20200305040604-4f3623dce67a/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
-github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
-github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
-github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
-github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
-github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
-github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
-github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
-github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
-github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
-github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
-github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
-github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
-github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
-github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
-github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
-github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
-github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
-github.com/buildkite/terminal-to-html v3.2.0+incompatible h1:WdXzl7ZmYzCAz4pElZosPaUlRTW+qwVx/SkQSCa1jXs=
-github.com/buildkite/terminal-to-html v3.2.0+incompatible/go.mod h1:BFFdFecOxCgjdcarqI+8izs6v85CU/1RA/4Bqh4GR7E=
-github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
-github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
-github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
-github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
-github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
-github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
-github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
-github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
-github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
-github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
-github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
-github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
-github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/samuelmeuli/nbtohtml v0.5.0 h1:Xl4DCwcMapYW8koN4EACicIzeVe/9uxapahLuyVKn4g=
-github.com/samuelmeuli/nbtohtml v0.5.0/go.mod h1:VTd3c3K+UDBYJPa4swssK8d4T7iQcZ1F+i+yQ5yI6Bg=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tdewolff/minify/v2 v2.24.14 h1:y05Dj1YHaCJ0g6nZa5wC6vyI+x5iziYBY20gsC6xvbo=
-github.com/tdewolff/minify/v2 v2.24.14/go.mod h1:ducvuHPTtUvKKK6uCI4p0fR2Mxvoy+XpFy9cC8QDv1c=
-github.com/tdewolff/parse/v2 v2.8.14 h1:GuhuMMPKAqj5BhP9hZUz3r3JqX/lOvQ1E6wxW6p/FhY=
-github.com/tdewolff/parse/v2 v2.8.14/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
-github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
-github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
-github.com/yuin/goldmark v1.1.22/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
-github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
-github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
-github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691/go.mod h1:YLF3kDffRfUH/bTxOxHhV6lxwIB3Vfj91rEwNMS9MXo=
-github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
-github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
-github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
-github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
-golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/HTMLConverter/htmlconverter.go b/HTMLConverter/htmlconverter.go
deleted file mode 100644
index 88ad40a..0000000
--- a/HTMLConverter/htmlconverter.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package main
-
-import (
-	"C"
-	"bytes"
-	"fmt"
-	"regexp"
-
-	"github.com/alecthomas/chroma/v2"
-	htmlFormatter "github.com/alecthomas/chroma/v2/formatters/html"
-	"github.com/alecthomas/chroma/v2/lexers"
-	"github.com/alecthomas/chroma/v2/styles"
-	"github.com/samuelmeuli/nbtohtml"
-	"github.com/yuin/goldmark"
-	highlighting "github.com/yuin/goldmark-highlighting/v2"
-	"github.com/yuin/goldmark/extension"
-)
-
-// Regex for YAML front matter in a Markdown document.
-var markdownFrontMatterRegex = regexp.MustCompile(`^---\r?\n([\s\S]*?)\r?\n---\r?\n`)
-
-// Enable syntax highlighting in Markdown
-var markdownParser = goldmark.New(
-	goldmark.WithExtensions(
-		extension.GFM,
-		highlighting.NewHighlighting(
-			highlighting.WithFormatOptions(
-				htmlFormatter.WithClasses(true),
-			),
-		),
-	),
-)
-
-// Functions for conversion between C and Go strings. Required here because cgo cannot be used in
-// tests.
-
-func convertToCString(goString string) *C.char {
-	return C.CString(goString)
-}
-
-func convertToGoString(cString *C.char) string {
-	return C.GoString(cString)
-}
-
-// Convention: Because all functions return C strings, errors are implemented as return values which
-// start with "error: ".
-
-// convertCodeToHTML converts the provided source code string to HTML. Classes for syntax
-// highlighting are generated using Chroma.
-//
-//export convertCodeToHTML
-func convertCodeToHTML(source *C.char, lexer *C.char) *C.char {
-	sourceString := convertToGoString(source)
-	lexerString := convertToGoString(lexer)
-	htmlBuffer := new(bytes.Buffer)
-
-	// Set up lexer for programming language
-	var l chroma.Lexer
-	if lexerString != "" {
-		l = lexers.Get(lexerString)
-	}
-	if l == nil {
-		l = lexers.Analyse(sourceString)
-	}
-	if l == nil {
-		l = lexers.Fallback
-	}
-	l = chroma.Coalesce(l)
-
-	// Use classes instead of inline styles
-	formatter := htmlFormatter.New(htmlFormatter.WithClasses(true))
-
-	iterator, err := l.Tokenise(nil, sourceString)
-	if err != nil {
-		errMessage := fmt.Sprintf("error: Could not render source code (tokenization error): %v", err)
-		return convertToCString(errMessage)
-	}
-
-	err = formatter.Format(htmlBuffer, styles.GitHub, iterator)
-	if err != nil {
-		errMessage := fmt.Sprintf("error: Could not render source code (formatting error): %v", err)
-		return convertToCString(errMessage)
-	}
-
-	// Chroma escapes tags, so HTML should be safe from code injection
-	htmlString := htmlBuffer.String()
-	return convertToCString(htmlString)
-}
-
-// convertMarkdownToHTML converts the provided Markdown string to HTML using goldmark. Classes for
-// syntax highlighting inside code blocks are generated using Chroma.
-//
-//export convertMarkdownToHTML
-func convertMarkdownToHTML(source *C.char) *C.char {
-	sourceString := convertToGoString(source)
-
-	// Render YAML front matter as code block
-	sourceString = markdownFrontMatterRegex.ReplaceAllString(sourceString, "```yaml\n$1\n```\n")
-
-	// Convert Markdown to HTML
-	var htmlBuffer bytes.Buffer
-	err := markdownParser.Convert([]byte(sourceString), &htmlBuffer)
-	if err != nil {
-		errMessage := fmt.Sprintf("error: Could not convert Markdown to HTML: %v", err)
-		return convertToCString(errMessage)
-	}
-	// goldmark does not render raw HTML or potentially-dangerous URLs, so HTML should be safe from
-	// code injection
-	return convertToCString(htmlBuffer.String())
-}
-
-// convertNotebookToHTML converts the provided Jupyter Notebook JSON to HTML using `nbtohtml`.
-//
-//export convertNotebookToHTML
-func convertNotebookToHTML(source *C.char) *C.char {
-	sourceString := convertToGoString(source)
-
-	html := new(bytes.Buffer)
-	err := nbtohtml.ConvertString(html, sourceString)
-	if err != nil {
-		errMessage := fmt.Sprintf("error: Could not convert Notebook to HTML: %v", err)
-		return convertToCString(errMessage)
-	}
-	htmlString := html.String()
-	return convertToCString(htmlString)
-}
-
-// Main function is required for `c-archive` builds.
-func main() {}
diff --git a/HTMLConverter/htmlconverter_test.go b/HTMLConverter/htmlconverter_test.go
deleted file mode 100644
index 51347e1..0000000
--- a/HTMLConverter/htmlconverter_test.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/tdewolff/minify/v2"
-	"github.com/tdewolff/minify/v2/html"
-)
-
-var minifier *minify.M
-
-func minifyHTML(htmlString string) string {
-	// Initialize minifier if necessary
-	if minifier == nil {
-		minifier = minify.New()
-		minifier.Add("text/html", &html.Minifier{KeepEndTags: true, KeepQuotes: true})
-	}
-
-	minified, err := minifier.String("text/html", htmlString)
-	if err != nil {
-		panic(fmt.Sprintf("Could not minify HTML: %v", err))
-	}
-
-	return minified
-}
-
-func TestConvertCodeToHTML(t *testing.T) {
-	source := `const print = (text) => console.log(text);
-print("Hello world");`
-	actual := convertToGoString(convertCodeToHTML(convertToCString(source), convertToCString("js")))
-	actualTrimmed := strings.TrimSpace(actual)
-	assert.True(t, strings.HasPrefix(actualTrimmed, `
`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, `
`)) -} - -func TestConvertCodeToHTMLEmptyInput(t *testing.T) { - actual := convertToGoString(convertCodeToHTML(convertToCString(""), convertToCString("swift"))) - actualTrimmed := strings.TrimSpace(actual) - - assert.True(t, strings.HasPrefix(actualTrimmed, `
`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, `
`)) -} - -func TestConvertCodeToHTMLUnknownLexerUsesFallback(t *testing.T) { - actual := convertToGoString(convertCodeToHTML( - convertToCString("plain text"), - convertToCString("not-a-real-lexer"), - )) - actualTrimmed := strings.TrimSpace(actual) - - assert.True(t, strings.HasPrefix(actualTrimmed, `
`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, `
`)) -} - -func TestConvertMarkdownToHTML(t *testing.T) { - source := `# Heading - -Text` - expected := "

Heading

Text

" - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - assert.Equal(t, expected, minifyHTML(actual)) -} - -func TestConvertMarkdownToHTMLEmptyInput(t *testing.T) { - actual := convertToGoString(convertMarkdownToHTML(convertToCString(""))) - - assert.Equal(t, "", actual) -} - -func TestConvertMarkdownToHTMLWithFrontMatter(t *testing.T) { - source := `--- -key: Value -key2: Another value ---- - -# Heading - -Text` - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - assert.True(t, strings.Contains(actual, `
`))
-	assert.True(t, strings.Contains(minifyHTML(actual), `

Heading

Text

`)) -} - -func TestConvertMarkdownToHTMLWithCRLFFrontMatter(t *testing.T) { - source := "---\r\nkey: Value\r\n---\r\n\r\n# Heading\r\n\r\nText" - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - - assert.True(t, strings.Contains(actual, `
`))
-	assert.True(t, strings.Contains(minifyHTML(actual), `

Heading

Text

`)) -} - -func TestConvertMarkdownToHTMLWithSyntaxHighlighting(t *testing.T) { - source := "# Heading\n\nText\n\n```js\nconst print = (text) => console.log(text);\nprint(\"Hello world\");\n```" // nolint:lll - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - assert.True(t, strings.Contains(actual, `
`))
-}
-
-func TestConvertMarkdownToHTMLSanitizesRawHTMLAndUnsafeLinks(t *testing.T) {
-	source := `
-
-[bad](javascript:alert("bad"))`
-	actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
-	actualLower := strings.ToLower(actual)
-
-	assert.NotContains(t, actualLower, "`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, ``))
-}
-
-func TestConvertNotebookToHTMLInvalid(t *testing.T) {
-	source := "This is not a valid JSON file."
-	actual := convertToGoString(convertNotebookToHTML(convertToCString(source)))
-	assert.True(t, strings.HasPrefix(actual, "error: "))
-}
diff --git a/PreviewCore/Cargo.lock b/PreviewCore/Cargo.lock
new file mode 100644
index 0000000..d9cb402
--- /dev/null
+++ b/PreviewCore/Cargo.lock
@@ -0,0 +1,1296 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "ammonia"
+version = "4.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "url",
+]
+
+[[package]]
+name = "ansi-to-html"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1747e9bde0e06cdfa780fcba909df1fa220f82421ead1fd4a93427763d2cbda6"
+dependencies = [
+ "memchr",
+ "regex",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bzip2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
+dependencies = [
+ "libbz2-rs-sys",
+]
+
+[[package]]
+name = "caseless"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
+dependencies = [
+ "unicode-normalization",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "comrak"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466"
+dependencies = [
+ "caseless",
+ "entities",
+ "finl_unicode",
+ "jetscii",
+ "phf",
+ "phf_codegen",
+ "rustc-hash",
+ "smallvec",
+ "typed-arena",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
+dependencies = [
+ "dtoa-short",
+ "itoa",
+ "smallvec",
+]
+
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+
+[[package]]
+name = "finl_unicode"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "glance-preview-core"
+version = "0.1.0"
+dependencies = [
+ "ammonia",
+ "ansi-to-html",
+ "base64 0.23.1",
+ "comrak",
+ "csv",
+ "flate2",
+ "libc",
+ "serde",
+ "serde_json",
+ "sevenz-rust2",
+ "two-face",
+ "zip",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jetscii"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libbz2-rs-sys"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lzma-rust2"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243cb2271683c2855cad62142cbf38199db439b1b1e1ccdd5a011342894dd7cc"
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "onig"
+version = "6.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppmd-rust"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sevenz-rust2"
+version = "0.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20acd38118edb830eb5a0bfacaf83c687b9995f48d6983cdac3cb18ce1d571cc"
+dependencies = [
+ "bzip2",
+ "crc32fast",
+ "js-sys",
+ "lzma-rust2",
+ "ppmd-rust",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "js-sys",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "two-face"
+version = "0.5.2+bat-0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915be7adc2ff6f4338acbf71f3eda0a146f46003b992a1669c674308610efdac"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "syntect",
+]
+
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "indexmap",
+ "memchr",
+ "time",
+ "typed-path",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/PreviewCore/Cargo.toml b/PreviewCore/Cargo.toml
new file mode 100644
index 0000000..21c9814
--- /dev/null
+++ b/PreviewCore/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "glance-preview-core"
+version = "0.1.0"
+edition = "2024"
+license = "MIT"
+publish = false
+
+[lib]
+crate-type = ["staticlib", "rlib"]
+
+[dependencies]
+# These migration-critical renderer/parser versions are exact by design; Cargo.lock pins the
+# complete transitive graph used by all locked production builds.
+ammonia = "4.1.4"
+ansi-to-html = "0.2.3"
+base64 = "0.23.1"
+comrak = { version = "0.54.0", default-features = false }
+csv = "=1.4.0"
+flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] }
+libc = "=0.2.189"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+sevenz-rust2 = { version = "=0.21.4", default-features = false, features = [
+	"bzip2",
+	"ppmd",
+] }
+two-face = { version = "=0.5.2", default-features = false, features = [
+	"syntect-default-onig",
+] }
+zip = { version = "=8.6.0", default-features = false, features = ["time"] }
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
+panic = "unwind"
diff --git a/PreviewCore/THIRD_PARTY_LICENSES.md b/PreviewCore/THIRD_PARTY_LICENSES.md
new file mode 100644
index 0000000..d619ce3
--- /dev/null
+++ b/PreviewCore/THIRD_PARTY_LICENSES.md
@@ -0,0 +1,41 @@
+# PreviewCore third-party licenses
+
+`Cargo.lock` is the authoritative dependency-version record. The table below records the published
+license expressions for PreviewCore's direct dependencies and the transitive native libraries with
+additional license families.
+
+License texts are available from the linked standard licenses and each crate's source package:
+[MIT](https://opensource.org/license/mit), [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0),
+[BSD-2-Clause](https://opensource.org/license/bsd-2-clause),
+[MPL-2.0](https://www.mozilla.org/MPL/2.0/), [Unicode licenses](https://www.unicode.org/license.txt),
+[Unlicense](https://unlicense.org/), [Zlib](https://www.zlib.net/zlib_license.html),
+[BSL-1.0](https://www.boost.org/LICENSE_1_0.txt), [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/),
+[MIT-0](https://opensource.org/license/mit-0),
+[Unicode-DFS-2016](https://spdx.org/licenses/Unicode-DFS-2016.html), and
+[bzip2-1.0.6](https://github.com/libarchive/bzip2/blob/bzip2-1.0.6/LICENSE).
+
+| Crate | Version | License expression |
+| --- | --- | --- |
+| ammonia | 4.1.4 | MIT OR Apache-2.0 |
+| ansi-to-html | 0.2.3 | MIT |
+| base64 | 0.22.1, 0.23.1 | MIT OR Apache-2.0 |
+| bzip2 | 0.6.1 | MIT OR Apache-2.0 |
+| comrak | 0.54.0 | BSD-2-Clause |
+| cssparser, dtoa-short | 0.37.0, 0.3.5 | MPL-2.0 |
+| csv, csv-core | 1.4.0, 0.1.13 | Unlicense OR MIT |
+| finl_unicode | 1.4.0 | (MIT OR Apache-2.0) AND Unicode-DFS-2016 |
+| flate2 | 1.1.9 | MIT OR Apache-2.0 |
+| ICU4X Unicode data crates | locked versions | Unicode-3.0 |
+| libbz2-rs-sys | 0.2.5 | bzip2-1.0.6 |
+| libc | 0.2.189 | MIT OR Apache-2.0 |
+| lzma-rust2 | 0.18.1 | Apache-2.0 |
+| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 |
+| onig, onig_sys | 6.5.3, 69.9.3 | MIT |
+| ppmd-rust | 1.4.0 | CC0-1.0 OR MIT-0 |
+| ryu | 1.0.23 | Apache-2.0 OR BSL-1.0 |
+| serde, serde_core, serde_derive | 1.0.229 | MIT OR Apache-2.0 |
+| serde_json | 1.0.151 | MIT OR Apache-2.0 |
+| sevenz-rust2 | 0.21.4 | Apache-2.0 |
+| syntect | 5.3.0 | MIT |
+| two-face | 0.5.2+bat-0.26.1 | MIT OR Apache-2.0 |
+| zip | 8.6.0 | MIT |
diff --git a/PreviewCore/build-xcode.sh b/PreviewCore/build-xcode.sh
new file mode 100755
index 0000000..dc64141
--- /dev/null
+++ b/PreviewCore/build-xcode.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+
+set -eu
+
+if [ "$(uname -m)" != "arm64" ]; then
+	echo "Apple silicon is required to build PreviewCore" >&2
+	exit 1
+fi
+
+PROJECT_ROOT="${PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
+CORE_ROOT="$PROJECT_ROOT/PreviewCore"
+TARGET="aarch64-apple-darwin"
+OUTPUT_DIRECTORY="$CORE_ROOT/build/${CONFIGURATION:-Debug}"
+export CARGO_TARGET_DIR="$CORE_ROOT/target"
+export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-26.0}"
+
+MISE_BIN="${MISE_BIN:-}"
+if [ -z "$MISE_BIN" ]; then
+	if command -v mise >/dev/null 2>&1; then
+		MISE_BIN="$(command -v mise)"
+	elif [ -x "$HOME/.local/bin/mise" ]; then
+		MISE_BIN="$HOME/.local/bin/mise"
+	elif [ -x "/opt/homebrew/bin/mise" ]; then
+		MISE_BIN="/opt/homebrew/bin/mise"
+	elif [ -x "/usr/local/bin/mise" ]; then
+		MISE_BIN="/usr/local/bin/mise"
+	fi
+fi
+if [ -z "$MISE_BIN" ]; then
+	echo "mise is required to build PreviewCore with the pinned Rust toolchain" >&2
+	exit 1
+fi
+
+export MISE_TRUSTED_CONFIG_PATHS="$PROJECT_ROOT${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}"
+
+PROFILE="debug"
+set --
+case "${CONFIGURATION:-Debug}" in
+	Release|Profile)
+		PROFILE="release"
+		set -- --release
+		;;
+esac
+
+if ! "$MISE_BIN" exec -- rustup target list --installed | /usr/bin/grep -Fxq "$TARGET"; then
+	echo "Rust target $TARGET is not installed; run 'mise exec -- rustup target add $TARGET'" >&2
+	exit 1
+fi
+
+"$MISE_BIN" exec -- cargo build \
+	--locked \
+	--manifest-path "$CORE_ROOT/Cargo.toml" \
+	--target "$TARGET" \
+	"$@"
+
+/usr/bin/install -d "$OUTPUT_DIRECTORY"
+/usr/bin/install -m 0644 \
+	"$CARGO_TARGET_DIR/$TARGET/$PROFILE/libglance_preview_core.a" \
+	"$OUTPUT_DIRECTORY/libglance_preview_core.a"
diff --git a/PreviewCore/include/glance_preview_core.h b/PreviewCore/include/glance_preview_core.h
new file mode 100644
index 0000000..ca139f8
--- /dev/null
+++ b/PreviewCore/include/glance_preview_core.h
@@ -0,0 +1,48 @@
+#ifndef GLANCE_PREVIEW_CORE_H
+#define GLANCE_PREVIEW_CORE_H
+
+#include 
+#include 
+#include 
+
+typedef struct GlanceRenderResult {
+	uint8_t *data;
+	size_t length;
+	int32_t status;
+} GlanceRenderResult;
+
+enum {
+	GLANCE_STATUS_OK = 0,
+	GLANCE_STATUS_INVALID_INPUT = 1,
+	GLANCE_STATUS_PARSE_ERROR = 2,
+	GLANCE_STATUS_INTERNAL_ERROR = 3,
+	GLANCE_STATUS_IO_ERROR = 4,
+	GLANCE_STATUS_RESOURCE_LIMIT = 5,
+	GLANCE_STATUS_UNSUPPORTED = 6,
+};
+
+/*
+ * Successful results contain payload bytes. Nonzero statuses contain a UTF-8 error message.
+ * The caller owns every non-NULL data buffer and must release it exactly once with
+ * glance_render_buffer_free, passing the returned length unchanged.
+ */
+
+GlanceRenderResult glance_render_code(
+	const uint8_t *source_data,
+	size_t source_length,
+	const uint8_t *lexer_data,
+	size_t lexer_length
+);
+GlanceRenderResult glance_render_markdown(const uint8_t *source_data, size_t source_length);
+GlanceRenderResult glance_render_notebook(const uint8_t *source_data, size_t source_length);
+GlanceRenderResult glance_parse_tsv(const uint8_t *data, size_t data_length);
+GlanceRenderResult glance_scan_zip(const uint8_t *path_data, size_t path_length);
+GlanceRenderResult glance_scan_tar(
+	const uint8_t *path_data,
+	size_t path_length,
+	bool is_gzipped
+);
+GlanceRenderResult glance_scan_seven_zip(const uint8_t *path_data, size_t path_length);
+void glance_render_buffer_free(uint8_t *data, size_t length);
+
+#endif
diff --git a/PreviewCore/src/error.rs b/PreviewCore/src/error.rs
new file mode 100644
index 0000000..6439abf
--- /dev/null
+++ b/PreviewCore/src/error.rs
@@ -0,0 +1,63 @@
+use std::fmt;
+
+#[derive(Debug)]
+pub(crate) struct RenderError(String);
+
+impl RenderError {
+    pub(crate) fn new(message: impl Into) -> Self {
+        Self(message.into())
+    }
+}
+
+impl fmt::Display for RenderError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.0)
+    }
+}
+
+impl std::error::Error for RenderError {}
+
+#[derive(Debug)]
+pub(crate) enum CoreError {
+    InvalidInput(String),
+    Parse(String),
+    Io(String),
+    ResourceLimit(String),
+    Unsupported(String),
+}
+
+impl CoreError {
+    pub(crate) fn invalid(message: impl Into) -> Self {
+        Self::InvalidInput(message.into())
+    }
+
+    pub(crate) fn parse(message: impl Into) -> Self {
+        Self::Parse(message.into())
+    }
+
+    pub(crate) fn io(message: impl Into) -> Self {
+        Self::Io(message.into())
+    }
+
+    pub(crate) fn limit(message: impl Into) -> Self {
+        Self::ResourceLimit(message.into())
+    }
+
+    pub(crate) fn unsupported(message: impl Into) -> Self {
+        Self::Unsupported(message.into())
+    }
+}
+
+impl fmt::Display for CoreError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::InvalidInput(message)
+            | Self::Parse(message)
+            | Self::Io(message)
+            | Self::ResourceLimit(message)
+            | Self::Unsupported(message) => formatter.write_str(message),
+        }
+    }
+}
+
+impl std::error::Error for CoreError {}
diff --git a/PreviewCore/src/ffi.rs b/PreviewCore/src/ffi.rs
new file mode 100644
index 0000000..e2161f2
--- /dev/null
+++ b/PreviewCore/src/ffi.rs
@@ -0,0 +1,365 @@
+use crate::error::{CoreError, RenderError};
+use std::ffi::OsStr;
+use std::os::unix::ffi::OsStrExt;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::path::Path;
+use std::ptr;
+use std::slice;
+use std::str;
+
+const STATUS_OK: i32 = 0;
+const STATUS_INVALID_INPUT: i32 = 1;
+const STATUS_PARSE_ERROR: i32 = 2;
+const STATUS_INTERNAL_ERROR: i32 = 3;
+const STATUS_IO_ERROR: i32 = 4;
+const STATUS_RESOURCE_LIMIT: i32 = 5;
+const STATUS_UNSUPPORTED: i32 = 6;
+
+#[repr(C)]
+pub struct GlanceRenderResult {
+    pub data: *mut u8,
+    pub length: usize,
+    pub status: i32,
+}
+
+/// Renders source code supplied as UTF-8 bytes.
+///
+/// # Safety
+///
+/// Each non-null pointer must be valid for reads of its paired length for the duration of this
+/// call. A null pointer is accepted only when its paired length is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_code(
+    source_data: *const u8,
+    source_length: usize,
+    lexer_data: *const u8,
+    lexer_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let source = utf8_input(source_data, source_length)?;
+        let lexer = utf8_input(lexer_data, lexer_length)?;
+        crate::highlight::render_code(source, lexer)
+            .map_err(CoreError::from)
+            .map(String::into_bytes)
+    })
+}
+
+/// Renders Markdown supplied as UTF-8 bytes.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `source_length` bytes for the duration of this call. A
+/// null pointer is accepted only when `source_length` is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_markdown(
+    source_data: *const u8,
+    source_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let source = utf8_input(source_data, source_length)?;
+        crate::markdown::render_markdown(source)
+            .map_err(CoreError::from)
+            .map(String::into_bytes)
+    })
+}
+
+/// Renders a Jupyter notebook supplied as UTF-8 JSON bytes.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `source_length` bytes for the duration of this call. A
+/// null pointer is accepted only when `source_length` is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_notebook(
+    source_data: *const u8,
+    source_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let source = utf8_input(source_data, source_length)?;
+        crate::notebook::render_notebook(source)
+            .map_err(CoreError::from)
+            .map(String::into_bytes)
+    })
+}
+
+/// Parses TSV bytes into a typed JSON payload.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `data_length` bytes for the duration of this call. A
+/// null pointer is accepted only when `data_length` is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_parse_tsv(
+    data: *const u8,
+    data_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let data = byte_input(data, data_length, "TSV")?;
+        json_bytes(&crate::tsv::parse_tsv(data)?)
+    })
+}
+
+/// Scans a ZIP/JAR/EAR/WAR archive at a raw filesystem path into a typed JSON payload.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `path_length` bytes for the duration of this call.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_scan_zip(
+    path_data: *const u8,
+    path_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe { json_bytes(&crate::zip::scan_zip(path_input(path_data, path_length)?)?) })
+}
+
+/// Scans a TAR or gzip-compressed TAR archive at a raw filesystem path.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `path_length` bytes for the duration of this call.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_scan_tar(
+    path_data: *const u8,
+    path_length: usize,
+    is_gzipped: bool,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        json_bytes(&crate::tar::scan_tar(
+            path_input(path_data, path_length)?,
+            is_gzipped,
+        )?)
+    })
+}
+
+/// Scans a 7z archive at a raw filesystem path into a typed JSON payload.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `path_length` bytes for the duration of this call.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_scan_seven_zip(
+    path_data: *const u8,
+    path_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        json_bytes(&crate::sevenzip::scan_seven_zip(path_input(
+            path_data,
+            path_length,
+        )?)?)
+    })
+}
+
+/// Releases a renderer result buffer.
+///
+/// # Safety
+///
+/// `data` and `length` must be the unchanged values returned together in one `GlanceRenderResult`.
+/// Each non-null buffer may be released exactly once.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_buffer_free(data: *mut u8, length: usize) {
+    if data.is_null() {
+        return;
+    }
+    let slice = ptr::slice_from_raw_parts_mut(data, length);
+    unsafe {
+        drop(Box::from_raw(slice));
+    }
+}
+
+fn ffi_call(operation: impl FnOnce() -> Result, CoreError>) -> GlanceRenderResult {
+    match catch_unwind(AssertUnwindSafe(operation)) {
+        Ok(Ok(output)) => result_from_bytes(output, STATUS_OK),
+        Ok(Err(error)) => {
+            let status = match error {
+                CoreError::InvalidInput(_) => STATUS_INVALID_INPUT,
+                CoreError::Parse(_) => STATUS_PARSE_ERROR,
+                CoreError::Io(_) => STATUS_IO_ERROR,
+                CoreError::ResourceLimit(_) => STATUS_RESOURCE_LIMIT,
+                CoreError::Unsupported(_) => STATUS_UNSUPPORTED,
+            };
+            result_from_bytes(error.to_string().into_bytes(), status)
+        }
+        Err(_) => result_from_bytes(
+            b"The Rust preview core stopped after an internal panic".to_vec(),
+            STATUS_INTERNAL_ERROR,
+        ),
+    }
+}
+
+fn result_from_bytes(bytes: Vec, status: i32) -> GlanceRenderResult {
+    if bytes.is_empty() {
+        return GlanceRenderResult {
+            data: ptr::null_mut(),
+            length: 0,
+            status,
+        };
+    }
+    let mut bytes = bytes.into_boxed_slice();
+    let result = GlanceRenderResult {
+        data: bytes.as_mut_ptr(),
+        length: bytes.len(),
+        status,
+    };
+    std::mem::forget(bytes);
+    result
+}
+
+/// Converts one validated FFI pointer/length pair into UTF-8.
+///
+/// # Safety
+///
+/// The returned reference must not outlive the enclosing FFI call. Its lifetime is not tied to
+/// the raw input pointer by the type system.
+unsafe fn utf8_input<'a>(data: *const u8, length: usize) -> Result<&'a str, CoreError> {
+    let bytes = unsafe { byte_input(data, length, "Renderer")? };
+    str::from_utf8(bytes)
+        .map_err(|error| CoreError::invalid(format!("Renderer input is not valid UTF-8: {error}")))
+}
+
+/// Converts one validated FFI pointer/length pair into bytes.
+///
+/// # Safety
+///
+/// The returned reference must not outlive the enclosing FFI call. Its lifetime is not tied to
+/// the raw input pointer by the type system.
+unsafe fn byte_input<'a>(
+    data: *const u8,
+    length: usize,
+    input_name: &str,
+) -> Result<&'a [u8], CoreError> {
+    if length == 0 {
+        return Ok(&[]);
+    }
+    if data.is_null() {
+        return Err(CoreError::invalid(format!(
+            "{input_name} input pointer is null for a non-empty buffer"
+        )));
+    }
+    Ok(unsafe { slice::from_raw_parts(data, length) })
+}
+
+unsafe fn path_input<'a>(data: *const u8, length: usize) -> Result<&'a Path, CoreError> {
+    let bytes = unsafe { byte_input(data, length, "Archive path")? };
+    if bytes.is_empty() {
+        return Err(CoreError::invalid("Archive path must not be empty"));
+    }
+    if bytes.contains(&0) {
+        return Err(CoreError::invalid("Archive path contains a null byte"));
+    }
+    Ok(Path::new(OsStr::from_bytes(bytes)))
+}
+
+fn json_bytes(value: &impl serde::Serialize) -> Result, CoreError> {
+    serde_json::to_vec(value)
+        .map_err(|error| CoreError::parse(format!("Could not encode preview payload: {error}")))
+}
+
+impl From for CoreError {
+    fn from(error: RenderError) -> Self {
+        Self::parse(error.to_string())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn take(result: GlanceRenderResult) -> (i32, String) {
+        let output = if result.length == 0 {
+            String::new()
+        } else {
+            let bytes = unsafe { slice::from_raw_parts(result.data, result.length) };
+            String::from_utf8(bytes.to_vec()).unwrap()
+        };
+        unsafe { glance_render_buffer_free(result.data, result.length) };
+        (result.status, output)
+    }
+
+    #[test]
+    fn returns_and_frees_successful_buffers() {
+        let input = b"# Heading";
+        let result = unsafe { glance_render_markdown(input.as_ptr(), input.len()) };
+        let (status, html) = take(result);
+        assert_eq!(status, STATUS_OK);
+        assert!(html.contains("

Heading

")); + } + + #[test] + fn validates_pointers_and_utf8() { + let result = unsafe { glance_render_markdown(ptr::null(), 1) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("null")); + + let invalid = [0xff]; + let result = unsafe { glance_render_markdown(invalid.as_ptr(), invalid.len()) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("UTF-8")); + + let malformed_notebook = b"not json"; + let result = unsafe { + glance_render_notebook(malformed_notebook.as_ptr(), malformed_notebook.len()) + }; + let (status, message) = take(result); + assert_eq!(status, STATUS_PARSE_ERROR); + assert!(message.contains("notebook JSON")); + } + + #[test] + fn accepts_empty_buffers() { + let result = unsafe { glance_render_markdown(ptr::null(), 0) }; + let (status, html) = take(result); + assert_eq!(status, STATUS_OK); + assert!(html.is_empty()); + + unsafe { glance_render_buffer_free(ptr::null_mut(), 0) }; + } + + #[test] + fn parses_tsv_and_validates_archive_paths() { + let input = b"name\tvalue\nhello\tworld\n"; + let result = unsafe { glance_parse_tsv(input.as_ptr(), input.len()) }; + let (status, json) = take(result); + assert_eq!(status, STATUS_OK); + assert!(json.contains("\"headers\":[\"name\",\"value\"]")); + + let result = unsafe { glance_scan_zip(ptr::null(), 1) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("null")); + + let result = unsafe { glance_scan_tar(ptr::null(), 0, false) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("must not be empty")); + } + + #[test] + fn reports_every_error_status_without_unwinding() { + let malformed = b"name\tvalue\nmissing\n"; + let result = unsafe { glance_parse_tsv(malformed.as_ptr(), malformed.len()) }; + assert_eq!(take(result).0, STATUS_PARSE_ERROR); + + let oversized = vec![b'a'; crate::tsv::MAX_FILE_SIZE + 1]; + let result = unsafe { glance_parse_tsv(oversized.as_ptr(), oversized.len()) }; + assert_eq!(take(result).0, STATUS_RESOURCE_LIMIT); + + let missing = Path::new("/definitely/missing/glance-preview.zip"); + let missing_bytes = missing.as_os_str().as_bytes(); + let result = unsafe { glance_scan_zip(missing_bytes.as_ptr(), missing_bytes.len()) }; + assert_eq!(take(result).0, STATUS_IO_ERROR); + + let encrypted = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../GlanceTests/TestFiles/archives/encrypted.7z"); + let encrypted_bytes = encrypted.as_os_str().as_bytes(); + let result = + unsafe { glance_scan_seven_zip(encrypted_bytes.as_ptr(), encrypted_bytes.len()) }; + assert_eq!(take(result).0, STATUS_UNSUPPORTED); + + let result = ffi_call(|| panic!("FFI panic test")); + let (status, message) = take(result); + assert_eq!(status, STATUS_INTERNAL_ERROR); + assert!(message.contains("internal panic")); + } +} diff --git a/PreviewCore/src/highlight.rs b/PreviewCore/src/highlight.rs new file mode 100644 index 0000000..f2922f1 --- /dev/null +++ b/PreviewCore/src/highlight.rs @@ -0,0 +1,243 @@ +use crate::error::RenderError; +use std::fmt; +use std::sync::OnceLock; +use two_face::re_exports::syntect::html::{ClassStyle, ClassedHTMLGenerator}; +use two_face::re_exports::syntect::parsing::{SyntaxReference, SyntaxSet}; +use two_face::re_exports::syntect::util::LinesWithEndings; + +static SYNTAX_SET: OnceLock = OnceLock::new(); + +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) +} + +pub(crate) fn render_code(source: &str, lexer: &str) -> Result { + let highlighted = render_code_body(source, lexer)?; + Ok(format!( + "
{highlighted}
" + )) +} + +pub(crate) fn render_code_body(source: &str, lexer: &str) -> Result { + let syntax_set = syntax_set(); + let syntax = select_syntax(syntax_set, source, lexer); + let mut generator = + ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, ClassStyle::Spaced); + + for line in LinesWithEndings::from(source) { + generator + .parse_html_for_line_which_includes_newline(line) + .map_err(|error| { + RenderError::new(format!("Could not highlight source code: {error}")) + })?; + } + + Ok(generator.finalize()) +} + +fn select_syntax<'a>(syntax_set: &'a SyntaxSet, source: &str, lexer: &str) -> &'a SyntaxReference { + if !lexer.is_empty() && lexer != "autodetect" { + let extension = lexer.trim_start_matches('.'); + for candidate in lexer_candidates(lexer) + .iter() + .copied() + .chain(std::iter::once(extension)) + { + if let Some(syntax) = syntax_set.find_syntax_by_token(candidate) { + return syntax; + } + if let Some(syntax) = syntax_set.find_syntax_by_extension(candidate) { + return syntax; + } + if let Some(syntax) = syntax_set.find_syntax_by_name(candidate) { + return syntax; + } + } + } + + let first_line = source.lines().next().unwrap_or_default(); + if let Some(syntax) = syntax_set.find_syntax_by_first_line(first_line) { + return syntax; + } + + let trimmed = source.trim_start(); + if (trimmed.starts_with('{') || trimmed.starts_with('[')) + && serde_json::from_str::(source).is_ok() + && let Some(syntax) = syntax_set.find_syntax_by_token("json") + { + return syntax; + } + if trimmed.starts_with('<') + && let Some(syntax) = syntax_set.find_syntax_by_token("xml") + { + return syntax; + } + + syntax_set.find_syntax_plain_text() +} + +fn lexer_candidates(lexer: &str) -> &'static [&'static str] { + match lexer.trim_start_matches('.').to_ascii_lowercase().as_str() { + "applescript" | "scpt" | "scptd" => &["AppleScript", "applescript"], + "bash" | "bashrc" | "zsh" | "zshrc" => &["Bash", "Shell-Unix-Generic", "sh"], + "c" => &["C", "c"], + "dockerfile" => &["Dockerfile"], + "elisp" => &["Lisp", "lisp"], + "gemfile" | "rakefile" => &["Ruby", "rb"], + "handlebars" => &["HTML (Handlebars)", "HTML", "html"], + "hcl" => &["Terraform", "HCL", "tf"], + "ini" => &["INI", "ini"], + "js" => &["JavaScript", "js"], + "json" => &["JSON", "json"], + "makefile" => &["Makefile"], + "pkgbuild" => &["Bash", "sh"], + "swift" => &["Swift", "swift"], + "tex" => &["LaTeX", "TeX", "tex"], + "txt" => &["Plain Text", "txt"], + "twig" => &["Jinja2", "HTML", "html"], + "vimrc" => &["VimL", "vim"], + "xml" => &["XML", "xml"], + "yaml" | "yml" => &["YAML", "yaml"], + _ => &[], + } +} + +pub(crate) struct MarkdownHighlighter; + +impl comrak::adapters::SyntaxHighlighterAdapter for MarkdownHighlighter { + fn write_highlighted( + &self, + output: &mut dyn fmt::Write, + language: Option<&str>, + code: &str, + ) -> fmt::Result { + let highlighted = + render_code_body(code, language.unwrap_or("autodetect")).map_err(|_| fmt::Error)?; + output.write_str(&highlighted) + } + + fn write_pre_tag<'a>( + &self, + output: &mut dyn fmt::Write, + _attributes: std::collections::HashMap<&'static str, std::borrow::Cow<'a, str>>, + ) -> fmt::Result { + output.write_str("
")
+    }
+
+    fn write_code_tag<'a>(
+        &self,
+        output: &mut dyn fmt::Write,
+        _attributes: std::collections::HashMap<&'static str, std::borrow::Cow<'a, str>>,
+    ) -> fmt::Result {
+        output.write_str("")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn highlights_explicit_and_detected_source() {
+        let empty = render_code("", "swift").unwrap();
+        assert_eq!(empty, "
"); + + let swift = render_code("let value = 42\n", "swift").unwrap(); + assert!(swift.starts_with("
"));
+        assert!(swift.contains("span"));
+
+        let unicode = render_code("let cafe = \"\u{2615}\"\n", "swift").unwrap();
+        assert!(unicode.contains('\u{2615}'));
+
+        let shell = render_code("#!/bin/zsh\necho hello\n", "autodetect").unwrap();
+        assert!(shell.contains("span"));
+
+        let json = render_code("{\"value\": 42}", "unknown-extension").unwrap();
+        assert!(json.contains("span"));
+
+        let rust = select_syntax(syntax_set(), "fn main() {}\n", "rs");
+        assert_ne!(rust.name, "Plain Text");
+
+        let xml = select_syntax(syntax_set(), "\n", "unknown-extension");
+        assert_ne!(xml.name, "Plain Text");
+
+        let plain = select_syntax(syntax_set(), "ordinary prose\n", "unknown-extension");
+        assert_eq!(plain.name, "Plain Text");
+    }
+
+    #[test]
+    fn escapes_source_html() {
+        let html = render_code("\n", "js").unwrap();
+        assert!(!html.contains("\n\n[bad](javascript:alert('bad'))\n")
+                .unwrap();
+        let lowercase = html.to_ascii_lowercase();
+        assert!(!lowercase.contains(",
+    pub rows: Vec>,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub(crate) struct ArchivePayload {
+    pub entries: Vec,
+    pub compressed_size: u64,
+    pub uncompressed_size: u64,
+    pub scanned_uncompressed_size: Option,
+    pub truncated: bool,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub(crate) struct ArchiveEntry {
+    pub path: String,
+    pub entry_type: ArchiveEntryType,
+    pub size: u64,
+    pub modified_unix_seconds: Option,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum ArchiveEntryType {
+    File,
+    Directory,
+    Other,
+}
diff --git a/PreviewCore/src/notebook.rs b/PreviewCore/src/notebook.rs
new file mode 100644
index 0000000..7c273dd
--- /dev/null
+++ b/PreviewCore/src/notebook.rs
@@ -0,0 +1,356 @@
+use crate::error::RenderError;
+use crate::highlight::render_code;
+use crate::markdown::render_markdown;
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use serde::Deserialize;
+use std::fmt::Write;
+
+#[derive(Debug, Default, Deserialize)]
+struct Notebook {
+    #[serde(default)]
+    cells: Vec,
+    #[serde(default)]
+    metadata: Metadata,
+    nbformat: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Metadata {
+    #[serde(default)]
+    language_info: LanguageInfo,
+    #[serde(default)]
+    kernelspec: KernelSpec,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct LanguageInfo {
+    file_extension: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct KernelSpec {
+    language: Option,
+    name: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Cell {
+    #[serde(default)]
+    cell_type: String,
+    execution_count: Option,
+    #[serde(default)]
+    source: NotebookText,
+    #[serde(default)]
+    outputs: Vec,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Output {
+    #[serde(default)]
+    output_type: String,
+    execution_count: Option,
+    #[serde(default)]
+    text: NotebookText,
+    #[serde(default)]
+    traceback: Vec,
+    #[serde(default)]
+    data: OutputData,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct OutputData {
+    #[serde(rename = "text/html")]
+    text_html: Option,
+    #[serde(rename = "application/pdf")]
+    application_pdf: Option,
+    #[serde(rename = "text/latex")]
+    text_latex: Option,
+    #[serde(rename = "image/svg+xml")]
+    image_svg: Option,
+    #[serde(rename = "image/png")]
+    image_png: Option,
+    #[serde(rename = "image/jpeg")]
+    image_jpeg: Option,
+    #[serde(rename = "text/markdown")]
+    text_markdown: Option,
+    #[serde(rename = "text/plain")]
+    text_plain: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(untagged)]
+enum NotebookText {
+    One(String),
+    Many(Vec),
+    #[default]
+    Missing,
+}
+
+impl NotebookText {
+    fn joined(&self) -> String {
+        match self {
+            Self::One(value) => value.clone(),
+            Self::Many(values) => values.concat(),
+            Self::Missing => String::new(),
+        }
+    }
+}
+
+pub(crate) fn render_notebook(source: &str) -> Result {
+    let notebook: Notebook = serde_json::from_str(source)
+        .map_err(|error| RenderError::new(format!("Could not parse notebook JSON: {error}")))?;
+    match notebook.nbformat {
+        None => {
+            return Err(RenderError::new(
+                "The provided Jupyter Notebook does not declare an nbformat version",
+            ));
+        }
+        Some(version) if version < 4 => {
+            return Err(RenderError::new(
+                "The provided Jupyter Notebook uses an old format; version 4 or newer is required",
+            ));
+        }
+        Some(_) => {}
+    }
+
+    let language = notebook_language(¬ebook);
+    let mut html = String::from("
"); + for cell in notebook.cells { + let input = render_cell_input(&cell, &language)?; + let cell_class = class_token(&cell.cell_type); + write!( + html, + "
{}
{input}
", + render_prompt(cell.execution_count) + ) + .expect("writing to a string cannot fail"); + + for output in cell.outputs { + let class_name = class_token(&output.output_type.replace('_', "-")); + let rendered = render_output(&output)?; + write!( + html, + "
{}
{rendered}
", + render_prompt(output.execution_count) + ) + .expect("writing to a string cannot fail"); + } + html.push_str("
"); + } + html.push_str("
"); + Ok(html) +} + +fn notebook_language(notebook: &Notebook) -> String { + notebook + .metadata + .language_info + .file_extension + .as_deref() + .map(|extension| extension.trim_start_matches('.').to_owned()) + .filter(|language| !language.is_empty()) + .or_else(|| notebook.metadata.kernelspec.language.clone()) + .or_else(|| notebook.metadata.kernelspec.name.clone()) + .unwrap_or_else(|| "autodetect".to_owned()) +} + +fn render_cell_input(cell: &Cell, language: &str) -> Result { + let source = cell.source.joined(); + match cell.cell_type.as_str() { + "markdown" => render_markdown(&source), + "code" => render_code(&source, language), + "raw" => Ok(format!("
{}
", escape_html(&source))), + _ => Ok(String::new()), + } +} + +fn render_output(output: &Output) -> Result { + Ok(match output.output_type.as_str() { + "display_data" => render_data_output(&output.data)?, + "execute_result" => render_data_output(&output.data)?, + "error" => render_error_output(output), + "stream" => format!("
{}
", escape_html(&output.text.joined())), + _ => String::new(), + }) +} + +fn class_token(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' { + character + } else { + '-' + } + }) + .collect() +} + +fn render_data_output(data: &OutputData) -> Result { + if let Some(value) = &data.text_html { + let html = value.joined(); + let unwrapped = html + .strip_prefix("
") + .and_then(|html| html.strip_suffix("
")) + .unwrap_or(&html); + return Ok(ammonia::Builder::default().clean(unwrapped).to_string()); + } + if data.application_pdf.is_some() { + return Ok("
PDF output
".to_owned()); + } + if data.text_latex.is_some() { + return Ok("
LaTeX output
".to_owned()); + } + if data.image_svg.is_some() { + return Ok("
SVG output
".to_owned()); + } + if let Some(value) = &data.image_png { + return render_image("png", &value.joined()); + } + if let Some(value) = &data.image_jpeg { + return render_image("jpeg", &value.joined()); + } + if let Some(value) = &data.text_markdown { + return render_markdown(&value.joined()); + } + if let Some(value) = &data.text_plain { + return Ok(format!("
{}
", escape_html(&value.joined()))); + } + Ok(String::new()) +} + +fn render_image(kind: &str, encoded: &str) -> Result { + let encoded = encoded + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + BASE64 + .decode(encoded.as_bytes()) + .map_err(|_| RenderError::new(format!("Invalid base64 data for image/{kind} output")))?; + Ok(format!( + "\"Notebook" + )) +} + +fn render_error_output(output: &Output) -> String { + if output.traceback.is_empty() { + return "
An unknown error occurred
".to_owned(); + } + + let converted = output + .traceback + .iter() + .map(|line| ansi_to_html::convert(line).unwrap_or_else(|_| escape_html(line))) + .collect::>() + .join("\n"); + format!("
{converted}
") +} + +fn render_prompt(execution_count: Option) -> String { + execution_count + .map(|count| format!("[{count}]:")) + .unwrap_or_default() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOTEBOOK: &str = r##"{ + "cells": [ + {"cell_type":"markdown","source":["# Heading"]}, + {"cell_type":"code","execution_count":1,"source":["print('hi')"],"outputs":[ + {"output_type":"stream","text":["\\n"]}, + {"output_type":"error","traceback":["\\u001b[31mError\\u001b[0m"]}, + {"output_type":"display_data","data":{"image/png":"aGVsbG8="}}, + {"output_type":"execute_result","execution_count":1,"data":{"text/html":["
safe
"]}} + ]}, + {"cell_type":"raw","source":""} + ], + "metadata":{"kernelspec":{"language":"python","name":"python3"}}, + "nbformat":4, + "nbformat_minor":4 + }"##; + + #[test] + fn renders_supported_cells_and_outputs_safely() { + let html = render_notebook(NOTEBOOK).unwrap(); + assert!(html.starts_with("
")); + assert!(html.contains("

Heading

")); + assert!(html.contains("cell-code")); + assert!(html.contains("output-stream")); + assert!(html.contains("data:image/png;base64,aGVsbG8=")); + assert!(html.contains("<unsafe>")); + assert!(html.contains("<raw>")); + assert!(!html.contains("