diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8015465..63340b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,33 +1,41 @@ name: CI - on: push: branches: - main - jobs: - publish: + ci: + name: CI runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - - name: Checkout repository + - name: Check out repository uses: actions/checkout@v4 - - name: Set up Deno - uses: denoland/setup-deno@v1 + uses: denoland/setup-deno@v2 with: - deno-version: v1.x - + deno-version: v2.x + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + registry-url: 'https://registry.npmjs.org' - name: Format run: deno fmt --check - - name: Lint run: deno lint - - - name: Check - run: deno task check - - - name: Publish to JSR - run: deno publish \ No newline at end of file + - name: Type-check + run: deno check --frozen **/*.ts + - name: Run tests + run: deno test --permit-no-files + - name: Generate jsr.json + run: deno run --allow-read=. --allow-run=deno scripts/generate-package-manifest.ts --type=jsr | tee jsr.json + - name: Dry run publish to JSR + run: deno publish --config=jsr.json --dry-run + - name: Generate package.json + run: deno run --allow-read=. --allow-run=deno scripts/generate-package-manifest.ts --type=npm | tee package.json + - name: Install dependencies + run: deno install + - name: Build + run: deno task --eval "tsc -b" + - name: Dry run publish to NPM + run: npm publish --dry-run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..84aa522 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +name: Publish +on: + release: + types: [published] +jobs: + publish-jsr: + name: Publish to JSR + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Generate jsr.json + run: deno run --allow-read=. --allow-run=deno scripts/generate-package-manifest.ts --type=jsr | tee jsr.json + - name: Publish to JSR + run: deno publish --config=jsr.json + publish-npm: + name: Publish to NPM + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + registry-url: 'https://registry.npmjs.org' + - name: Generate package.json + run: deno run --allow-read=. --allow-run=deno scripts/generate-package-manifest.ts --type=npm | tee package.json + - name: Install dependencies + run: deno install + - name: Build + run: deno task --eval "tsc -b" + - name: Publish to NPM + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index fff3a34..a68570a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -deno.lock +dist +jsr.json +package.json +node_modules +tsconfig.tsbuildinfo diff --git a/README.md b/README.md index 2368a24..8e0e9dd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -# zip +# @quentinadam/zip -[![JSR](https://jsr.io/badges/@quentinadam/zip)](https://jsr.io/@quentinadam/zip) -[![CI](https://github.com/quentinadam/deno-zip/actions/workflows/ci.yml/badge.svg)](https://github.com/quentinadam/deno-zip/actions/workflows/ci.yml) +[![JSR][jsr-image]][jsr-url] [![NPM][npm-image]][npm-url] [![CI][ci-image]][ci-url] A library for creating and extracting ZIP archives. @@ -21,3 +20,10 @@ for (const { name, data } of await zip.extract(buffer)) { console.log(name, new TextDecoder().decode(data)); } ``` + +[ci-image]: https://img.shields.io/github/actions/workflow/status/quentinadam/deno-zip/ci.yml?branch=main&logo=github&style=flat-square +[ci-url]: https://github.com/quentinadam/deno-zip/actions/workflows/ci.yml +[npm-image]: https://img.shields.io/npm/v/@quentinadam/zip.svg?style=flat-square +[npm-url]: https://npmjs.org/package/@quentinadam/zip +[jsr-image]: https://jsr.io/badges/@quentinadam/zip?style=flat-square +[jsr-url]: https://jsr.io/@quentinadam/zip diff --git a/deno.json b/deno.json index 65ab85f..d7769f8 100644 --- a/deno.json +++ b/deno.json @@ -1,23 +1,21 @@ { "name": "@quentinadam/zip", "version": "0.1.3", + "description": "A library for creating and extracting ZIP archives", "license": "MIT", - "exports": "./zip.ts", + "exports": "./src/zip.ts", "imports": { - "@quentinadam/assert": "jsr:@quentinadam/assert@^0.1.7", - "@quentinadam/uint8array-extension": "jsr:@quentinadam/uint8array-extension@^0.1.4" - }, - "publish": { - "exclude": [ - ".github/", - ".vscode/" - ] + "@quentinadam/assert": "jsr:@quentinadam/assert@^0.1.10", + "@quentinadam/require": "jsr:@quentinadam/require@^0.1.4", + "@quentinadam/uint8array-extension": "jsr:@quentinadam/uint8array-extension@^0.1.5", + "typescript": "npm:typescript@^5.7.2" }, "fmt": { "singleQuote": true, "lineWidth": 120 }, - "tasks": { - "check": "deno check **/*.ts" + "compilerOptions": { + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true } } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..e072159 --- /dev/null +++ b/deno.lock @@ -0,0 +1,39 @@ +{ + "version": "4", + "specifiers": { + "jsr:@quentinadam/assert@~0.1.10": "0.1.10", + "jsr:@quentinadam/require@~0.1.4": "0.1.4", + "jsr:@quentinadam/uint8array-extension@~0.1.5": "0.1.5", + "npm:typescript@^5.7.2": "5.7.2" + }, + "jsr": { + "@quentinadam/assert@0.1.10": { + "integrity": "623eb01c4eb8bbe6420abcbc06792dbd5ca4301c270ac04b3745deac099f70df" + }, + "@quentinadam/require@0.1.4": { + "integrity": "ef2a475d3fc241817470e540bd892ac23520d620ffb23b7796c940df31391c72", + "dependencies": [ + "jsr:@quentinadam/assert" + ] + }, + "@quentinadam/uint8array-extension@0.1.5": { + "integrity": "d7ccf8c8841947c7410c2db3249eb88a77bc1677b2a1c945971b8ead57e725b4", + "dependencies": [ + "jsr:@quentinadam/assert" + ] + } + }, + "npm": { + "typescript@5.7.2": { + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@quentinadam/assert@~0.1.10", + "jsr:@quentinadam/require@~0.1.4", + "jsr:@quentinadam/uint8array-extension@~0.1.5", + "npm:typescript@^5.7.2" + ] + } +} diff --git a/scripts/generate-package-manifest.ts b/scripts/generate-package-manifest.ts new file mode 100644 index 0000000..713a23d --- /dev/null +++ b/scripts/generate-package-manifest.ts @@ -0,0 +1,154 @@ +type Graph = { + version: number; + roots: string[]; + modules: ({ kind: 'external'; specifier: string } | { + kind: 'esm'; + dependencies?: { specifier: string; code?: { specifier: string } }[]; + specifier: string; + })[]; +}; + +function assert(value: boolean): asserts value { + if (value !== true) { + throw new Error('Assertion failed'); + } +} + +function require(value: T | undefined | null): T { + assert(value !== undefined && value !== null); + return value; +} + +function parsePackageSpecifier(specifier: string) { + // deno-fmt-ignore + const regex = /^(:?(?jsr|npm):\/?)?(?(?:@[a-zA-Z0-9_\-]+\/)?[a-zA-Z0-9_\-]+)(?:@(?(?:\*|(?:\^|~|[<>]=?)?\d+(?:\.\d+)*)))?(?(\/[^\/]+)+)?$/; + const match = specifier.match(regex); + if (match !== null) { + const groups = require(match.groups); + const registry = groups.registry; + const name = require(groups.name); + const version = groups.version; + const path = groups.path; + return { registry, name, version, path }; + } + return undefined; +} + +class GraphAnalyzer { + readonly #graph: Graph; + readonly #specifiers: Set; + + constructor(graph: Graph, specifiers: Set) { + this.#graph = graph; + this.#specifiers = specifiers; + } + + analyze(specifier: string) { + const module = require(this.#graph.modules.find((module) => module.specifier === specifier)); + if (module.kind === 'esm' && module.dependencies !== undefined) { + for (const dependency of module.dependencies) { + const parsedPackageSpecifier = parsePackageSpecifier(dependency.specifier); + if (parsedPackageSpecifier !== undefined) { + this.#specifiers.add(parsedPackageSpecifier.name); + } else if (dependency.code !== undefined) { + this.analyze(dependency.code.specifier); + } + } + } + } +} + +export default async function getExportsDependencies() { + const exports = require(configurationFile.exports); + const exportedPaths = (typeof exports === 'string') ? [exports] : Object.values(exports); + const specifiers = new Set(); + for (const path of exportedPaths) { + const command = new Deno.Command('deno', { args: ['info', '--json', path] }); + const { code, stdout, stderr } = await command.output(); + if (code !== 0) { + throw new Error(new TextDecoder().decode(stderr)); + } + const graph = JSON.parse(new TextDecoder().decode(stdout)) as Graph; + const analyzer = new GraphAnalyzer(graph, specifiers); + analyzer.analyze(require(graph.roots[0])); + } + if (specifiers.size > 0) { + const imports = configurationFile.imports as Record | undefined; + assert(imports !== undefined); + return Array.from(specifiers).toSorted().map((specifier) => { + const parsedPackageSpecifier = require(parsePackageSpecifier(require(imports[specifier]))); + const { registry, name, version } = parsedPackageSpecifier; + assert(registry !== undefined); + assert(version !== undefined); + return { registry, name, version }; + }); + } + return []; +} + +type ConfigurationFile = { + name: string; + version: string; + description: string; + license: string; + exports?: string | Record; + imports?: Record; +}; + +const type = (() => { + try { + return require(require(require(Deno.args[0]).match(/^--type=(jsr|npm)$/))[1]); + } catch { + throw new Error('Missing or invalid type argument'); + } +})(); + +const configurationFile: ConfigurationFile = JSON.parse(Deno.readTextFileSync('deno.json')); + +const dependencies = await getExportsDependencies(); + +const scopedName = require(configurationFile.name); +const name = require(scopedName.split('/')[1]); + +const manifest = (() => { + if (type === 'jsr') { + return { + name: scopedName, + version: require(configurationFile.version), + license: require(configurationFile.license), + exports: require(configurationFile.exports), + publish: { include: ['src', 'README.md'], exclude: ['**/*.test.ts'] }, + imports: dependencies.length > 0 + ? Object.fromEntries(dependencies.map(({ name, registry, version }) => { + return [name, `${registry}:${name}@${version}`]; + })) + : undefined, + }; + } + if (type === 'npm') { + return { + name: scopedName, + version: require(configurationFile.version), + description: require(configurationFile.description), + license: require(configurationFile.license), + author: 'Quentin Adam', + repository: { type: 'git', url: `git+https://github.com/quentinadam/deno-${name}.git` }, + type: 'module', + exports: ((exports) => { + const replaceFn = (path: string) => path.replace(/^\.\/src\//, './dist/').replace(/\.ts$/, '.js'); + if (typeof exports === 'string') { + return replaceFn(exports); + } else { + return Object.fromEntries(Object.entries(exports).map(([key, value]) => [key, replaceFn(value)])); + } + })(require(configurationFile.exports)), + files: ['dist', 'README.md'], + dependencies: dependencies.length > 0 + ? Object.fromEntries(dependencies.map(({ name, version }) => [name, version])) + : undefined, + }; + } + throw new Error(`Invalid type ${type}`); +})(); + +console.log(JSON.stringify(manifest, null, 2)); diff --git a/crc32.ts b/src/crc32.ts similarity index 74% rename from crc32.ts rename to src/crc32.ts index a7b12db..e0b26ca 100644 --- a/crc32.ts +++ b/src/crc32.ts @@ -1,3 +1,5 @@ +import require from '@quentinadam/require'; + const POLYNOMIAL = -306674912; const TABLE = /* @__PURE__ */ (() => { @@ -13,8 +15,8 @@ const TABLE = /* @__PURE__ */ (() => { })(); export default function crc32(buffer: Uint8Array, crc = 0xFFFFFFFF) { - for (let i = 0; i < buffer.length; ++i) { - crc = TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8); + for (const byte of buffer) { + crc = require(TABLE[(crc ^ byte) & 0xff]) ^ (crc >>> 8); } return (crc ^ -1) >>> 0; } diff --git a/deflate.ts b/src/deflate.ts similarity index 90% rename from deflate.ts rename to src/deflate.ts index 8832725..24ba246 100644 --- a/deflate.ts +++ b/src/deflate.ts @@ -1,4 +1,4 @@ -import Uint8ArrayExtension from '@quentinadam/uint8array-extension'; +import * as Uint8ArrayExtension from '@quentinadam/uint8array-extension'; async function transform(stream: TransformStream, data: Uint8Array) { const writer = stream.writable.getWriter(); diff --git a/zip.ts b/src/zip.ts similarity index 100% rename from zip.ts rename to src/zip.ts index 201a005..c644f8f 100644 --- a/zip.ts +++ b/src/zip.ts @@ -1,5 +1,5 @@ -import Uint8ArrayExtension from '@quentinadam/uint8array-extension'; import assert from '@quentinadam/assert'; +import Uint8ArrayExtension from '@quentinadam/uint8array-extension'; import crc32 from './crc32.ts'; import { compress, decompress } from './deflate.ts'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a1b24ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "rootDir": "src", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noUncheckedSideEffectImports": true, + "declaration": true, + "outDir": "dist", + "noEmitOnError": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "skipDefaultLibCheck": false, + "skipLibCheck": false + }, + "include": ["src"], + "exclude": ["**/*.test.ts"] +}