diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a37c368 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: codeql + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + analyze: + name: analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [ 'javascript' ] + steps: + - name: checkout + uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 20 + - name: initialize codeql + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + - name: build + run: | + npm install + bash scripts/build.sh + - name: analyze + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/cover.yml b/.github/workflows/cover.yml new file mode 100644 index 0000000..cef2ae1 --- /dev/null +++ b/.github/workflows/cover.yml @@ -0,0 +1,26 @@ +name: cover + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + cover: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 20 + - name: cover + run: | + npm install + npm run cover + - name: upload report + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..cc274b3 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,27 @@ +name: publish +on: + workflow_dispatch: { } + push: + branches: + - main +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + - name: build + run: | + cd docs + npm install + npm run build + - uses: actions/configure-pages@v3 + - uses: actions/upload-pages-artifact@v2 + with: + path: .vitepress/dist + - uses: actions/deploy-pages@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a4eea08..ad925bb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,4 +13,4 @@ jobs: - uses: actions/checkout@v2 - name: editorconfig run: | - docker run --rm --volume=$PWD:/check mstruebing/editorconfig-checker ec --exclude .git + docker run --rm --volume=$PWD:/check mstruebing/editorconfig-checker ec --exclude "\.git|*\.blend" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..aa325ec --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,24 @@ +name: publish + +on: + release: + types: [ published ] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + registry-url: "https://npm.pkg.github.com" + - name: build + run: | + npm install + bash scripts/build.sh + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GH_KEY }} diff --git a/README.md b/README.md index b4058ad..59f42f7 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,30 @@

npm - codefactor - codecov - codeql + codefactor + codecov + codeql


