diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 3e811fa8..00000000 --- a/.eslintignore +++ /dev/null @@ -1,13 +0,0 @@ -# /node_modules/* and /bower_components/* in the project root are ignored by default - -# Ignore built files -browser/ - -# Ignore generated coverage directory -coverage/ - -# Ignore documentation site scaffold -documentation/ - -# Ignore local testing script -test.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index fe28b0d0..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,76 +0,0 @@ -module.exports = { - 'env': { - 'es6': true, - 'node': true, - }, - 'extends': 'eslint:recommended', - 'parserOptions': { - 'ecmaVersion': 2018, - }, - 'rules': { - 'array-bracket-spacing': [ 'error', 'always' ], - 'arrow-parens': [ 'error', 'as-needed', { 'requireForBlockBody': true } ], - 'arrow-spacing': [ 'error', { - 'before': true, - 'after': true, - } ], - 'block-spacing': [ 'error' ], - 'comma-dangle': [ 'error', 'always-multiline' ], - 'comma-spacing': [ 'error', { - 'before': false, - 'after': true, - } ], - 'eol-last': [ 'error', 'unix' ], - 'eqeqeq': [ 'error' ], - 'func-call-spacing': [ 'error' ], - 'indent': [ 'error', 'tab' ], - 'key-spacing': [ 'error', { - 'beforeColon': false, - 'afterColon': true, - } ], - 'linebreak-style': [ 'error', 'unix' ], - 'no-console': [ 'warn' ], - 'no-mixed-spaces-and-tabs': [ 'error', 'smart-tabs' ], - 'no-multiple-empty-lines': [ 'error', { - 'max': 1, - } ], - 'no-var': [ 'error' ], - 'object-curly-newline': [ 'error', { - 'ObjectExpression': { - 'consistent': true, - 'minProperties': 2, - 'multiline': true, - }, - 'ObjectPattern': { - 'consistent': true, - 'multiline': true, - }, - 'ImportDeclaration': { - 'consistent': true, - 'multiline': true, - }, - 'ExportDeclaration': { - 'consistent': true, - 'minProperties': 2, - 'multiline': true, - }, - } ], - 'object-curly-spacing': [ 'error', 'always' ], - 'object-property-newline': [ 'error' ], - 'prefer-arrow-callback': [ 'error' ], - 'prefer-const': [ 'error' ], - 'quotes': [ 'error', 'single' ], - 'semi': [ 'error', 'always' ], - 'semi-spacing': [ 'error', { - 'before': false, - 'after': true, - } ], - 'space-before-function-paren': [ 'error', { - 'anonymous': 'never', - 'asyncArrow': 'always', - 'named': 'never', - } ], - 'space-in-parens': [ 'error', 'always' ], - 'yoda': [ 'error', 'never' ], - }, -}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a7460ea5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + test: + name: Lint, typecheck, build & unit test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [ 24, latest ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + # The precomputed route tree and handler typings are generated from + # default-routes.json and committed; fail if they are stale. + - run: | + npm run precompute-routes + git diff --exit-code lib/data/default-route-tree.json lib/data/default-route-handlers.ts + - run: npm run build + - run: npm run test:unit + - if: matrix.node-version == 'latest' + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + retention-days: 1 + + # Smokes the built artifact under a production-only install (no devDependencies), + # which the test job above cannot catch: it verifies dist/ is self-contained and + # requires nothing outside the declared runtime dependencies. + dist-smoke: + name: Published dist works on Node ${{ matrix.node-version }} + runs-on: ubuntu-latest + needs: test + strategy: + matrix: + node-version: [ 24 ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci --omit=dev + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - run: | + node -e " + const WPAPI = require('./dist/index.cjs'); + const wp = new WPAPI({ endpoint: 'http://localhost/wp-json' }); + if (typeof wp.posts !== 'function') throw new Error('CJS entry did not bootstrap'); + if (typeof WPAPI.transport.get !== 'function') throw new Error('CJS entry has no bound transport'); + " + node --input-type=module -e " + import WPAPI from './dist/index.mjs'; + const wp = new WPAPI({ endpoint: 'http://localhost/wp-json' }); + if (typeof wp.posts !== 'function') throw new Error('ESM entry did not bootstrap'); + if (typeof WPAPI.transport.get !== 'function') throw new Error('ESM entry has no bound transport'); + " + node -e " + try { require('./dist/superagent.cjs'); } + catch (e) { if (/wpapi.superagent was removed/.test(e.message)) process.exit(0); throw e; } + throw new Error('superagent stub did not throw its migration error'); + " + + integration: + name: Integration tests (wp-env) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run env:start + - run: npm run env:seed + - run: npm run test:integration + # `npm run env:logs` defaults to `--watch`, which never exits; pass + # `--no-watch` directly so this step prints once and completes. + - if: always() + run: npx @wordpress/env logs development --no-watch + - if: always() + run: npm run env:stop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6f631309 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +# Scaffolded in Phase 1, replacing the local `release-npm` script; not yet +# exercised until publishing resumes (requires an `NPM_TOKEN` secret). +on: + release: + types: [ published ] + +jobs: + publish: + name: Build, test & publish to npm + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + # tsdown/rolldown require Node >=22.18 to run the build itself. + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run build + - run: npm run test:unit + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index b4170884..da4c79ef 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,5 @@ documentation/index.html *.sublime-* .ruby-version -# Built files (for use in browser) -browser/ - -# Consuming applications should maintain their own lockfile. -package-lock.json +# Built files (tsdown output: ESM/CJS/UMD + .d.ts) +dist/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..b81e51c9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +# Route data: default-routes.json is machine-fetched from a live WP site; +# default-route-tree.json and default-route-handlers.ts are generated from it +# by build/scripts/precompute-default-routes.js. Formatting any of these would +# break the CI staleness check for the generated files. +lib/data/default-routes.json +lib/data/default-route-tree.json +lib/data/default-route-handlers.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..340e342d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "useTabs": true, + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 90 +} diff --git a/.scratchpad/bench-startup.mjs b/.scratchpad/bench-startup.mjs new file mode 100644 index 00000000..19c20c02 --- /dev/null +++ b/.scratchpad/bench-startup.mjs @@ -0,0 +1,69 @@ +// Benchmark default-mode WPAPI startup cost against the built dist/ output. +// Usage: npm run build && node .scratchpad/bench-startup.mjs [samples] +// +// Reports, across N fresh node child processes: +// - load: time to require('dist/index.cjs') +// - init: time for the first default-mode `new WPAPI(...)` (route parse + factories) +// - init2: time for a second instantiation (factories already cached) +// Plus an in-process micro-benchmark of the default-route bootstrap work itself. + +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const repoRoot = path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '..' ); +const samples = parseInt( process.argv[ 2 ], 10 ) || 30; + +const childScript = ` + const t0 = performance.now(); + const WPAPI = require( ${ JSON.stringify( path.join( repoRoot, 'dist/index.cjs' ) ) } ); + const t1 = performance.now(); + new WPAPI( { endpoint: 'http://localhost/wp-json' } ); + const t2 = performance.now(); + new WPAPI( { endpoint: 'http://localhost/wp-json' } ); + const t3 = performance.now(); + console.log( JSON.stringify( { load: t1 - t0, init: t2 - t1, init2: t3 - t2 } ) ); +`; + +const results = []; +for ( let i = 0; i < samples; i++ ) { + const out = execFileSync( process.execPath, [ '-e', childScript ], { encoding: 'utf8' } ); + results.push( JSON.parse( out ) ); +} + +const stats = ( key ) => { + const values = results.map( r => r[ key ] ).sort( ( a, b ) => a - b ); + const mean = values.reduce( ( a, b ) => a + b, 0 ) / values.length; + const median = values[ Math.floor( values.length / 2 ) ]; + return { mean, median, min: values[ 0 ], max: values[ values.length - 1 ] }; +}; + +const fmt = ( s ) => `median ${ s.median.toFixed( 3 ) }ms mean ${ s.mean.toFixed( 3 ) }ms min ${ s.min.toFixed( 3 ) }ms max ${ s.max.toFixed( 3 ) }ms`; + +console.log( `samples: ${ samples } (fresh node process each)` ); +console.log( `require('dist/index.cjs') ${ fmt( stats( 'load' ) ) }` ); +console.log( `first new WPAPI() (parse) ${ fmt( stats( 'init' ) ) }` ); +console.log( `second new WPAPI() (cached) ${ fmt( stats( 'init2' ) ) }` ); + +// In-process micro-benchmark: repeat the first-instantiation bootstrap work by +// clearing the module cache so the lazy default-factory cache is rebuilt. +const microScript = ` + const distPath = ${ JSON.stringify( path.join( repoRoot, 'dist/index.cjs' ) ) }; + const ITERATIONS = 200; + // Warm up disk cache / JIT with one throwaway pass. + let WPAPI = require( distPath ); + new WPAPI( { endpoint: 'http://localhost/wp-json' } ); + const times = []; + for ( let i = 0; i < ITERATIONS; i++ ) { + delete require.cache[ require.resolve( distPath ) ]; + WPAPI = require( distPath ); + const t0 = performance.now(); + new WPAPI( { endpoint: 'http://localhost/wp-json' } ); + times.push( performance.now() - t0 ); + } + times.sort( ( a, b ) => a - b ); + const mean = times.reduce( ( a, b ) => a + b, 0 ) / times.length; + console.log( JSON.stringify( { mean, median: times[ Math.floor( times.length / 2 ) ] } ) ); +`; +const micro = JSON.parse( execFileSync( process.execPath, [ '-e', microScript ], { encoding: 'utf8' } ) ); +console.log( `bootstrap micro (200 iter) median ${ micro.median.toFixed( 3 ) }ms mean ${ micro.mean.toFixed( 3 ) }ms` ); diff --git a/.scratchpad/type-consumer/README.md b/.scratchpad/type-consumer/README.md new file mode 100644 index 00000000..3dc141d8 --- /dev/null +++ b/.scratchpad/type-consumer/README.md @@ -0,0 +1,17 @@ +# Shipped-declaration consumer check + +Strict-mode consumers exercising the generated `dist/*.d.*` typings after +`npm run build`. `consumer.cts`/`consumer.mts` must typecheck clean (require + +import paths, constructability, statics, chaining); every line flagged in +`negative.cts` must error (proves the types reject bad code, which is what +regressed in Phase 2). The `wp.posts()` negative lines guard the Phase 4 +generated handler typings: if the declaration bundle ever degrades those +handlers back to `any`, the positives stay clean but these stop erroring. + +Usage: + +``` +npx tsc --strict --noEmit --skipLibCheck --module node16 --moduleResolution node16 \ + --target es2020 .scratchpad/type-consumer/consumer.cts .scratchpad/type-consumer/consumer.mts +npx tsc --strict --noEmit ... .scratchpad/type-consumer/negative.cts # expect 5 errors +``` diff --git a/.scratchpad/type-consumer/consumer.cts b/.scratchpad/type-consumer/consumer.cts new file mode 100644 index 00000000..8335d084 --- /dev/null +++ b/.scratchpad/type-consumer/consumer.cts @@ -0,0 +1,34 @@ +// Strict-mode consumer exercising the shipped CJS declarations (require path). +import WPAPI = require( '../../dist/index.cjs' ); + +const wp = new WPAPI( { endpoint: 'http://example.com/wp-json' } ); +const viaSite = WPAPI.site( 'http://example.com/wp-json' ); +const req = wp.posts().perPage( 5 ).page( 2 ); +const url: string = req.toString(); +const chained = wp.url( 'http://x/' ).auth( { username: 'u', password: 'p' } ); +async function run(): Promise { + const posts = await wp.posts(); + console.log( posts, url, viaSite, chained ); + const discovered = await WPAPI.discover( 'http://example.com' ); + console.log( discovered.pages() ); +} +run(); + +// The wpapi/fetch alias must expose the same constructable type. +import WPAPIFetch = require( '../../dist/fetch.cjs' ); +const wpF = new WPAPIFetch( { endpoint: 'http://example.com/wp-json' } ); +console.log( wpF.media().toString() ); + +// Generated default-route handler typings (Phase 4): mixins and path-part +// setters chain with WPRequest methods, and namespace() is overloaded for +// the default namespaces. +const typedChain: string = wp.posts() + .categories( [ 1, 2 ] ) + .sticky( true ) + .id( 7 ) + .toString(); +console.log( typedChain, wp.pages().parent( 3 ).revisions( 12 ) ); +console.log( wp.namespace( 'wp/v2' ).users().me() ); +console.log( wp.namespace( 'oembed/1.0' ).proxy().param( 'url', 'http://x' ) ); +// Handlers registered from custom route data still pass the index signature. +console.log( wp.namespace( 'someplugin/v1' ).customResource() ); diff --git a/.scratchpad/type-consumer/consumer.mts b/.scratchpad/type-consumer/consumer.mts new file mode 100644 index 00000000..3d7bcaa9 --- /dev/null +++ b/.scratchpad/type-consumer/consumer.mts @@ -0,0 +1,11 @@ +// Strict-mode consumer exercising the shipped ESM declarations (import path). +import WPAPI from '../../dist/index.mjs'; + +const wp = new WPAPI( { endpoint: 'http://example.com/wp-json' } ); +const req = wp.posts().perPage( 5 ); +const url: string = req.toString(); +async function run(): Promise { + const posts = await wp.posts(); + console.log( posts, url ); +} +run(); diff --git a/.scratchpad/type-consumer/negative.cts b/.scratchpad/type-consumer/negative.cts new file mode 100644 index 00000000..7186273b --- /dev/null +++ b/.scratchpad/type-consumer/negative.cts @@ -0,0 +1,8 @@ +import WPAPI = require( '../../dist/index.cjs' ); +const wp = new WPAPI( { endpoint: 'http://example.com/wp-json' } ); +// Each line below should be a type error: +wp.url( 'x' ).nonexistentMethod(); +const n: number = wp.url( 'x' ).toString(); +wp.url( 'x' ).auth( { username: 42 } ); +wp.posts().nonexistentMethod(); +wp.posts().sticky( 'yes' ); diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index eec9a381..00000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: node_js -script: "npm run test:ci" -node_js: - - "node" - - "lts/*" - - "10" - - "8" -# Opt-in to travis container infrastructure -sudo: false diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 00000000..3ee33a35 --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,15 @@ +{ + "port": 2747, + "testsEnvironment": false, + "config": { + "SCRIPT_DEBUG": true, + "WP_DEBUG_DISPLAY": false, + "WP_DEBUG": true, + "WP_DEVELOPMENT_MODE": "plugin" + }, + "plugins": [ + ".", + "https://downloads.wordpress.org/plugin/query-monitor.zip", + "https://github.com/WP-API/Basic-Auth/archive/refs/heads/master.zip" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d42793db..36fa2251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog -## v2.0.0 [**alpha**] _Second Toughest in the Infants_ +## v2.0.0 [**unreleased**] _Second Toughest in the Infants_ - **BREAKING**: "Node-style" error-first callbacks (_e.g._ `.get( ( err, data ) => {} )`) are no longer supported. All transport request methods return Promises. -- **BREAKING**: The module exported as `wpapi` no longer includes HTTP methods. Install `superagent` as a peer dependency and `require( 'wpapi/superagent' )` in order to make HTTP requests. +- **BREAKING**: The minimum supported Node.js version is now v24; Internet Explorer is no longer supported. +- **BREAKING**: HTTP requests are made with the native `fetch` API. The superagent transport and the `wpapi/superagent` entrypoint introduced in the v2 alphas are removed; the default `wpapi` export makes HTTP requests out of the box again, with no additional dependencies. `wpapi/fetch` remains as an alias for the default export. +- **BREAKING**: Media uploads use native `FormData`/`Blob`. `.file()` accepts a file path (Node), a `Buffer` or `Blob` plus a file name, or a `File` object; streams are no longer supported. The multipart part's content type is no longer inferred from the file name (WordPress determines the type server-side from the name); pass a `Blob` or `File` with an explicit `type` to control it. - **BREAKING**: Autodiscovery now either succeeds or fails; a WPAPI instance configured with default routes will no longer be returned. diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index 2f8ebe84..00000000 --- a/Gruntfile.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The Gruntfile in this project is not responsible for the code build or - * linting, and instead handles all tasks related to documentation generation - * and output. - */ -'use strict'; - -module.exports = function( grunt ) { - grunt.initConfig( { - pkg: grunt.file.readJSON( 'package.json' ), - } ); - - // Individual tasks are defined within build/grunt - grunt.loadTasks( 'build/grunt' ); - - grunt.registerTask( 'docs', [ - 'clean', - 'generate_readme_docs', - 'zip', - ] ); -}; diff --git a/README.md b/README.md index 3668a032..9738e866 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ To get started, `npm install wpapi` or [download the browser build](https://wp-a ## Installation -`node-wpapi` works both on the server or in the browser. Node.js version 8.6 or higher (or version 8.2.1 with the `--harmony` flag) is required, and the latest LTS release is recommended. +`node-wpapi` works both on the server or in the browser. Node.js version 18 or higher is required, and the latest LTS release is recommended. -In the browser `node-wpapi` officially supports the latest two versions of all evergreen browsers, and Internet Explorer 11. +In the browser `node-wpapi` officially supports the latest two versions of all evergreen browsers. ### Install with NPM @@ -54,47 +54,35 @@ To use the library from Node, install it with [npm](http://npmjs.org): npm install --save wpapi ``` -Then, within your application's script files, `require` the module to gain access to it. As `wpapi` is both a query builder and a transport layer (_i.e._ a tool for getting and sending HTTP requests), we leave it up to you as the author of your application whether you need both parts of this functionality. You may use `wpapi` with [superagent](https://www.npmjs.com/package/superagent) if you wish to send and receive HTTP requests using this library, but you may also use only the query builder part of the library if you intend to submit your HTTP requests with `fetch`, `axios` or other tools. - -To import only the query builder (without the `.get()`, `.create()`, `.delete()`, `.update()` or `.then()` chaining methods): - -```javascript -var WPAPI = require( 'wpapi' ); -``` - -To import the superagent bundle, which contains the full suite of HTTP interaction methods: +Then, within your application's script files, `require` or `import` the module to gain access to it: ```js -var WPAPI = require( 'wpapi/superagent' ); +const WPAPI = require( 'wpapi' ); +// or +import WPAPI from 'wpapi'; ``` -This library is designed to work in the browser as well, via a build system such as Browserify or Webpack; just install the package and `require( 'wpapi' )` (or `'wpapi/superagent'`) from your application code. +HTTP requests are made with the `fetch` API built into Node.js (v24 or later is required), browsers and other modern JavaScript runtimes, so no other dependencies are required. This library is designed to work in the browser as well, via a build system such as Vite or Webpack; just install the package and import it from your application code. ### Download the UMD Bundle Alternatively, you may download a [ZIP archive of the bundled library code](https://wp-api.github.io/node-wpapi/wpapi.zip). These files are UMD modules, which may be included directly on a page using a regular `