diff --git a/.circleci/config.yml b/.circleci/config.yml index d8d281b1e28..b62c652e744 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -221,7 +221,16 @@ jobs: - *restore_yarn_cache - *run_yarn_install - run: - name: Lint & Code styles + name: Format check + command: | + yarn run format:check && exit 0 + echo "" + echo "=== Formatting violations (showing intended oxfmt output as diff) ===" + yarn oxfmt . > /dev/null + git --no-pager diff --color + exit 1 + - run: + name: Lint command: yarn run lint - run: name: Type Checking diff --git a/.claude/rules/e2e.md b/.claude/rules/e2e.md index c910337673c..f4497313c3f 100644 --- a/.claude/rules/e2e.md +++ b/.claude/rules/e2e.md @@ -3,7 +3,7 @@ ## Test Locations and Frameworks | Location | Framework | Purpose | -|----------|-----------|---------| +| --- | --- | --- | | `tests/e2e/playwright/` | Playwright | Main e2e suite for all InstantSearch flavors | | `tests/e2e/` (wdio files) | WebdriverIO | IE11 tests only (via Sauce Labs) | | `packages/react-instantsearch-nextjs/__tests__/e2e/` | Playwright | App Router Next.js e2e tests | @@ -14,6 +14,7 @@ ### Main E2E Suite (Playwright) **Prerequisites:** Examples must be built first: + ```bash yarn website:examples ``` @@ -86,49 +87,60 @@ test.describe('feature name', () => { The `helpers` fixture provides these methods: **RefinementList:** + - `clickRefinementListItem(label)` - Click a refinement list item - `getSelectedRefinementListItem()` - Get the selected item's text **SearchBox:** + - `setSearchBoxValue(value)` - Set the search input value - `getSearchBoxValue()` - Get the current search value **Hits:** + - `getHitsTitles()` - Get all hit titles as an array **HierarchicalMenu:** + - `clickHierarchicalMenuItem(label)` - Click a menu item - `getSelectedHierarchicalMenuItems()` - Get selected items **RangeSlider:** + - `dragRangeSliderLowerBoundTo(value)` - Drag lower handle - `dragRangeSliderUpperBoundTo(value)` - Drag upper handle - `getRangeSliderLowerBoundValue()` - Get lower bound value - `getRangeSliderUpperBoundValue()` - Get upper bound value **Pagination:** + - `clickPage(n)` - Navigate to page n - `clickNextPage()` - Go to next page - `clickPreviousPage()` - Go to previous page - `getCurrentPage()` - Get current page number **ToggleRefinement:** + - `clickToggleRefinement()` - Toggle the refinement - `getToggleRefinementStatus()` - Get checked status **RatingMenu:** + - `clickRatingMenuItem(label)` - Click rating (e.g., "4 & up") - `getSelectedRatingMenuItem()` - Get selected rating label **SortBy:** + - `setSortByValue(label)` - Select sort option by label - `getSortByValue()` - Get current sort value **HitsPerPage:** + - `setHitsPerPage(label)` - Select hits per page - `getHitsPerPage()` - Get current value **ClearRefinements:** + - `clickClearRefinements()` - Click clear button ### Best Practices @@ -156,6 +168,7 @@ The main e2e suite tests multiple InstantSearch flavors: Tests run against example apps in `website/examples/{flavor}/e-commerce/`. To run a single flavor: + ```bash E2E_FLAVOR=react yarn test:e2e ``` @@ -163,6 +176,7 @@ E2E_FLAVOR=react yarn test:e2e ## IE11 Considerations IE11 tests are kept in WebdriverIO because Playwright doesn't support IE11. These tests: + - Only run `js` and `js-umd` flavors - Require Sauce Labs for remote IE11 browser - Use the same test specs in `tests/e2e/specs/` and helpers in `tests/e2e/helpers/` @@ -188,6 +202,7 @@ PWDEBUG=1 yarn workspace @instantsearch/e2e-tests test:playwright ### View Test Reports After running tests, open the HTML report: + ```bash npx playwright show-report tests/e2e/playwright/playwright-report ``` @@ -203,15 +218,20 @@ npx playwright show-report tests/e2e/playwright/playwright-report ### Port conflicts on macOS On macOS, port 5000 is used by AirPlay Receiver (ControlCenter). The tests use port 3456 instead. If you need to change the port, update: + - `tests/e2e/playwright/playwright.config.ts` (both `baseURL` and `webServer.command/url`) ### SPA routing not working The e-commerce examples use History API routing (e.g., `/search/Appliances/`). The `website/serve.json` file configures rewrites for these routes. If you add new SPA routes, update this file: + ```json { "rewrites": [ - { "source": "/examples/js/e-commerce/search/**", "destination": "/examples/js/e-commerce/index.html" } + { + "source": "/examples/js/e-commerce/search/**", + "destination": "/examples/js/e-commerce/index.html" + } ] } ``` @@ -225,18 +245,21 @@ Tests have 1 retry locally and 2 retries in CI to handle occasional flakiness. ### Multiple elements matched If you see "strict mode violation" errors about multiple elements: + - SearchBox selectors are scoped to the header to avoid matching RefinementList search inputs - Use more specific selectors when targeting widgets that may appear multiple times ### Different HTML structures across flavors The JS and React examples have different HTML structures: + - **JS examples**: Use `id="header"` and `data-widget="searchbox"` attributes - **React examples**: Use `className="header"` (class instead of id) and render SearchBox directly inside header The fixtures account for this with combined selectors like: + ```typescript -'#header .ais-SearchBox [type=search], .header > .ais-SearchBox [type=search], [data-widget="searchbox"] .ais-SearchBox [type=search]' +'#header .ais-SearchBox [type=search], .header > .ais-SearchBox [type=search], [data-widget="searchbox"] .ais-SearchBox [type=search]'; ``` If you add new selectors, ensure they work across all flavors. @@ -254,6 +277,7 @@ When migrating tests from WebDriverIO to Playwright, be aware of these differenc - **WebDriverIO**: `$('.ais-Hits-item')` returns first match, clicking works on container elements - **Playwright**: `page.locator('.ais-Hits-item').first()` - clicking on container may not hit child link elements. Use `.locator('a')` to target links explicitly: + ```typescript // WebDriverIO const link = await $('.ais-Hits-item'); @@ -289,10 +313,10 @@ This gives the browser enough time to process JavaScript events like popstate ha ## CI Integration Tests run in CircleCI: + - **e2e tests playwright** - Main suite with Chromium + Firefox - **e2e tests ie11** - IE11 via Sauce Labs - **e2e tests router nextjs** - Pages Router Next.js tests - **e2e tests app router nextjs** - App Router Next.js tests -JUnit reports are stored for test results visualization. -Playwright HTML reports are stored as artifacts. +JUnit reports are stored for test results visualization. Playwright HTML reports are stored as artifacts. diff --git a/.claude/skills/port-widget/agents/openai.yaml b/.claude/skills/port-widget/agents/openai.yaml index a931cd472c2..1a1a427401c 100644 --- a/.claude/skills/port-widget/agents/openai.yaml +++ b/.claude/skills/port-widget/agents/openai.yaml @@ -1,7 +1,7 @@ interface: - display_name: "Port InstantSearch Widget" - short_description: "Port widgets across InstantSearch flavors" - default_prompt: "Use $port-widget to port a widget or connector-driven feature across the InstantSearch JavaScript, React, and Vue packages in this repo." + display_name: 'Port InstantSearch Widget' + short_description: 'Port widgets across InstantSearch flavors' + default_prompt: 'Use $port-widget to port a widget or connector-driven feature across the InstantSearch JavaScript, React, and Vue packages in this repo.' policy: allow_implicit_invocation: true diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 279bd64d527..00000000000 --- a/.eslintignore +++ /dev/null @@ -1,56 +0,0 @@ -# Vendors -node_modules -**/node_modules/** -.yarn -**/.yarn/** - -# Artifacts -dist -build -website -**/cjs -**/es -**/dist -**/vue2 -**/vue3 -coverage - -# Caches -.cache -.parcel-cache -.expo -.next - -# Non-lintable source files -## Symlinked packages in examples (already linted from their real location) -examples/js/e-commerce-umd/public/packages -## Polyfills for examples, retrieved from polyfill.io -examples/**/polyfills.js -## React-Native TypeScript wasn't supporting the mix of react 18 and 17 -examples/react/react-native -## Next.js examples have their own tsconfig and are type-checked during their build -examples/react/next -examples/react/next-routing -examples/react/next-app-router -## Excluded from global typescript config -**/next-env.d.ts -specs/src/env.d.ts -## templates that don't get installed -packages/create-instantsearch-app/src/templates -packages/create-instantsearch-app/src/templates/** -## test fixtures for codemods -packages/instantsearch-codemods/__testfixtures__ -packages/instantsearch-codemods/__testfixtures__/** -## legacy ESLint configs kept for reference/migration only -**/.eslintrc -**/.eslintrc.js -**/.eslintrc.cjs -**/eslint.config.js - -# Playwright artifacts and test files (Playwright has its own tsconfig) -**/playwright-report/** -**/test-results/** -tests/e2e/playwright/** - -## Rollup configs use import attributes syntax not yet supported by ESLint parser -**/rollup.config.mjs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index cbbfbecd49c..51368ffa616 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,5 @@ # Introduced prettier 25cd787e6a06502ee3a85648f90f693276cca671 + +# Reformat with oxfmt +3e2abd41c28a6dec768ac7a2c3b531300d8bc62e diff --git a/.github/prompts/docs-automation.md b/.github/prompts/docs-automation.md index 7068bf96b3f..d95fad05176 100644 --- a/.github/prompts/docs-automation.md +++ b/.github/prompts/docs-automation.md @@ -1,14 +1,13 @@ You are updating documentation for InstantSearch. -IMPORTANT: This is a non-interactive automated workflow. Do not ask for user input or clarification. -If you cannot access a file, skip it and work with what you have. -If there are no documentable changes, report that finding and exit successfully. +IMPORTANT: This is a non-interactive automated workflow. Do not ask for user input or clarification. If you cannot access a file, skip it and work with what you have. If there are no documentable changes, report that finding and exit successfully. BE EFFICIENT: You have limited turns. Read multiple files in parallel when possible. Don't over-explore. ## Repository Layout You are running from the root directory where: + - instantsearch/ contains the InstantSearch source code and changelogs - docs-new/ contains the documentation repository to update @@ -21,29 +20,31 @@ You are running from the root directory where: - instantsearch/packages/vue-instantsearch/CHANGELOG.md (low priority - only update Vue docs if this changelog shows explicit feature additions) 2. Find and read ONE existing doc as a format reference: - - Use Glob to find InstantSearch widget docs: docs-new/**/instantsearch/**/*.mdx + - Use Glob to find InstantSearch widget docs: docs-new/**/instantsearch/**/\*.mdx - Read just ONE example file to understand the format (don't read many) 3. Update documentation for any new features, modified components, or breaking changes. 4. After making changes, run link check to catch broken links: - - cd docs-new && npm run check:links - Fix any broken links you introduced, but don't spend time on pre-existing issues. + - cd docs-new && npm run check:links Fix any broken links you introduced, but don't spend time on pre-existing issues. + +5. ## REQUIRED: Write a summary file at CHANGES_SUMMARY.md with this exact format: -5. REQUIRED: Write a summary file at CHANGES_SUMMARY.md with this exact format: - --- First line: A short title describing the main change (e.g., 'Add useFrequentlyBoughtTogether hook documentation') Blank line, then a markdown list of what was changed: - Added docs for X widget/hook - Updated Y component with new Z prop - Fixed broken links in W page - --- + + *** + If no changes were made, write 'No documentation changes needed' as the title. ## Flavor Mapping Each package has its own documentation flavor: + - instantsearch.js → .js.mdx files - react-instantsearch → .react.mdx files - vue-instantsearch → .vue.mdx files @@ -57,12 +58,8 @@ Each package has its own documentation flavor: - Match the existing documentation format and style exactly - Only modify documentation files in docs-new/ - Don't add placeholder content - only document what actually exists -- CROSS-FLAVOR CONSISTENCY: When updating a widget/hook that exists in multiple flavors (JS, React, Vue), - check if the same prop/feature exists in the other flavors and update ALL relevant docs. - Many features are shared via instantsearch-ui-components. Read the source for each flavor to verify. +- CROSS-FLAVOR CONSISTENCY: When updating a widget/hook that exists in multiple flavors (JS, React, Vue), check if the same prop/feature exists in the other flavors and update ALL relevant docs. Many features are shared via instantsearch-ui-components. Read the source for each flavor to verify. ## Source Code Reference -The InstantSearch source is at instantsearch/ for reference. -Read source files to understand APIs, types, and implementation. -Example: instantsearch/packages/instantsearch.js/src/widgets/ +The InstantSearch source is at instantsearch/ for reference. Read source files to understand APIs, types, and implementation. Example: instantsearch/packages/instantsearch.js/src/widgets/ diff --git a/.github/scripts/architecture-refactor.cjs b/.github/scripts/architecture-refactor.cjs index 93b6211dd32..5b20a2c5b8a 100644 --- a/.github/scripts/architecture-refactor.cjs +++ b/.github/scripts/architecture-refactor.cjs @@ -83,8 +83,8 @@ function parseArgs(argv) { inlineValue !== undefined ? inlineValue : next && !next.startsWith('--') - ? (index++, next) - : true; + ? (index++, next) + : true; options[name] = value; } diff --git a/.github/workflows/docs-automation.yml b/.github/workflows/docs-automation.yml index 647314dc167..2bdf97f8088 100644 --- a/.github/workflows/docs-automation.yml +++ b/.github/workflows/docs-automation.yml @@ -31,8 +31,8 @@ jobs: runs-on: ubuntu-latest # Only run on release commits (chore: release) or manual triggers if: >- - github.event_name == 'workflow_dispatch' || - (startsWith(github.event.head_commit.message, 'chore:') && contains(github.event.head_commit.message, 'release')) + github.event_name == 'workflow_dispatch' || (startsWith(github.event.head_commit.message, 'chore:') && contains(github.event.head_commit.message, 'release')) + steps: - name: Checkout instantsearch diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 96db8241284..af19cba4aa1 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -30,19 +30,5 @@ jobs: - name: Publish preview packages run: > - npx pkg-pr-new publish - --compact - --template './examples/js/getting-started' - --template './examples/react/getting-started' - --template './examples/react/next-app-router' - --template './examples/react/next-routing' - --template './examples/vue/getting-started' - './packages/algoliasearch-helper' - './packages/instantsearch.js' - './packages/react-instantsearch' - './packages/react-instantsearch-core' - './packages/react-instantsearch-nextjs' - './packages/react-instantsearch-router-nextjs' - './packages/vue-instantsearch' - './packages/instantsearch.css' - './packages/instantsearch-ui-components' + npx pkg-pr-new publish --compact --template './examples/js/getting-started' --template './examples/react/getting-started' --template './examples/react/next-app-router' --template './examples/react/next-routing' --template './examples/vue/getting-started' './packages/algoliasearch-helper' './packages/instantsearch.js' './packages/react-instantsearch' './packages/react-instantsearch-core' './packages/react-instantsearch-nextjs' './packages/react-instantsearch-router-nextjs' './packages/vue-instantsearch' './packages/instantsearch.css' './packages/instantsearch-ui-components' + diff --git a/.gitignore b/.gitignore index d089e5befc9..47e03a4b48d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,6 @@ scripts/*/CHANGELOG.md .vscode/ # Caches -.eslintcache .parcel-cache .nuxt diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000..95183b4650c --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,50 @@ +{ + "singleQuote": true, + "proseWrap": "never", + "trailingComma": "es5", + "printWidth": 80, + "sortPackageJson": false, + "ignorePatterns": [ + "node_modules", + "**/node_modules/**", + ".yarn", + "**/.yarn/**", + "yarn.lock", + "dist", + "build", + "website", + "**/cjs", + "**/es", + "**/dist", + "**/vue2", + "**/vue3", + "coverage", + "**/CHANGELOG.md", + ".cache", + ".parcel-cache", + ".expo", + ".next", + "**/.nuxt", + "**/.nuxt/**", + "examples/js/e-commerce-umd/public/packages", + "examples/**/polyfills.js", + "examples/react/react-native", + "examples/react/next", + "examples/react/next-routing", + "examples/react/next-app-router", + "**/next-env.d.ts", + "specs/src/env.d.ts", + "packages/create-instantsearch-app/src/templates", + "packages/create-instantsearch-app/src/templates/**", + "packages/instantsearch-codemods/__testfixtures__", + "packages/instantsearch-codemods/__testfixtures__/**", + "**/playwright-report/**", + "**/test-results/**", + "tests/e2e/playwright/**", + "**/rollup.config.mjs", + "packages/algoliasearch-helper/documentation-src/partials/**/*.hbs", + "packages/instantsearch.css/components/**", + "packages/instantsearch.css/themes/**", + "specs/public/themes/**" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json index 0623cfcfe98..6f56c536d59 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -2,10 +2,6 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "ignorePatterns": [ ".yarn/**", - "**/.eslintrc", - "**/.eslintrc.js", - "**/.eslintrc.cjs", - "**/eslint.config.js", "packages/create-instantsearch-app/src/templates/**", "packages/instantsearch-codemods/__testfixtures__/**" ], @@ -119,14 +115,7 @@ "caseInsensitive": true, "order": "asc" }, - "groups": [ - "builtin", - "external", - "parent", - "sibling", - "index", - "type" - ], + "groups": ["builtin", "external", "parent", "sibling", "index", "type"], "newlines-between": "always", "pathGroups": [ { @@ -135,9 +124,7 @@ "position": "before" } ], - "pathGroupsExcludedImportTypes": [ - "builtin" - ] + "pathGroupsExcludedImportTypes": ["builtin"] } ], "react-hooks/exhaustive-deps": [ @@ -157,24 +144,15 @@ "no-restricted-imports": [ "error", { - "paths": [ - "instantsearch.js/es" - ], - "patterns": [ - "instantsearch.js/cjs/*" - ] + "paths": ["instantsearch.js/es"], + "patterns": ["instantsearch.js/cjs/*"] } ], "instantsearch/no-default-props-assignment": "error" }, "overrides": [ { - "files": [ - "**/*.js", - "**/*.cjs", - "**/*.mjs", - "**/*.d.ts" - ], + "files": ["**/*.js", "**/*.cjs", "**/*.mjs", "**/*.d.ts"], "rules": { "@typescript-eslint/consistent-type-imports": "off", "@typescript-eslint/no-floating-promises": "off", @@ -186,9 +164,7 @@ } }, { - "files": [ - "packages/**/*" - ], + "files": ["packages/**/*"], "rules": { "instantsearch/no-async-functions": "error", "instantsearch/no-for-in": "error", @@ -200,20 +176,14 @@ } }, { - "files": [ - "packages/vue-instantsearch/**/*", - "examples/vue/**/*" - ], + "files": ["packages/vue-instantsearch/**/*", "examples/vue/**/*"], "rules": { "react-hooks/exhaustive-deps": "off", "react-hooks/rules-of-hooks": "off" } }, { - "files": [ - "packages/**/*.test.*", - "packages/**/__tests__/**" - ], + "files": ["packages/**/*.test.*", "packages/**/__tests__/**"], "rules": { "instantsearch/no-async-functions": "off", "instantsearch/no-for-in": "off", @@ -231,10 +201,7 @@ "packages/instantsearch.js/src/**/*.tsx" ], "rules": { - "import/extensions": [ - "error", - "never" - ] + "import/extensions": ["error", "never"] } }, { @@ -243,26 +210,17 @@ "packages/react-instantsearch-*/src/**/*" ], "rules": { - "import/extensions": [ - "error", - "never" - ] + "import/extensions": ["error", "never"] } }, { - "files": [ - "packages/*/test/module/**/*.cjs" - ], + "files": ["packages/*/test/module/**/*.cjs"], "rules": { "import/extensions": "off" } }, { - "files": [ - "**/__tests__/**/*", - "**/*.spec.*", - "**/*.test.*" - ], + "files": ["**/__tests__/**/*", "**/*.spec.*", "**/*.test.*"], "rules": { "import/extensions": "off", "new-cap": "off", @@ -270,19 +228,13 @@ } }, { - "files": [ - "**/*.cjs" - ], + "files": ["**/*.cjs"], "rules": { "import/no-commonjs": "off" } }, { - "files": [ - "scripts/**/*", - "**/*.config.js", - "**/*.conf.js" - ], + "files": ["scripts/**/*", "**/*.config.js", "**/*.conf.js"], "rules": { "import/no-commonjs": "off", "no-console": "off" @@ -309,9 +261,7 @@ } }, { - "files": [ - "tests/e2e/**/*" - ], + "files": ["tests/e2e/**/*"], "rules": { "@typescript-eslint/no-floating-promises": "error", "import/no-commonjs": "off", @@ -349,19 +299,14 @@ } }, { - "files": [ - "**/.storybook/**/*", - "**/stories/**/*" - ], + "files": ["**/.storybook/**/*", "**/stories/**/*"], "rules": { "new-cap": "off", "no-console": "off" } }, { - "files": [ - "packages/instantsearch.js/src/**/*" - ], + "files": ["packages/instantsearch.js/src/**/*"], "rules": { "no-restricted-globals": [ "error", diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 05b6b60f0e4..00000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -packages/*/CHANGELOG.md -examples/**/polyfills.js diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 833f03b6214..00000000000 --- a/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "singleQuote": true, - "proseWrap": "never", - "trailingComma": "es5" -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a67ab81953..b2399d6d771 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -400,11 +400,12 @@ If your editor support them, then you will see the errors directly there. You ca yarn lint ``` -JavaScript and TypeScript files are formatted with [Prettier](https://github.com/prettier/prettier) and linted with [Oxlint](https://oxc.rs/docs/guide/usage/linter/). +JavaScript and TypeScript files are formatted with [oxfmt](https://oxc.rs/) and linted with [Oxlint](https://oxc.rs/docs/guide/usage/linter/). Formatting settings live in `.oxfmtrc.json`; lint rules live in `.oxlintrc.json`. Markdown, JSON, and YAML files are no longer auto-formatted. Useful lint commands: -- `yarn lint` runs the full repo lint flow. +- `yarn lint` runs Oxlint across the repo and workspace examples. +- `yarn format` rewrites files with oxfmt; `yarn format:check` only reports violations. CI runs it separately from `yarn lint`. - `yarn lint:ox ` lints only the paths you pass. - `yarn lint:changed` lints files changed since the branch point with `origin/master`. - `yarn lint:staged` lints staged JavaScript, TypeScript, and Vue files. diff --git a/examples/.oxlintrc.json b/examples/.oxlintrc.json index 4e0674381ff..19e07084c14 100644 --- a/examples/.oxlintrc.json +++ b/examples/.oxlintrc.json @@ -1,13 +1,6 @@ { - "extends": [ - "../.oxlintrc.json" - ], - "ignorePatterns": [ - "dist", - "build", - ".next", - ".nuxt" - ], + "extends": ["../.oxlintrc.json"], + "ignorePatterns": ["dist", "build", ".next", ".nuxt"], "rules": { "@typescript-eslint/consistent-type-imports": "off", "import/named": "off", diff --git a/examples/js/algolia-experiences/index.html b/examples/js/algolia-experiences/index.html index 8c877d9a498..5dfc6dbdbf3 100644 --- a/examples/js/algolia-experiences/index.html +++ b/examples/js/algolia-experiences/index.html @@ -1,4 +1,4 @@ - + @@ -9,8 +9,17 @@ />