diff --git a/.gitattributes b/.gitattributes index 91d3c46..5a2fbc7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,4 +12,9 @@ /phpstan.neon export-ignore /phpstan-baseline.neon export-ignore /phpunit.xml.dist export-ignore -/rector export-ignore +/rector.php export-ignore +/bin export-ignore +/babel.config.js export-ignore +/jest.config.js export-ignore +/rollup.config.js export-ignore +/tsconfig.json export-ignore diff --git a/.github/workflows/exported_files.yml b/.github/workflows/exported_files.yml new file mode 100644 index 0000000..7539f47 --- /dev/null +++ b/.github/workflows/exported_files.yml @@ -0,0 +1,18 @@ +name: Exported files + +on: [push] + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: "actions/checkout@v4" + + - name: "Check exported files" + run: | + EXPECTED="LICENSE,README.md,RELEASES.md,SECURITY.md,composer.json,package.json" + CURRENT="$(git archive HEAD | tar --list --exclude="assets" --exclude="assets/*" --exclude="src" --exclude="src/*" | paste -s -d ",")" + echo "CURRENT =${CURRENT}" + echo "EXPECTED=${EXPECTED}" + test "${CURRENT}" == "${EXPECTED}" diff --git a/.gitignore b/.gitignore index 18e5996..b8e03bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ -/vendor/ +.phpunit.result.cache +*.cache +*.log +node_modules +package-lock.json /composer.lock -/.phpunit.cache +/vendor +/.phpunit.cache/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4ec12c7..86be7ce 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities diff --git a/README.md b/README.md index 7c57fb0..0f57b00 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Progressive Web App for Symfony -=============================== +# Progressive Web App for Symfony ![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Coding%20Standards/badge.svg) ![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Static%20Analyze/badge.svg) @@ -24,7 +23,7 @@ Please have a look at the [Web app manifests](https://developer.mozilla.org/en-U # Installation -Install the bundle with Composer: +Install the bundle with Composer: ```bash composer require spomky-labs/pwa-bundle @@ -42,9 +41,9 @@ I bring solutions to your problems and answer your questions. If you really love that project and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! -* [Become a sponsor](https://github.com/sponsors/Spomky) -* [Become a Patreon](https://www.patreon.com/FlorentMorselli) -* [Buy me a coffee](https://www.buymeacoffee.com/FlorentMorselli) +- [Become a sponsor](https://github.com/sponsors/Spomky) +- [Become a Patreon](https://www.patreon.com/FlorentMorselli) +- [Buy me a coffee](https://www.buymeacoffee.com/FlorentMorselli) # Contributing @@ -58,7 +57,7 @@ Please make sure to [follow these best practices](.github/CONTRIBUTING.md). # Security Issues If you discover a security vulnerability within the project, please **don't use the bug tracker and don't publish it publicly**. -Instead, all security issues must be sent to security [at] spomky-labs.com. +Instead, all security issues must be sent to security [at] spomky-labs.com. # Licence diff --git a/RELEASES.md b/RELEASES.md index b3f2355..66861f3 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -3,6 +3,6 @@ ## Supported Versions | Version | Supported | -|---------|--------------------| +| ------- | ------------------ | | 1.0.x | :white_check_mark: | | < 1.0.x | :x: | diff --git a/assets/dist/controller.d.ts b/assets/dist/controller.d.ts new file mode 100644 index 0000000..a7c69a2 --- /dev/null +++ b/assets/dist/controller.d.ts @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; +export default class extends Controller { + static targets: string[]; + static values: { + onlineMessage: { + type: StringConstructor; + default: string; + }; + offlineMessage: { + type: StringConstructor; + default: string; + }; + }; + readonly onlineMessageValue: string; + readonly offlineMessageValue: string; + readonly attributeTargets: HTMLElement[]; + readonly messageTargets: HTMLElement[]; + connect(): void; + dispatchEvent(name: any, payload: any): void; + statusChanged(data: any): void; +} diff --git a/assets/dist/controller.js b/assets/dist/controller.js new file mode 100644 index 0000000..598dc2f --- /dev/null +++ b/assets/dist/controller.js @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; + +var Status; +(function (Status) { + Status["OFFLINE"] = "OFFLINE"; + Status["ONLINE"] = "ONLINE"; +})(Status || (Status = {})); +class default_1 extends Controller { + connect() { + this.dispatchEvent('connect', {}); + if (navigator.onLine) { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + } + else { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + } + window.addEventListener("offline", () => { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + }); + window.addEventListener("online", () => { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + }); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'connection-status' }); + } + statusChanged(data) { + this.messageTargets.forEach((element) => { + element.innerHTML = data.message; + }); + this.attributeTargets.forEach((element) => { + element.setAttribute('data-connection-status', data.status); + }); + this.dispatchEvent('status-changed', { detail: data }); + } +} +default_1.targets = ['message', 'attribute']; +default_1.values = { + onlineMessage: { type: String, default: 'You are online.' }, + offlineMessage: { type: String, default: 'You are offline.' }, +}; + +export { default_1 as default }; diff --git a/assets/jest.config.js b/assets/jest.config.js new file mode 100644 index 0000000..a7fde9b --- /dev/null +++ b/assets/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../jest.config.js'); diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..a3a4152 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pwa/connection-status", + "description": "PWA for Symfony", + "license": "MIT", + "version": "1.0.0", + "main": "dist/controller.js", + "types": "dist/controller.d.ts", + "symfony": { + "controllers": { + "connection-status": { + "main": "dist/controller.js", + "name": "pwa/connection-status", + "webpackMode": "eager", + "fetch": "eager", + "enabled": true + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0" + } +} diff --git a/assets/src/controller.ts b/assets/src/controller.ts new file mode 100644 index 0000000..bf83be2 --- /dev/null +++ b/assets/src/controller.ts @@ -0,0 +1,61 @@ +'use strict'; + +import { Controller } from '@hotwired/stimulus'; + +enum Status { + OFFLINE = 'OFFLINE', + ONLINE = 'ONLINE', +} +export default class extends Controller { + static targets = ['message', 'attribute']; + static values = { + onlineMessage: { type: String, default: 'You are online.' }, + offlineMessage: { type: String, default: 'You are offline.' }, + }; + + declare readonly onlineMessageValue: string; + declare readonly offlineMessageValue: string; + declare readonly attributeTargets: HTMLElement[]; + declare readonly messageTargets: HTMLElement[]; + + connect() { + this.dispatchEvent('connect', {}); + if (navigator.onLine) { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + } else { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + } + + window.addEventListener('offline', () => { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + }); + window.addEventListener('online', () => { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + }); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'connection-status' }); + } + + statusChanged(data) { + this.messageTargets.forEach((element) => { + element.innerHTML = data.message; + }); + this.attributeTargets.forEach((element) => { + element.setAttribute('data-connection-status', data.status); + }); + this.dispatchEvent('status-changed', { detail: data }); + } +} diff --git a/assets/test/controller.test.ts b/assets/test/controller.test.ts new file mode 100644 index 0000000..750b982 --- /dev/null +++ b/assets/test/controller.test.ts @@ -0,0 +1,53 @@ +'use strict'; + +import {Application, Controller} from '@hotwired/stimulus'; +import {getByTestId, waitFor} from '@testing-library/dom'; +import {clearDOM, mountDOM} from '@symfony/stimulus-testing'; +import StatusController from '../src/controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('pwa-status:connect', () => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application: Application = Application.start(); + application.register('check', CheckController); + application.register('pwa-status', StatusController); +}; + +describe('StatusController', () => { + let container: any; + + beforeEach(() => { + container = mountDOM(` + + + Symfony UX + + +
+
+ + + `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect', async () => { + expect(getByTestId(container, 'pwa-status')).not.toHaveClass('connected'); + + startStimulus(); + await waitFor(() => expect(getByTestId(container, 'pwa-status')).toHaveClass('connected')); + }); +}); diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..da1f3ca --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/react', + ['@babel/preset-typescript', { allowDeclareFields: true }] + ], +}; diff --git a/bin/build_javascript.js b/bin/build_javascript.js new file mode 100644 index 0000000..af28ddc --- /dev/null +++ b/bin/build_javascript.js @@ -0,0 +1,29 @@ +/** + * This file is used to compile the TypeScript files in the assets/src directory + * of each package. + * + * It allows each package to spawn its own rollup process, which is necessary + * to keep memory usage down. + */ +const { spawnSync } = require('child_process'); +const glob = require('glob'); + +const files = [ + ...glob.sync('assets/src/*controller.ts'), +]; + +files.forEach((file) => { + const result = spawnSync('node', [ + 'node_modules/.bin/rollup', + '-c', + '--environment', + `INPUT_FILE:${file}`, + ], { + stdio: 'inherit', + shell: true + }); + + if (result.error) { + console.error(`Error compiling ${file}:`, result.error); + } +}); diff --git a/composer.json b/composer.json index 584251b..49ff62c 100644 --- a/composer.json +++ b/composer.json @@ -3,25 +3,30 @@ "description": "Progressive Web App Manifest Generator Bundle for Symfony.", "type": "symfony-bundle", "license": "MIT", - "keywords": ["PWA", "Bundle", "Symfony"], + "keywords": [ + "PWA", + "Bundle", + "Symfony" + ], "homepage": "https://github.com/spomky-labs", "authors": [ { "name": "Florent Morselli", "homepage": "https://github.com/Spomky" - },{ + }, + { "name": "All contributors", "homepage": "https://github.com/spomky-labs/pwa-bundle/contributors" } ], "autoload": { "psr-4": { - "SpomkyLabs\\PwaBundle\\": "src/" + "SpomkyLabs\\PwaBundle\\": "src/" } }, "autoload-dev": { "psr-4": { - "SpomkyLabs\\PwaBundle\\Tests\\": "tests/" + "SpomkyLabs\\PwaBundle\\Tests\\": "tests/" } }, "require": { diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5916d11 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,19 @@ +const path = require('path'); + +module.exports = { + testEnvironmentOptions: { + "url": "https://localhost/" + }, + verbose: true, + testRegex: "test/.*\\.test.ts", + testEnvironment: 'jsdom', + setupFilesAfterEnv: [ + path.join(__dirname, 'tests/setup.js'), + ], + transform: { + '\\.(j|t)s$': ['babel-jest', { configFile: path.join(__dirname, './babel.config.js') }] + }, + "transformIgnorePatterns": [ + "node_modules/(?!@ngrx|(?!deck.gl)|ng-dynamic)" + ] +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..43c8c50 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "private": true, + "workspaces": [ + "assets" + ], + "scripts": { + "build": "node bin/build_javascript.js", + "test": "yarn workspaces run jest", + "lint": "yarn workspaces run eslint src test", + "format": "prettier assets/src/*.ts assets/test/*.js *.{json,md} --write", + "check-lint": "yarn lint --no-fix", + "check-format": "yarn format --no-write --check" + }, + "devDependencies": { + "@babel/core": "^7.15.8", + "@babel/preset-env": "^7.15.8", + "@babel/preset-react": "^7.15.8", + "@babel/preset-typescript": "^7.15.8", + "@rollup/plugin-commonjs": "^25.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "@rollup/plugin-typescript": "^11.0.0", + "@symfony/stimulus-testing": "^2.0.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "babel-jest": "^29.0", + "clean-css-cli": "^5.6.2", + "eslint": "^8.1.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-jest": "^27.0.0", + "jest": "^29.0.0", + "jest-environment-jsdom": "^29.0", + "prettier": "^3.0.0", + "rollup": "^3.7.0", + "tslib": "^2.3.1", + "typescript": "^5.0.0" + }, + "eslintConfig": { + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "prettier", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/ban-ts-comment": "off", + "quotes": [ + "error", + "single" + ] + }, + "env": { + "browser": true + }, + "overrides": [ + { + "files": [ + "assets/test/**/*.ts" + ], + "extends": [ + "plugin:jest/recommended" + ] + } + ] + }, + "prettier": { + "printWidth": 120, + "trailingComma": "es5", + "tabWidth": 4, + "jsxBracketSameLine": true, + "singleQuote": true + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..ba64664 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,95 @@ +const resolve = require('@rollup/plugin-node-resolve'); +const commonjs = require('@rollup/plugin-commonjs'); +const typescript = require('@rollup/plugin-typescript'); +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); + +/** + * Guarantees that any files imported from a peer dependency are treated as an external. + * + * For example, if we import `chart.js/auto`, that would not normally + * match the "chart.js" we pass to the "externals" config. This plugin + * catches that case and adds it as an external. + * + * Inspired by https://github.com/oat-sa/rollup-plugin-wildcard-external + */ +const wildcardExternalsPlugin = (peerDependencies) => ({ + name: 'wildcard-externals', + resolveId(source, importer) { + if (importer) { + let matchesExternal = false; + peerDependencies.forEach((peerDependency) => { + if (source.includes(`/${peerDependency}/`)) { + matchesExternal = true; + } + }); + + if (matchesExternal) { + return { + id: source, + external: true, + moduleSideEffects: true + }; + } + } + + return null; // other ids should be handled as usually + } +}); + +/** + * Moves the generated TypeScript declaration files to the correct location. + * + * This could probably be configured in the TypeScript plugin. + */ +const moveTypescriptDeclarationsPlugin = (packagePath) => ({ + name: 'move-ts-declarations', + writeBundle: async () => { + const files = glob.sync(path.join(packagePath, 'dist', '**', 'assets', 'src', '**/*.d.ts')); + files.forEach((file) => { + // a bit odd, but remove first 8 directories, which will leave + // only the relative path to the file + const relativePath = file.split('/').slice(5).join('/'); + + const targetFile = path.join(packagePath, 'dist', relativePath); + if (!fs.existsSync(path.dirname(targetFile))) { + fs.mkdirSync(path.dirname(targetFile), { recursive: true }); + } + fs.renameSync(file, targetFile); + }); + } +}); + +const file = process.env.INPUT_FILE; +const packageRoot = path.join(file, '..', '..'); +const packagePath = path.join(packageRoot, 'package.json'); +const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const peerDependencies = [ + '@hotwired/stimulus', + ...(packageData.peerDependencies ? Object.keys(packageData.peerDependencies) : []) +]; + +module.exports = { + input: file, + output: { + file: path.join(packageRoot, 'dist', path.basename(file, '.ts') + '.js'), + format: 'esm', + }, + external: peerDependencies, + plugins: [ + resolve(), + typescript({ + filterRoot: packageRoot, + include: ['**/*.ts'], + compilerOptions: { + outDir: 'dist', + declaration: true, + emitDeclarationOnly: true, + } + }), + commonjs(), + wildcardExternalsPlugin(peerDependencies), + moveTypescriptDeclarationsPlugin(packageRoot), + ], +}; diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index 7e1bb91..ee9040f 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -57,6 +57,23 @@ public function loadExtension(array $config, ContainerConfigurator $container, C } public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void + { + $this->setAssetPublicPrefix($builder); + $this->setAssetMapperPath($builder); + } + + private function setAssetMapperPath(ContainerBuilder $builder): void + { + $builder->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__ . '/../assets/dist' => '@pwa/connection-status/status', + ], + ], + ]); + } + + private function setAssetPublicPrefix(ContainerBuilder $builder): void { $bundles = $builder->getParameter('kernel.bundles'); if (isset($bundles['FrameworkBundle'])) { diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..9be33c3 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,3 @@ +'use strict'; + +import '@symfony/stimulus-testing/setup'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..140b25f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": ["dom", "es2015"], + "module": "es2015", + "moduleResolution": "node", + "noUnusedLocals": true, + "rootDir": "", + "strict": true, + "strictPropertyInitialization": false, + "target": "es2017", + "removeComments": true, + "outDir": "types", + "baseUrl": ".", + "noEmit": false, + "declaration": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "jsx": "react" + }, + "exclude": ["assets/dist"], + "include": ["assets/src", "assets/test"] +}