-# imgit +# Convert media links to optimized HTML -Optimize images and video generated from markdown. +Images, video and YouTube: fetch, encode, scale, lazyload – for best UX and [Web Vitals](https://web.dev/vitals). + +✨ Builds optimized HTML for arbitrary image, video and YouTube syntax, such as URLs, markdown or JSX tags. + +⚡ Encodes to the modern AV1/AVIF format compressing by up to 90% without noticeable quality loss. Supports GPU acceleration. + +♻️ Works with most known media formats: JPEG, PNG, APNG, SVG, GIF, WEBP, WEBM, MP4, AVI, MOV, MKV, BMP, TIFF, TGA and even PSD. + +🌊 Generates tiny blurred covers from the source content to be beautifully crossfaded into HD originals once lazy-loaded. + +📐 Optionally scales down the content to specified threshold while preserving high-resolution variants for high-DPI displays. + +🌐 Fetches from remote sources, such as image hostings. Uploads optimized content to designated endpoint, such as CDN. + +🗺️ Built-in plugins for Astro, SvelteKit, SolidStart, VitePress, Nuxt and Remix. Adapters for Node, Deno and Bun runtimes. + +### 🎬 Get Started + +http://imgit.dev/guide diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..06cf653 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +cache diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..b98e491 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,76 @@ +import { defineConfig } from "vitepress"; +import md from "./md"; +import escapeCode from "./escape-code"; +import imgit from "imgit/vite"; +import svg from "imgit/svg"; +import youtube from "imgit/youtube"; + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "imgit", + titleTemplate: ":title • imgit", + appearance: "dark", + cleanUrls: true, + lastUpdated: true, + markdown: md, + vite: { plugins: [imgit({ width: 688, plugins: [svg(), youtube(), escapeCode] })] }, + head: [ + ["link", { rel: "icon", href: "/favicon.svg" }], + ["link", { rel: "preload", href: "/fonts/inter.woff2", as: "font", type: "font/woff2", crossorigin: "" }], + ["link", { rel: "preload", href: "/fonts/jb.woff2", as: "font", type: "font/woff2", crossorigin: "" }], + ["meta", { name: "theme-color", content: "#ee3248" }], + ["meta", { name: "og:image", content: "/img/og.jpg" }], + ["meta", { name: "twitter:card", content: "summary_large_image" }] + ], + themeConfig: { + logo: { src: "/favicon.svg" }, + logoLink: "/", + socialLinks: [{ icon: "github", link: "https://github.com/elringus/imgit" }], + search: { provider: "local" }, + lastUpdated: { text: "Updated", formatOptions: { dateStyle: "medium" } }, + sidebarMenuLabel: "Menu", + darkModeSwitchLabel: "Appearance", + returnToTopLabel: "Return to top", + outline: { label: "On this page", level: "deep" }, + docFooter: { prev: "Previous page", next: "Next page" }, + nav: [ + { text: "Guide", link: "/guide/", activeMatch: "/guide/" }, + { + text: `v${(await import("./../../package.json")).version}`, items: [ + { text: "Changes", link: "https://github.com/elringus/imgit/releases/latest" }, + { text: "Contribute", link: "https://github.com/elringus/imgit/labels/help%20wanted" } + ] + } + ], + editLink: { + pattern: "https://github.com/elringus/imgit/edit/main/docs/:path", + text: "Edit this page on GitHub" + }, + sidebar: { + "/guide/": [ + { + text: "Guide", + items: [ + { text: "Introduction", link: "/guide/" }, + { text: "Getting Started", link: "/guide/getting-started" }, + { text: "GPU Acceleration", link: "/guide/gpu-acceleration" }, + { text: "Plugins", link: "/guide/plugins" } + ] + }, + { + text: "Integrations", + items: [ + { text: "Vite", link: "/guide/integrations/vite" }, + { text: "Astro", link: "/guide/integrations/astro" }, + { text: "Nuxt", link: "/guide/integrations/nuxt" }, + { text: "Remix", link: "/guide/integrations/remix" }, + { text: "SolidStart", link: "/guide/integrations/solid" }, + { text: "SvelteKit", link: "/guide/integrations/svelte" }, + { text: "VitePress", link: "/guide/integrations/vitepress" } + ] + } + ] + } + }, + sitemap: { hostname: "https://imgit.dev" } +}); diff --git a/docs/.vitepress/escape-code.ts b/docs/.vitepress/escape-code.ts new file mode 100644 index 0000000..76ebc42 --- /dev/null +++ b/docs/.vitepress/escape-code.ts @@ -0,0 +1,50 @@ +import { Plugin, CapturedAsset, stages } from "imgit/server"; + +type Range = { start: number; end: number; }; + +export default { capture } satisfies Plugin; + +// Remove captures inside Markdown code blocks (```code```). +function capture(content: string, assets: CapturedAsset[]): boolean { + stages.capture.capture(content, assets); + if (assets.length === 0) return true; + const ranges = findCodeRanges(content); + if (ranges.length > 0) + assets.splice(0, assets.length, ...assets.filter(a => !isInCodeBlock(a, ranges))); + return true; +} + +function findCodeRanges(content: string): Range[] { + const ranges = new Array(); + let tickCount = 0; + let openIndex = -1; + for (let i = 0; i < content.length; i++) + if (content[i] === "`") handleTick(i); + else tickCount = 0; + return ranges; + + function handleTick(index: number): void { + if (++tickCount < 3) return; + if (openIndex === -1) openRange(index); + else closeRange(index); + } + + function openRange(index: number): void { + openIndex = index; + tickCount = 0; + } + + function closeRange(index: number): void { + ranges.push({ start: openIndex, end: index }); + openIndex = -1; + tickCount = 0; + } +} + +function isInCodeBlock(asset: CapturedAsset, ranges: Range[]): boolean { + for (const range of ranges) + if (asset.syntax.index >= range.start && + (asset.syntax.index + asset.syntax.text.length) <= range.end) + return true; + return false; +} diff --git a/docs/.vitepress/md/index.ts b/docs/.vitepress/md/index.ts new file mode 100644 index 0000000..44e0873 --- /dev/null +++ b/docs/.vitepress/md/index.ts @@ -0,0 +1,11 @@ +import { MarkdownOptions, MarkdownRenderer } from "vitepress"; +import { AppendIconToExternalLinks } from "./md-link"; + +export default { + config: installPlugins, + attrs: { disable: true } // https://github.com/vuejs/vitepress/issues/2440 +} satisfies MarkdownOptions; + +function installPlugins(md: MarkdownRenderer) { + md.use(AppendIconToExternalLinks); +} diff --git a/docs/.vitepress/md/md-link.ts b/docs/.vitepress/md/md-link.ts new file mode 100644 index 0000000..55e5371 --- /dev/null +++ b/docs/.vitepress/md/md-link.ts @@ -0,0 +1,30 @@ +import type { MarkdownRenderer } from "vitepress"; +import type { RenderRule } from "markdown-it/lib/renderer"; + +export function AppendIconToExternalLinks(md: MarkdownRenderer) { + const renderToken: RenderRule = (tokens, idx, options, env, self) => self.renderToken(tokens, idx, options); + const defaultLinkOpenRenderer = md.renderer.rules.link_open || renderToken; + const defaultLinkCloseRenderer = md.renderer.rules.link_close || renderToken; + let externalLinkOpen = false; + + md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const href = token.attrGet("href"); + + if (href) { + token.attrJoin("class", "vp-link"); + if (/^((ht|f)tps?):\/\/?/.test(href)) + externalLinkOpen = true; + } + + return defaultLinkOpenRenderer(tokens, idx, options, env, self); + }; + + md.renderer.rules.link_close = (tokens, idx, options, env, self) => { + if (externalLinkOpen) { + externalLinkOpen = false; + return ` ↗${self.renderToken(tokens, idx, options)}`; + } + return defaultLinkCloseRenderer(tokens, idx, options, env, self); + }; +} diff --git a/docs/.vitepress/md/md-replacer.ts b/docs/.vitepress/md/md-replacer.ts new file mode 100644 index 0000000..6c242ce --- /dev/null +++ b/docs/.vitepress/md/md-replacer.ts @@ -0,0 +1,39 @@ +// Based on https://github.com/rlidwka/markdown-it-regexp. + +import { inherits } from "node:util"; +import { MarkdownEnv, MarkdownRenderer } from "vitepress"; + +let instanceId = 0; + +export function Replacer(regexp: RegExp, replace: (match: string[], env: MarkdownEnv) => string) { + let self: any = (md: any) => self.init(md); + self.__proto__ = Replacer.prototype; + self.regexp = new RegExp("^" + regexp.source, regexp.flags); + self.replace = replace; + self.id = `md-replacer-${instanceId}`; + instanceId++; + return self; +} + +inherits(Replacer, Function); + +Replacer.prototype.init = function (md: MarkdownRenderer) { + md.inline.ruler.push(this.id, this.parse.bind(this)); + md.renderer.rules[this.id] = this.render.bind(this); +}; + +Replacer.prototype.parse = function (state: any, silent: any) { + let match = this.regexp.exec(state.src.slice(state.pos)); + if (!match) return false; + + state.pos += match[0].length; + if (silent) return true; + + let token = state.push(this.id, "", 0); + token.meta = { match: match }; + return true; +}; + +Replacer.prototype.render = function (tokens: any, id: any, options: any, env: MarkdownEnv) { + return this.replace(tokens[id].meta.match, env); +}; diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..d299c08 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,12 @@ +import DefaultTheme from "vitepress/theme-without-fonts"; +import "./style.css"; + +// Have to import client assets manually due to vitepress +// bug: https://github.com/vuejs/vitepress/issues/3314 +import "imgit/styles"; +import "imgit/styles/youtube"; +import "imgit/client"; +import "imgit/client/youtube"; + +// https://vitepress.dev/guide/extending-default-theme +export default { extends: { Layout: DefaultTheme.Layout } }; diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 0000000..bef8bd8 --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,187 @@ +/** https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css */ + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + /* noinspection CssUnknownTarget */ + src: url(/fonts/inter.woff2) format("woff2"); +} + +@font-face { + font-family: "JetBrains"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + /* noinspection CssUnknownTarget */ + src: url(/fonts/jb.woff2) format("woff2"); +} + +:root { + --vp-font-family-base: "Inter", sans-serif; + --vp-font-family-mono: "JetBrains", monospace; + font-optical-sizing: auto; + + /* Main highlight/link color (light mode). */ + --vp-c-brand-1: #d22c40; + /* Link hover color (light mode). */ + --vp-c-brand-2: #be2033; +} + +.dark { + /* Main highlight/link color (dark mode). */ + --vp-c-brand-1: #ee3248; + /* Link hover color (dark mode). */ + --vp-c-brand-2: #f35d44; +} + +.dark .dark-only, +.light-only { + display: flex !important; +} + +.dark .light-only, +.dark-only { + display: none !important; +} + +.attr { + display: block; + text-align: right; + font-size: 11px; + opacity: 0.65; + transition: opacity 0.25s; + font-family: "JetBrains", monospace; + user-select: none; +} + +.attr:hover { + opacity: 1; +} + +pre { + font-variant-ligatures: none; +} + +.vp-doc a { + text-underline-position: from-font; +} + +.external-link-icon { + margin-left: -1px; + margin-right: 2px; +} + +details summary { + font-weight: 500; + color: var(--vp-c-brand-1); + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.25s, opacity 0.25s; + text-underline-position: from-font; + cursor: pointer; +} + +details summary:hover { + color: var(--vp-c-brand-2); +} + +.vp-doc th, .vp-doc td, .vp-doc tr { + border: inherit !important; +} + +table { + --border: 1px solid var(--vp-c-divider); + width: fit-content; + border-radius: 8px; + border-spacing: 0; + border-collapse: separate !important; + border: var(--border) !important; + overflow: hidden; +} + +table th:not(:last-child), +table td:not(:last-child) { + border-right: var(--border) !important; +} + +table > thead > tr:not(:last-child) > th, +table > thead > tr:not(:last-child) > td, +table > tbody > tr:not(:last-child) > th, +table > tbody > tr:not(:last-child) > td, +table > tfoot > tr:not(:last-child) > th, +table > tfoot > tr:not(:last-child) > td, +table > tr:not(:last-child) > td, +table > tr:not(:last-child) > th, +table > thead:not(:last-child), +table > tbody:not(:last-child), +table > tfoot:not(:last-child) { + border-bottom: var(--border) !important; +} + +/* Stretch YouTube embeds with small thumbnails. */ +.imgit-youtube { + width: 100% !important; +} + +[data-imgit-container], +[data-imgit-container] img, +[data-imgit-container] video { + border-radius: 8px; +} + +/** + * Navbar blur effect + * -------------------------------------------------------------------------- */ + +/* remove default opaque bg from navbar */ +.content-body { + background: none !important; +} + +/* remove opacity gradient under navbar */ +.curtain, +.aside-curtain { + display: none !important; +} + +/* a hack for logo title to keep sidebar bg color */ +@media (min-width: 960px) { + .VPNavBar.has-sidebar div.title { + background: var(--vp-sidebar-bg-color) !important; + } +} + +/* a bit of tint to make navbar not as transparent */ +.VPNavBar:not(.top) { + background: rgba(255, 255, 255, 0.75) !important; +} + +.dark .VPNavBar:not(.top) { + background: rgba(30, 30, 32, 0.5) !important; +} + +/* the blur effect */ +.VPNavBar:not(.top)::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + backdrop-filter: saturate(180%) blur(5px); + z-index: -1; +} + +.VPNavBar.top::after { + opacity: 0; +} + +.VPNavBar.top { + background-color: transparent; +} + +.VPNavBar { + transition: border-bottom-color 0.25s, background-color 0.25s, opacity 0.25s; +} diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..7245626 --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,123 @@ +# Getting Started + +Make sure [ffmpeg](https://ffmpeg.org) version 6.0 or later is available in system path. You can either [build from source](https://trac.ffmpeg.org/wiki/CompilationGuide) or install from a package manager: + +::: code-group + +```sh [Linux] +apt install ffmpeg +``` + +```sh [Mac] +brew install ffmpeg +``` + +```sh [Windows] +choco install ffmpeg +``` + +::: + +::: tip +It's possible to swap ffmpeg with an alternative solution (eg, remote encoding service) via probe and encode hooks, allowing to use imgit in constraint environments, such as edge runtimes. +::: + +Install imgit as a dev dependency from NPM: + +::: code-group + +```sh [npm] +npm install -D imgit +``` + +```sh [yarn] +yarn add -D imgit +``` + +```sh [pnpm] +pnpm add -D imgit +``` + +```sh [bun] +bun add -D imgit +``` + +::: + +When using any of the supported web frameworks continue on the dedicated page: + + - [Astro](/guide/integrations/astro) + - [Nuxt](/guide/integrations/nuxt) + - [Remix](/guide/integrations/remix) + - [SolidStart](/guide/integrations/solid) + - [SvelteKit](/guide/integrations/svelte) + - [VitePress](/guide/integrations/vitepress) + +In case your framework is not on the list, but supports Vite plugins, continue on [Vite](/guide/integrations/vite). + +Otherwise, use imgit directly to transform source documents. For example, giving following `./index.html` file: + +```html + + + + + + + + + +![](https://github.com/elringus/imgit/raw/main/samples/assets/png.png) +![](https://github.com/elringus/imgit/raw/main/samples/assets/mp4.mp4) +![](https://www.youtube.com/watch?v=arbuYnJoLtU) + + + + + +``` + +Run following script: + +```js +import { boot, transform, exit } from "imgit/server"; +import fs from "node:fs/promises"; + +// Configure imgit server. In this case we're setting width threshold +// to 800px, so that when content is larger it'll be scaled down, +// while high-res original will still be shown on high-dpi displays. +await boot({ width: 800 }); + +// Read sample HTML document with images and video referenced +// via markdown image tags: ![](url). The format can be changed +// in boot config, for example to capture custom JSX tags instead. +const input = await fs.readFile("./index.html", { encoding: "utf8" }); + +// Run the imgit transformations over sample HTML content. +// This will capture images and video syntax, fetch the remote files, +// encode them to AV1/AVIF, generate covers, dense and safe variants +// when necessary, serve generated files (in this minimal case we just +// write them to 'public' directory; usually you'd upload to a CDN) and +// return transformed content where captured syntax is replaced with +// and