diff --git a/.editorconfig b/.editorconfig index 4a6589a..ae2dd4c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 -[*.{htaccess,html,xml}] +[*.{htaccess,html,xml,js}] indent_size = 2 [*.php] diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..151b785 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + "extends": "airbnb-base", + "env": { + "browser": true, + }, + "rules": { + "no-param-reassign": 0, // manipulate DOM style properties + "no-restricted-globals": 0, // currently Shaarli uses alert/confirm, could be be improved later + "no-alert": 0, // currently Shaarli uses alert/confirm, could be be improved later + "no-cond-assign": [2, "except-parens"], // assignment in while loops is readable and avoid assignment duplication + } +}; diff --git a/.gitattributes b/.gitattributes index b191e22..549777e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -25,16 +25,17 @@ Dockerfile text *.mo binary # Exclude from Git archives -.editorconfig export-ignore -.gitattributes export-ignore -.github export-ignore -.gitignore export-ignore -.travis.yml export-ignore -doc/**/*.json export-ignore -doc/**/*.md export-ignore -docker/ export-ignore -Doxyfile export-ignore -Makefile export-ignore -mkdocs.yml export-ignore -phpunit.xml export-ignore -tests/ export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.github export-ignore +.gitignore export-ignore +.travis.yml export-ignore +doc/**/*.json export-ignore +doc/**/*.md export-ignore +docker/ export-ignore +Doxyfile export-ignore +Makefile export-ignore +node_modules/ export-ignore +mkdocs.yml export-ignore +phpunit.xml export-ignore +tests/ export-ignore diff --git a/.gitignore b/.gitignore index 3f6939a..414ff6d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,13 @@ doc/html/ tpl/* !tpl/default !tpl/vintage + +# Front end +node_modules +tpl/default/js +tpl/default/css +tpl/default/fonts +tpl/default/img +tpl/vintage/js +tpl/vintage/css +tpl/vintage/img diff --git a/.travis.yml b/.travis.yml index 758aa9f..1b2bf97 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,20 +2,22 @@ sudo: false dist: trusty language: php cache: + yarn: true directories: - $HOME/.composer/cache + - $HOME/.cache/yarn php: - 7.2 - 7.1 - 7.0 - 5.6 install: - - composer self-update + - yarn install - composer install --prefer-dist - - locale -a before_script: - PATH=${PATH//:\.\/node_modules\/\.bin/} script: - make clean - make check_permissions + - make eslint - make all_tests diff --git a/Makefile b/Makefile index 5e3ae26..d121656 100644 --- a/Makefile +++ b/Makefile @@ -157,15 +157,23 @@ composer_dependencies: clean composer install --no-dev --prefer-dist find vendor/ -name ".git" -type d -exec rm -rf {} + +### download 3rd-party frontend libraries +frontend_dependencies: + yarn install + +### Build frontend dependencies +build_frontend: frontend_dependencies + yarn run build + ### generate a release tarball and include 3rd-party dependencies and translations -release_tar: composer_dependencies htmldoc translate +release_tar: composer_dependencies htmldoc translate build_frontend git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD tar rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/ tar rvf $(ARCHIVE_VERSION).tar --transform "s|^doc/html|$(ARCHIVE_PREFIX)doc/html|" doc/html/ gzip $(ARCHIVE_VERSION).tar ### generate a release zip and include 3rd-party dependencies and translations -release_zip: composer_dependencies htmldoc translate +release_zip: composer_dependencies htmldoc translate build_frontend git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD mkdir -p $(ARCHIVE_PREFIX)/{doc,vendor} rsync -a doc/html/ $(ARCHIVE_PREFIX)doc/html/ @@ -207,3 +215,8 @@ htmldoc: ### Generate Shaarli's translation compiled file (.mo) translate: @find inc/languages/ -name shaarli.po -execdir msgfmt shaarli.po -o shaarli.mo \; + +### Run ESLint check against Shaarli's JS files +eslint: + @yarn run eslint assets/vintage/js/ + @yarn run eslint assets/default/js/ diff --git a/assets/.htaccess b/assets/.htaccess new file mode 100644 index 0000000..f601c1e --- /dev/null +++ b/assets/.htaccess @@ -0,0 +1,13 @@ + + = 2.4> + Require all denied + + + Allow from none + Deny from all + + + + + Require all denied + diff --git a/assets/common/js/picwall.js b/assets/common/js/picwall.js new file mode 100644 index 0000000..87a93fc --- /dev/null +++ b/assets/common/js/picwall.js @@ -0,0 +1,10 @@ +import Blazy from 'blazy'; + +(() => { + const picwall = document.getElementById('picwall_container'); + if (picwall != null) { + // Suppress ESLint error because that's how bLazy works + /* eslint-disable no-new */ + new Blazy(); + } +})(); diff --git a/tpl/default/fonts/Roboto-Bold.woff b/assets/default/fonts/Roboto-Bold.woff similarity index 100% rename from tpl/default/fonts/Roboto-Bold.woff rename to assets/default/fonts/Roboto-Bold.woff diff --git a/tpl/default/fonts/Roboto-Bold.woff2 b/assets/default/fonts/Roboto-Bold.woff2 similarity index 100% rename from tpl/default/fonts/Roboto-Bold.woff2 rename to assets/default/fonts/Roboto-Bold.woff2 diff --git a/tpl/default/fonts/Roboto-Regular.woff b/assets/default/fonts/Roboto-Regular.woff similarity index 100% rename from tpl/default/fonts/Roboto-Regular.woff rename to assets/default/fonts/Roboto-Regular.woff diff --git a/tpl/default/fonts/Roboto-Regular.woff2 b/assets/default/fonts/Roboto-Regular.woff2 similarity index 100% rename from tpl/default/fonts/Roboto-Regular.woff2 rename to assets/default/fonts/Roboto-Regular.woff2 diff --git a/tpl/default/img/apple-touch-icon.png b/assets/default/img/apple-touch-icon.png similarity index 100% rename from tpl/default/img/apple-touch-icon.png rename to assets/default/img/apple-touch-icon.png diff --git a/tpl/default/img/favicon.png b/assets/default/img/favicon.png similarity index 100% rename from tpl/default/img/favicon.png rename to assets/default/img/favicon.png diff --git a/tpl/default/img/icon.png b/assets/default/img/icon.png similarity index 100% rename from tpl/default/img/icon.png rename to assets/default/img/icon.png diff --git a/tpl/default/img/sad_star.png b/assets/default/img/sad_star.png similarity index 100% rename from tpl/default/img/sad_star.png rename to assets/default/img/sad_star.png diff --git a/assets/default/js/base.js b/assets/default/js/base.js new file mode 100644 index 0000000..5cf037c --- /dev/null +++ b/assets/default/js/base.js @@ -0,0 +1,606 @@ +import Awesomplete from 'awesomplete'; + +/** + * Find a parent element according to its tag and its attributes + * + * @param element Element where to start the search + * @param tagName Expected parent tag name + * @param attributes Associative array of expected attributes (name=>value). + * + * @returns Found element or null. + */ +function findParent(element, tagName, attributes) { + const parentMatch = key => attributes[key] !== '' && element.getAttribute(key).indexOf(attributes[key]) !== -1; + while (element) { + if (element.tagName.toLowerCase() === tagName) { + if (Object.keys(attributes).find(parentMatch)) { + return element; + } + } + element = element.parentElement; + } + return null; +} + +/** + * Ajax request to refresh the CSRF token. + */ +function refreshToken() { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '?do=token'); + xhr.onload = () => { + const token = document.getElementById('token'); + token.setAttribute('value', xhr.responseText); + }; + xhr.send(); +} + +function createAwesompleteInstance(element, tags = []) { + const awesome = new Awesomplete(Awesomplete.$(element)); + // Tags are separated by a space + awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); + // Insert new selected tag in the input + awesome.replace = (text) => { + const before = awesome.input.value.match(/^.+ \s*|/)[0]; + awesome.input.value = `${before}${text} `; + }; + // Highlight found items + awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]); + // Don't display already selected items + const reg = /(\w+) /g; + let match; + awesome.data = (item, input) => { + while ((match = reg.exec(input))) { + if (item === match[1]) { + return ''; + } + } + return item; + }; + awesome.minChars = 1; + if (tags.length) { + awesome.list = tags; + } + + return awesome; +} + +/** + * Update awesomplete list of tag for all elements matching the given selector + * + * @param selector CSS selector + * @param tags Array of tags + * @param instances List of existing awesomplete instances + */ +function updateAwesompleteList(selector, tags, instances) { + if (instances.length === 0) { + // First load: create Awesomplete instances + const elements = document.querySelectorAll(selector); + [...elements].forEach((element) => { + instances.push(createAwesompleteInstance(element, tags)); + }); + } else { + // Update awesomplete tag list + instances.map((item) => { + item.list = tags; + return item; + }); + } + return instances; +} + +/** + * html_entities in JS + * + * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript + */ +function htmlEntities(str) { + return str.replace(/[\u00A0-\u9999<>&]/gim, i => `&#${i.charCodeAt(0)};`); +} + +function activateFirefoxSocial(node) { + const loc = location.href; + const baseURL = loc.substring(0, loc.lastIndexOf('/') + 1); + + const data = { + name: document.title, + description: document.getElementById('translation-delete-link').innerHTML, + author: 'Shaarli', + version: '1.0.0', + + iconURL: `${baseURL}/images/favicon.ico`, + icon32URL: `${baseURL}/images/favicon.ico`, + icon64URL: `${baseURL}/images/favicon.ico`, + + shareURL: `${baseURL}?post=%{url}&title=%{title}&description=%{text}&source=firefoxsocialapi`, + homepageURL: baseURL, + }; + node.setAttribute('data-service', JSON.stringify(data)); + + const activate = new CustomEvent('ActivateSocialFeature'); + node.dispatchEvent(activate); +} + +/** + * Add the class 'hidden' to city options not attached to the current selected continent. + * + * @param cities List of