(Grav GitSync) Automatic Commit from GitSync

This commit is contained in:
GitSync
2026-06-14 00:27:27 +00:00
parent a2920f812d
commit 3c1bfda80f
2933 changed files with 491625 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
{
"env": {
"browser": true,
"node": true
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 7,
"sourceType": "module"
},
"rules": {
"accessor-pairs": 2,
"array-bracket-spacing": 0,
"block-scoped-var": 0,
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"camelcase": 0,
"comma-dangle": [2, "never"],
"comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"],
"complexity": 0,
"computed-property-spacing": 0,
"consistent-return": 0,
"consistent-this": 0,
"constructor-super": 2,
"curly": [2, "multi-line"],
"default-case": 0,
"dot-location": [2, "property"],
"dot-notation": 0,
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"func-names": 0,
"func-style": 0,
"generator-star-spacing": [2, { "before": true, "after": true }],
"guard-for-in": 0,
"handle-callback-err": [2, "^(err|error)$" ],
"indent": [2, 4, { "SwitchCase": 1 }],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"linebreak-style": 0,
"lines-around-comment": 0,
"max-nested-callbacks": 0,
"new-cap": [2, { "newIsCap": true, "capIsNew": false }],
"new-parens": 2,
"newline-after-var": 0,
"no-alert": 0,
"no-array-constructor": 2,
"no-caller": 2,
"no-catch-shadow": 0,
"no-cond-assign": 2,
"no-console": 0,
"no-constant-condition": 0,
"no-continue": 0,
"no-control-regex": 2,
"no-debugger": 2,
"no-delete-var": 2,
"no-div-regex": 0,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-else-return": 0,
"no-empty": 0,
"no-empty-character-class": 2,
"no-eq-null": 0,
"no-eval": 2,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": 0,
"no-extra-semi": 0,
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inline-comments": 0,
"no-inner-declarations": [2, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-lonely-if": 0,
"no-loop-func": 0,
"no-mixed-requires": 0,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 1 }],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-nested-ternary": 0,
"no-new": 2,
"no-new-func": 0,
"no-new-object": 2,
"no-new-require": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-param-reassign": 0,
"no-path-concat": 0,
"no-process-env": 0,
"no-process-exit": 0,
"no-proto": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-modules": 0,
"no-return-assign": 2,
"no-script-url": 0,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 0,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-sync": 0,
"no-ternary": 0,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-undefined": 0,
"no-underscore-dangle": 0,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": 2,
"no-unreachable": 2,
"no-unused-expressions": 0,
"no-unused-vars": [2, { "vars": "all", "args": "none" }],
"no-use-before-define": 0,
"no-var": 0,
"no-void": 0,
"no-warning-comments": 0,
"no-with": 2,
"object-curly-spacing": 0,
"object-shorthand": 0,
"one-var": [2, { "initialized": "never" }],
"operator-assignment": 0,
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"padded-blocks": 0,
"prefer-const": 0,
"quote-props": 0,
"quotes": [2, "single", "avoid-escape"],
"radix": 2,
"semi": [2, "always"],
"semi-spacing": 0,
"sort-vars": 0,
"keyword-spacing": [2, {"after": true, "overrides": {"throw": { "after": true}, "return": { "before": true }}}],
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": [2, "always", { "markers": ["global", "globals", "eslint", "eslint-disable", "*package", "!"] }],
"strict": 0,
"use-isnan": 2,
"valid-jsdoc": 0,
"valid-typeof": 2,
"vars-on-top": 0,
"wrap-iife": [2, "any"],
"wrap-regex": 0,
"yoda": [2, "never"]
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules
*.js.map
*.css.map
/.idea
+263
View File
@@ -0,0 +1,263 @@
# v3.4.3
## 06/01/2026
1. [](#bugfix)
* Fixed commits failing with `fatal: empty ident name (for <…>) not allowed` when the committer name resolved to an empty string — for example a blank **Committer Name** field, or "Use Grav User Full Name" selected for an admin account with no full name set. Blank names/emails now fall back to the `GitSync` / `git-sync@trilby.media` defaults [#249](https://github.com/trilbymedia/grav-plugin-git-sync/issues/249).
# v3.4.2
## 05/29/2026
1. [](#bugfix)
* **The Git Sync sidebar entry and the toolbar Synchronize button are now hidden from users who lack any Git Sync permission**, matching how admin-classic gates the same menu. The items only appear for users with one of `api.git-sync`, `api.git-sync.read`, `api.git-sync.write`, or `api.git-sync.admin` (or super-admins). Requires grav-plugin-api ≥ 1.0.0-rc.11. Fixes [getgrav/grav-plugin-admin2#23](https://github.com/getgrav/grav-plugin-admin2/issues/23).
# v3.4.1
## 05/07/2026
1. [](#bugfix)
* Fixed automatic sync not running after page save / delete / media changes in Admin2 [#250](https://github.com/trilbymedia/grav-plugin-git-sync/issues/250).
* Fixed the Wizard's webhook URL preview leaving out the install sub-folder when Grav is hosted under a path [#249](https://github.com/trilbymedia/grav-plugin-git-sync/issues/249).
# v3.4.0
## 05/06/2026
1. [](#new)
* Added a dedicated Git Sync page in Admin2 with a sidebar entry and Wizard / Synchronize / Reset Local Copy / Save buttons in the page header.
* Added a step-by-step setup Wizard in Admin2 that walks you through hosting service, repository, webhook and folders, with a one-click button to verify your credentials and branch.
* Added a quick Synchronize button to the Admin2 toolbar that's available everywhere once Git Sync is configured.
* Added a proper password input for the Git Password / Token field in Admin2 with a show / hide toggle and the same "securely stored" placeholder you're used to.
1. [](#improved)
* The plugin's entry under Admin2 → Plugins now shows just the enable / disable toggle and a pointer to the dedicated Git Sync page, so you don't see the same form twice.
* Synchronize and Reset Local Copy keep the rest of Admin2 responsive while they're running.
# v3.2.1
## 05/01/2026
1. [](#improved)
* Added 1.7|2.0 compatibility flags
# v3.2.0
## 12/29/2025
1. [](#improved)
* Improved PHP 8.4 compatibility
1. [](#bugfix)
* Fix issue with saving reporting problems with `Folders`.
# v3.1.0
## 12/03/2025
1. [](#new)
* Added sync direction configuration to enable one-way (pull only) synchronization [#224](https://github.com/trilbymedia/grav-plugin-git-sync/pull/224)
1. [](#improved)
* Fixed git pull command for modern git versions by adding `--ff` flag [#246](https://github.com/trilbymedia/grav-plugin-git-sync/pull/246)
* Removed unnecessary `config->reload()` call in `onAdminAfterSave` event
# v3.0.0
## 10/19/2025
1. [](#improved)
* Grav 1.8 support
* Use `{$var}` instead of `${var}` causing deprecation notices
* Prevent accepting webhooks when they are disabled in config [#216](https://github.com/trilbymedia/grav-plugin-git-sync/issues/216)
* Updated FAQ link to discussion
# v2.3.2
## 06/03/2021
1. [](#bugfix)
* Better validation for Git Repository value on both Wizard and Backend.
* Prevent malicious commands from being executed in Wizard when "Verifying Authentication, Connection and Branch".
# v2.3.1
## 04/30/2021
1. [](#bugfix)
* Fixed regression where `testRepository` would erroneously pass with invalid credentials [#200](https://github.com/trilbymedia/grav-plugin-git-sync/issues/200)
* Fixed Exception thrown with `bin/plugin git-sync status` command, preventing `sync` [#200](https://github.com/trilbymedia/grav-plugin-git-sync/issues/200)
# v2.3.0
## 04/27/2021
1. [](#new)
* Added new Advanced Git Ignore field where it is possible to specify custom git ignore entries to play along with GitSync [#197](https://github.com/trilbymedia/grav-plugin-git-sync/issues/197) [#117](https://github.com/trilbymedia/grav-plugin-git-sync/issues/117)
* Support `ssh://` protocol and SSH Key authentication ([read more](https://github.com/trilbymedia/grav-plugin-git-sync#ssh--enterprise)) [#110](https://github.com/trilbymedia/grav-plugin-git-sync/issues/110)
1. [](#improved)
* Updated PHP Encryption dependency
1. [](#bugfix)
* Fixed issue with Flex Objects, preventing GitSync's settings to get refreshed `onAdminSave` when "Sync on Page Save" disabled
* Return raw URL for repositories setup with `ssh://` protocol, instead of injecting the password like `git://` and `http://` protocols do [#104](https://github.com/trilbymedia/grav-plugin-git-sync/issues/104)
# v2.2.0
## 04/17/2021
1. [](#improved)
* Better support for branches other than `master`. This includes the transition to `main` from GitHub and the groundwork to support other big providers making the change as announced soon. GitSync is now capable of preset the branch based on the provider selected. You are now also able to specify any custom branch and when testing the repository connection it will also ensure the branch exists and provide feedback if not.
1. [](#bugfix)
* Changing remote branch is now going to properly reference it instead of remaining stuck to `master` [#192](https://github.com/trilbymedia/grav-plugin-git-sync/issues/192), [#183](https://github.com/trilbymedia/grav-plugin-git-sync/issues/183)
* Fixed issue where the Folders to synchronize from the Wizard wouldn't get properly saved [#178](https://github.com/trilbymedia/grav-plugin-git-sync/issues/178)
# v2.1.1
## 07/17/2020
1. [](#new)
* Added `No User` option to allow disabling the username requirement. This is useful for when you have a token and the user is not required. (#166, thanks GwynethLlewelyn)
* Added `passwd` command for programmatically change user/password (use: `bin/plugin git-sync passwd`) (#146)
* Fixed regression wrongly returning the installed Git version and causing all sort of problems, including unrelated histories not kicking off (#61, #168, #171, #173)
* Fixed potential issue where the new feature `no_user` my throw an error
* Fixed issue with autoload
1. [](#bugfix)
* Fixed classes not being loaded in `cli` commands due to Grav changes (#167)
* Updated dependencies / recompiled JS for production
1. [](#improved)
* Bumped modules versions
# v2.1.0
## 03/13/2020
1. [](#new)
* Requires Grav v1.6.0
* Pass phpstan level 2 tests
1. [](#improved)
* Code cleanup
* Added support for Gitea / Gogs webhook secret (#149, thanks @Aisbergg)
# v2.0.5
## 05/06/2019
1. [](#bugfix)
* Fixed validation error with commalist in Folders to Sync field (#141)
# v2.0.4
## 04/22/2019
1. [](#improved)
* urlencode username to allow for special characters (#139)
# v2.0.3
## 03/07/2019
1. [](#bugifx)
* Properly fallback to config message if not there yet (#134)
# v2.0.2
## 02/21/2019
1. [](#improved)
* Fixed InitCommand spelling (#132, thanks @alex-mohemian)
1. [](#bugfix)
* Fixed PHP 5.6 incompatibility introduced by latest release.
# v2.0.1
## 02/19/2019
1. [](#new)
* Added new `init` CLI command (`bin/plugin git-sync init`) (#128, thanks @LeonRyan and @alex-mohemian)
1. [](#improved)
* Allow setting a personalised commit message (#123, thanks @kyed)
* Added better directions for Azure + IIS users for the Git Binary
1. [](#bugfix)
* Fixed `LC_ALL` to use `C` instead of en_US.UTF-8`, to be more flexible (#124, #125, thanks @lambopedia)
# v2.0.0
## 10/15/2018
1. [](#new)
* Added support for new awesome Grav 1.6 Scheduler
* Added logic to display custom nested folders in wizard
* Other than `pages`, it is now possible to enable `config`, `data`, `plugins` and `themes` for synchronization. You can also add any custom folder you have in your `user` (#4, #21, #34, #58, #63, #83)
* Allow users with `admin.pages` permissions to synchronize through quick tray (#79, thanks @apfrod)
* When using Grav as committer, the user email will be now used for the commit (#81, thanks @apfrod)
* Added support for Webhook Secret (Bitbucket does not yet support them) (#72, #73, thanks @pathmissing)
* Added options to turn automatic synchronization on/off with page saves, delete and media changes (#105, thanks @AmauryCarrade)
1. [](#improved)
* Fixed alignment of the git icon in the Wizard (#115)
* Prevent Wizard modal to get canceled when clicking on the overlay background (#115)
* Quick tray icon is now smarter. If GitSync has not been initialized yet, it will take you straight to wizard, otherwise it would perform a synchronization (#115)
* Rearranged blueprint order (thanks @paulhibbitts)
* GitLab: Updated wizard instructions to be inline with the new GitLab UI (#90)
* Tweaked alignment of links in the wizard (#57)
* Properly support local branches that aren't `master` (#56)
* Allow to specify custom local_repository (default, `USER_DIR`) (#95, thanks @Hydraner, also #54, #33, #25)
* Webhook URL is now more robust and secure, by default it is generated with a random value
* Git icon from Admin has been replaced to use the `git` text icon instead of the logo
* Prevent next step if Step 1 and Step 2 are not filled in (#92)
* Added notice in Step 2 explaning what GitSync expect from the repository structure (#92)
1. [](#bugfix)
* Fixed issue where on first initialization the checkout process would error out
* Fixed issue with Pages save.
* Fixed JS error in plugins list
* Fixed nested folders not synchronizing
* Fixed issue where Wizard wouldn't work in case the `admin` path was modified (#27, #94, #77, thanks @pathmissing)
* Fixed webhook generated URL when multi-lang active (#71)
* Resolved issue with untracked/uncommited files at the root of the `sync` folder. (#101, thanks @ScottHamper)
# v1.0.4
## 08/16/2017
1. [](#new)
* CLI: Added `status` command to check config and git (#52, thanks @karfau)
* Allow local branches to be named differently than the remote branches (#48, thanks @denniswebb)
* Added support for new Admin Navigation Tray
1. [](#bugfix)
* Fixed minimum Git required version to support `--all` (#32,#49, thanks @redrohX)
# v1.0.3
## 02/21/2017
1. [](#bugfix)
* Fixed issue with new 'author' option that could trigger errors when settings were not saved. (#23)
* Fixed the 'More Details' button triggering the Modal to close instead of just expanding the details
# v1.0.2
## 02/18/2017
1. [](#new)
* It is now possible to change the committer name. You can choose between Git User, GitSync Committer Name, Grav User Name, Grav User Fullname (#14).
2. [](#improved)
* Added more documentation and description about the support of 2FA and Access Tokens (#16, #19, thanks @OleVik)
* Added 4th Generic Git choice in the wizard for self-hosted and custom git services (Gogs/Gitea) (#7 - #22 - thanks @erlepereira)
1. [](#bugfix)
* Fixed issue preventing the custom Git Binary Path from getting used (#15)
* Fixed issue with Webhook auto-generated URL where it would display double slashes in case of root domain (#15)
* Fixed issue with the modal not properly restoring the tutorial steps of the active selected service
# v1.0.1
## 01/29/2017
1. [](#bugfix)
* Changed default GitSync email for commits
# v1.0.0
## 01/25/2017
1. [](#new)
* Released plugin to stable GPM channel
# v1.0.0-rc.3
## 01/19/2017
1. [](#new)
* Added logger setting to log Git command executions
1. [](#improved)
* Improved Windows compatibility
# v1.0.0-rc.2
## 01/16/2017
1. [](#new)
* Allow to change the path for the `git` binary (#1)
* Added CLI for synchronizing `bin/plugin git-sync sync` (#2)
* More security: Git password will now get encrypted and won't load in admin
1. [](#improved)
* Wizard: Improved Bitbucket explanation about stripping out `user@` from the copied HTTPS url (#3)
1. [](#bugfix)
* Fixed potential issue when retrieving the currently installed git version
* Fixed issue that would not properly hide the password from error messages if the password contained special chars
* Fixed issue preventing the plugin to properly get setup the very first time and causing 401 error (#4)
* Workaround for error thrown when removing the plugin
# v1.0.0-rc.1
## 12/19/2016
1. [](#new)
* Initial Release
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+90
View File
@@ -0,0 +1,90 @@
![](images/gitsync-logo.png)
**Git Sync** is a Plugin for [Grav CMS](http://github.com/getgrav/grav) that allows to seamlessly synchronize a Git repository with your Grav site, and vice-versa.
Git Sync captures any change that you make on your site and instantly updates your git repository. In the same way, Git Sync supports _webhooks_, allowing to automatically synchronize your site if the repository changes.
Thanks to this powerful bi-directional flow, Git Sync can now turn your site into a collaborative environment where the source of truth is always your git repository and unlimited collaborators and sites can share and contribute to the same content.
## Videos: Setup and Demo
| Up and Running in 2 mins | 2-way Sync Demonstration |
| ------------ | ----------------- |
| [![Up and Running in 2 mins](https://img.youtube.com/vi/avcGP0FAzB8/0.jpg)](https://www.youtube.com/watch?v=avcGP0FAzB8) | [![2-way Sync Demonstration](https://img.youtube.com/vi/3fy78afacyw/0.jpg)](https://www.youtube.com/watch?v=3fy78afacyw) |
## Installation using the GPM (Grav Package Manager)
To install git-sync simply run this command from the Grav root folder
```
bin/gpm install git-sync
```
After having installed the plugin, make sure to go in the plugin settings in order to get the Wizard configuration started.
## Features
<img src="wizard.png" width="500" />
* Easy step-by-step Wizard setup will guide you through a detailed process for setting things up
* Supported hosting services: [GitHub](https://github.com), [BitBucket](https://bitbucket.org), [GitLab](https://gitlab.com) as well as any self-hosted and git service with webhooks support.
* Private repositories
* Basic SSH / Enterprise support (You will need SSH Key properly setup on your machine)
* Synchronize any folder under `user` (pages, themes, config)
* 2FA (Two-Factor Authentication) and Access Token support
* Webhooks support allow for automatic synchronization from the Git Repository with secure Webhook URL auto-generated and support for Webhook Secret (when available)
* Automatically handles simple merges behind the scenes
* Easy one-click button to reset your local changes and restores it to the actual state of the git repository
* Easy one-click button for manual synchronization
* Support for Admin Quick Tray, so you can synchronize from anywhere in Admin
* Ability to customize whether GitSync should synchronize upon save or just manually
* Customize the Committer Name, choose between Git User, GitSync Commiter Name, Grav User Name and Grav user Fullname
* With the built-in Form Process action `gitsync`, you can trigger the synchronization anytime someone submits a post.
* Any 3rd party plugin can integrate with Git Sync and trigger the synchronization through the `gitsync` event.
* Built-in CLI commands to automate synchronizations
* Log any command performed by GitSync to ensure everything runs smoothly or debug potential issues
# Command Line Interface
Git Sync comes with a CLI that allows running synchronizations right within your terminal. This feature is extremely useful in case you'd like to run an autonomous periodic crontab jobs to synchronize with your repository.
To execute the command simply run:
```bash
bin/plugin git-sync sync
```
You can also get a status by running:
```bash
bin/plugin git-sync status
```
Since version 2.1.1 you can now also programmatically change user/password via the `bin/plugin git-sync passwd`. This is useful if you have a container that resets your password, or you have some running scripts that require to programmatically update the password.
# Requirements
In order for the plugin to work, the server needs to run `git` 1.7.1 and above.
The PHP `exec()` and `escapeshellarg()` functions are mandatory. Ensure the options to be enabled in your PHP.
# SSH / Enterprise
Since version v2.3.0, GitSync supports SSH authentication. This means you can omit password altogether and rely on the Repository URL and SSH key on your machine, that you can point to from the Advanced settings in GitSync.
Please note that In order to be able to sparse-checkout and push changes, it is expected you have an ssh-key configured for accessing the repository. This is usually found under `~/.ssh` and it needs to be configured for the same user that runs the web-server.
Point it to the secret (not the public) and make also sure you have strict permissions to the file. (`-rw-------`).
Example: private_key: `/home/www-data/.ssh/id_rsa`
> **IMPORTANT**: SSH keys with passphrase are **NOT** supported. To remove a passphrase, run the `ssh-keygen -p` command and when asked for the new passphrase leave blank and return.
# Known Issues and Resolutions
**Q:** `error: The requested URL returned error: 403 Forbidden while accessing...` (reference, [#39](https://github.com/trilbymedia/grav-plugin-git-sync/issues/39))
**A:** This might be caused by your computer having stored in the registry a user/password that might conflict with the one you are intending to use.
[Follow the instructions for resolving the issue...](https://github.com/trilbymedia/grav-plugin-git-sync/discussions/202#discussioncomment-869460)
# Sponsored by
This plugin could not have been realized without the sponsorship of [HibbittsDesign.org](http://www.hibbittsdesign.org) and the development of [Trilby Media](http://trilby.media).
@@ -0,0 +1,174 @@
/**
* Git Sync — enc-password custom field for admin-next.
*
* The plugin's git-sync/data endpoint never returns the stored password to
* the client (it's encrypted at rest and only the boolean `password_stored`
* flag is sent back). The field renders a normal password input whose
* placeholder reflects whether a password is on file:
*
* - empty + nothing stored → "Your Git Password or Token"
* - empty + stored encrypted → "Your password is securely stored."
* - empty + stored unencrypted → "Your password is stored but not encrypted."
*
* Submitting an empty value tells the server "keep what's already there";
* any non-empty value replaces and re-encrypts.
*/
const TAG = window.__GRAV_FIELD_TAG;
class EncPasswordField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._field = null;
this._value = '';
this._show = false;
this._stored = false;
this._encrypted = false;
}
set field(v) {
this._field = v || {};
// Server annotates the resolved blueprint with the current
// password storage state (see onApiBlueprintResolved in git-sync.php).
this._stored = !!(this._field.password_stored);
this._encrypted = !!(this._field.password_encrypted);
this._render();
}
get field() { return this._field; }
set value(v) {
const next = (v == null) ? '' : String(v);
if (next !== this._value) {
this._value = next;
// Re-render only if we haven't already mounted, to avoid stomping
// on the user's in-flight typing.
const input = this.shadowRoot.querySelector('input');
if (!input) this._render();
}
}
get value() { return this._value; }
connectedCallback() {
this._render();
}
_placeholder() {
if (this._value) return this._field.placeholder || '';
if (this._stored && this._encrypted) return 'Your password is securely stored.';
if (this._stored && !this._encrypted) return 'Your password is stored but not encrypted.';
return this._field.placeholder || 'Your Git Password or Token';
}
_onInput(e) {
this._value = e.target.value;
this.dispatchEvent(new CustomEvent('change', {
detail: this._value,
bubbles: true,
}));
}
_toggleVisibility() {
this._show = !this._show;
const input = this.shadowRoot.querySelector('input');
const btn = this.shadowRoot.querySelector('.toggle');
if (input) input.type = this._show ? 'text' : 'password';
if (btn) btn.setAttribute('aria-pressed', String(this._show));
if (btn) btn.innerHTML = this._show ? this._eyeOff() : this._eye();
}
_eye() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';
}
_eyeOff() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-10-7-10-7a17.36 17.36 0 0 1 4.06-5.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 10 7 10 7a17.36 17.36 0 0 1-3.17 4.55"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
}
_render() {
const placeholder = this._placeholder();
const value = this._value;
const autocomplete = (this._field && this._field.autocomplete) || 'new-password';
this.shadowRoot.innerHTML = `
<style>
:host { display: block; font-family: inherit; }
.wrap {
position: relative;
display: flex;
align-items: stretch;
width: 100%;
height: 2.5rem;
border: 1px solid var(--input, var(--border));
background: color-mix(in srgb, var(--muted) 50%, transparent);
border-radius: 0.5rem;
transition: box-shadow 120ms ease, border-color 120ms ease;
}
.wrap:focus-within {
outline: none;
box-shadow: 0 0 0 1px var(--ring);
border-color: var(--ring);
}
input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--foreground);
background: transparent;
border: 0;
border-radius: 0.5rem 0 0 0.5rem;
box-sizing: border-box;
font-family: inherit;
}
input:focus { outline: none; }
input::placeholder {
color: var(--muted-foreground);
opacity: 1;
}
.toggle {
flex: 0 0 auto;
width: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
color: var(--muted-foreground);
cursor: pointer;
border-radius: 0 0.5rem 0.5rem 0;
padding: 0;
font: inherit;
}
.toggle:hover {
color: var(--foreground);
background: color-mix(in srgb, var(--accent) 60%, transparent);
}
.stored-hint {
margin-top: 0.375rem;
font-size: 0.75rem;
color: #b45309;
}
</style>
<div class="wrap">
<input
type="password"
autocomplete="${autocomplete}"
placeholder="${placeholder.replace(/"/g, '&quot;')}"
value="${value.replace(/"/g, '&quot;')}"
/>
<button type="button" class="toggle" aria-label="Show password" aria-pressed="false" tabindex="-1">
${this._eye()}
</button>
</div>
${this._stored && !this._encrypted ? '<div class="stored-hint">Existing password is stored unencrypted — saving the form will encrypt it.</div>' : ''}
`;
const input = this.shadowRoot.querySelector('input');
input.addEventListener('input', (e) => this._onInput(e));
const toggle = this.shadowRoot.querySelector('.toggle');
toggle.addEventListener('click', () => this._toggleVisibility());
}
}
customElements.define(TAG, EncPasswordField);
@@ -0,0 +1,863 @@
/**
* Git Sync — auto-loaded admin-next widget.
*
* Registered with `autoLoad: true, showFab: false`, so admin-next pulls
* this script on every admin page load but never instantiates the custom
* element (there's no FAB, no panel). The script's job is to:
*
* 1. Listen at the window level for the `grav:plugin-page-action` event
* that the plugin page header dispatches when the Wizard action is
* clicked. (Patched into admin-next's `executeAction` for blueprint
* pages with no endpoint / no navigate.)
* 2. Render the wizard as a centered modal portal'd into document.body
* so it's not constrained by the floating-widget panel chrome.
*
* The wizard mirrors the four-step flow from the admin-classic Twig
* partial (`templates/partials/modal-wizard.html.twig`) but talks to the
* plugin's API endpoints (`/git-sync/wizard/state`, `/git-sync/data`,
* `/git-sync/test-connection`) instead of admin-classic tasks.
*/
const TAG = window.__GRAV_WIDGET_TAG;
// ─── API helpers ─────────────────────────────────────────────────────────
function apiUrl(path) {
return (window.__GRAV_API_SERVER_URL || '') +
(window.__GRAV_API_PREFIX || '/api/v1') + path;
}
function apiHeaders(json = false) {
const h = {};
const token = window.__GRAV_API_TOKEN;
if (token) h['X-API-Token'] = token;
if (json) h['Content-Type'] = 'application/json';
return h;
}
async function apiCall(method, path, body) {
const opts = { method, headers: apiHeaders(!!body) };
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(apiUrl(path), opts);
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
if (!resp.ok) {
const msg = (json.errors && json.errors[0] && json.errors[0].detail)
|| json.detail || json.message || `HTTP ${resp.status}`;
throw new Error(msg);
}
return json.data ?? json;
}
// ─── Service catalogue (mirrors admin-classic wizard) ───────────────────
const SERVICES = {
github: { host: 'github.com', branch: 'main', create: 'https://github.com/join?source=header-home' },
bitbucket: { host: 'bitbucket.org', branch: 'master', create: 'https://bitbucket.org/account/signup/' },
gitlab: { host: 'gitlab.com', branch: 'master', create: 'https://gitlab.com/users/sign_up' },
allothers: { host: 'allothers.repo', branch: 'master', create: null },
};
const GIT_REGEX = /(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|#[-\d\w._]+?)$/;
function detectServiceFromUrl(url) {
if (!url) return null;
if (url.includes('github.com')) return 'github';
if (url.includes('bitbucket.org')) return 'bitbucket';
if (url.includes('gitlab.com')) return 'gitlab';
return 'allothers';
}
// ─── Wizard modal ───────────────────────────────────────────────────────
class WizardModal {
constructor() {
this.host = null;
this.shadow = null;
this.step = 0;
this.maxStep = 4;
this.state = null;
this.frontendUrl = '';
this.draft = {
service: '',
no_user: false,
user: '',
password: '',
repository: '',
branch: 'main',
webhook: '',
webhook_enabled: false,
webhook_secret: '',
folders: ['pages'],
};
this.testing = false;
this.testResult = null;
this.saving = false;
this.saveError = '';
}
async open() {
if (this.host) return;
this.host = document.createElement('div');
this.host.setAttribute('data-grav-gitsync-wizard', '');
this.shadow = this.host.attachShadow({ mode: 'open' });
document.body.appendChild(this.host);
this._injectStyles();
// Disable page scroll
this._prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// Esc to close
this._onKeydown = (e) => { if (e.key === 'Escape') this.close(); };
window.addEventListener('keydown', this._onKeydown);
// Render skeleton, then load state
this._render();
try {
const state = await apiCall('GET', '/git-sync/wizard/state');
this.state = state;
// Server-derived public site URL (Uri::base + Uri::rootUrl) so
// that Grav installs in a sub-folder render the right webhook URL.
this.frontendUrl = state.frontend_url || window.location.origin;
// Pre-fill from saved settings
const s = state.settings || {};
if (s.repository) {
this.draft.service = detectServiceFromUrl(s.repository) || 'allothers';
this.draft.repository = s.repository;
}
if (typeof s.no_user === 'boolean') this.draft.no_user = s.no_user;
if (s.user) this.draft.user = s.user;
if (s.branch) this.draft.branch = s.branch;
if (s.webhook) this.draft.webhook = s.webhook;
if (typeof s.webhook_enabled === 'boolean') this.draft.webhook_enabled = s.webhook_enabled;
if (s.webhook_secret) this.draft.webhook_secret = s.webhook_secret;
if (Array.isArray(s.folders) && s.folders.length) this.draft.folders = s.folders.slice();
} catch (err) {
console.warn('[git-sync] wizard state load failed:', err);
this.state = { git_installed: true, settings: {} };
}
this._render();
}
close() {
if (!this.host) return;
document.body.style.overflow = this._prevOverflow || '';
window.removeEventListener('keydown', this._onKeydown);
this.host.remove();
this.host = null;
this.shadow = null;
this.step = 0;
this.testResult = null;
this.saveError = '';
}
_injectStyles() {
const style = document.createElement('style');
style.textContent = `
:host { all: initial; font-family: inherit; }
* { box-sizing: border-box; }
.backdrop {
position: fixed; inset: 0;
background: rgb(23 23 23 / 0.75);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
animation: fadeIn 150ms ease-out;
}
.modal {
width: 100%;
max-width: 720px;
max-height: calc(100vh - 2rem);
background: var(--card, #fff);
color: var(--card-foreground, var(--foreground, #0f172a));
border: 1px solid var(--border, #e2e8f0);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
animation: pop 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border, #e2e8f0);
}
.title { font-size: 1.05rem; font-weight: 600; }
.step-pill { font-size: 0.7rem; color: var(--muted-foreground, #64748b); margin-left: 0.5rem; }
.close-btn {
background: transparent; border: 0; color: var(--muted-foreground, #64748b);
cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; line-height: 0;
}
.close-btn:hover { background: var(--accent, #f1f5f9); color: var(--foreground); }
.body {
padding: 1.25rem;
overflow-y: auto;
flex: 1;
font-size: 0.875rem;
line-height: 1.5;
}
.body p { margin: 0 0 0.75rem 0; }
.body p:last-child { margin-bottom: 0; }
.body code {
background: var(--muted, #f1f5f9);
border-radius: 0.25rem; padding: 0.05rem 0.3rem;
font-size: 0.8125rem; font-family: ui-monospace, SFMono-Regular, monospace;
}
.body ul, .body ol { padding-left: 1.25rem; margin: 0 0 0.75rem 0; }
.body li { margin-bottom: 0.25rem; }
.body h4 { margin: 1rem 0 0.5rem 0; font-size: 0.95rem; }
.footer {
display: flex; justify-content: space-between; align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.25rem;
background: var(--muted, #f8fafc);
border-top: 1px solid var(--border, #e2e8f0);
}
.footer-right { display: flex; gap: 0.5rem; }
.btn {
font: inherit; font-size: 0.8125rem; font-weight: 500;
height: 2rem; padding: 0 0.75rem;
background: var(--background, #fff);
color: var(--foreground, #0f172a);
border: 1px solid var(--border, #e2e8f0);
border-radius: 0.375rem;
cursor: pointer;
display: inline-flex; align-items: center; gap: 0.375rem;
transition: background 120ms ease, border-color 120ms ease;
}
.btn:hover { background: var(--accent, #f1f5f9); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--primary, #0ea5e9);
color: var(--primary-foreground, #fff);
border-color: var(--primary, #0ea5e9);
}
.btn-primary:hover { background: color-mix(in srgb, var(--primary, #0ea5e9) 85%, black); }
.btn-danger {
background: #ef4444; color: #fff; border-color: #ef4444;
}
.btn-danger:hover { background: #dc2626; border-color: #dc2626; }
.hosting-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-top: 0.75rem;
}
.host-card {
display: flex; flex-direction: column; align-items: center;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--border, #e2e8f0);
border-radius: 0.5rem;
cursor: pointer;
background: var(--background, #fff);
transition: border-color 120ms, box-shadow 120ms;
}
.host-card:hover { border-color: var(--primary, #0ea5e9); }
.host-card.selected {
border-color: var(--primary, #0ea5e9);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary, #0ea5e9) 20%, transparent);
}
.host-card .name { font-weight: 600; font-size: 0.875rem; }
.host-card .small { font-size: 0.75rem; color: var(--muted-foreground, #64748b); }
label.field {
display: block;
margin-bottom: 0.875rem;
}
label.field > .lbl {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.8125rem; font-weight: 600;
margin-bottom: 0.375rem;
}
label.field input[type="text"],
label.field input[type="password"] {
width: 100%;
height: 2.25rem;
padding: 0 0.625rem;
font: inherit;
font-size: 0.875rem;
background: var(--background, #fff);
color: var(--foreground, #0f172a);
border: 1px solid var(--border, #e2e8f0);
border-radius: 0.375rem;
}
label.field input:focus {
outline: none;
border-color: var(--primary, #0ea5e9);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0ea5e9) 20%, transparent);
}
label.field input.invalid {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.2);
}
.inline-checkbox {
font-size: 0.75rem; font-weight: 500;
color: var(--muted-foreground, #64748b);
display: inline-flex; align-items: center; gap: 0.25rem;
}
.creds-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
.verify-row {
display: flex; justify-content: center; margin: 0.75rem 0;
}
.test-result {
margin-top: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.test-result.success {
background: rgba(34, 197, 94, 0.1);
color: #15803d;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.test-result.error {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.folder-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.folder-card {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border, #e2e8f0);
border-radius: 0.375rem;
cursor: pointer;
background: var(--background, #fff);
}
.folder-card.selected {
border-color: var(--primary, #0ea5e9);
background: color-mix(in srgb, var(--primary, #0ea5e9) 8%, var(--background, #fff));
}
.folder-card .warn {
font-size: 0.7rem; color: #b45309;
}
.save-error {
margin-top: 0.75rem;
padding: 0.625rem 0.75rem;
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.375rem;
font-size: 0.8125rem;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes pop {
from { opacity: 0; transform: scale(0.96) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.spin {
display: inline-block;
width: 0.875rem; height: 0.875rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
`;
this.shadow.appendChild(style);
}
_render() {
const container = this.shadow.querySelector('.backdrop') || (() => {
const el = document.createElement('div');
el.className = 'backdrop';
// Backdrop dismissal guard: only close when both mousedown AND
// mouseup land on the backdrop itself. Without this, dragging
// a text selection out of an input inside the modal — or any
// mousedown-inside-mouseup-outside motion — fires a click on
// the backdrop and snaps the wizard shut. The wizard form is
// long enough that accidental drags happen often.
let mouseDownOnBackdrop = false;
el.addEventListener('mousedown', (e) => {
mouseDownOnBackdrop = (e.target === el);
});
el.addEventListener('mouseup', (e) => {
if (mouseDownOnBackdrop && e.target === el && !this.saving) {
this.close();
}
mouseDownOnBackdrop = false;
});
this.shadow.appendChild(el);
return el;
})();
const stepLabel = ['Welcome', 'Hosting Service', 'Repository', 'Webhook', 'Folders'][this.step];
const isReady = this.state !== null;
const gitInstalled = !this.state || this.state.git_installed !== false;
container.innerHTML = `
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="gs-wiz-title">
<div class="header">
<div>
<span class="title" id="gs-wiz-title">Git Sync — Wizard</span>
${isReady && gitInstalled ? `<span class="step-pill">Step ${this.step} of ${this.maxStep} · ${stepLabel}</span>` : ''}
</div>
<button class="close-btn" aria-label="Close" data-close>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="body">
${!isReady ? this._renderLoading() :
!gitInstalled ? this._renderNoGit() :
this._renderStep()}
</div>
${isReady && gitInstalled ? this._renderFooter() : `
<div class="footer">
<span></span>
<div class="footer-right">
<button class="btn" data-close>Close</button>
</div>
</div>
`}
</div>
`;
// Close handlers
container.querySelectorAll('[data-close]').forEach((b) => {
b.addEventListener('click', () => this.close());
});
// Wire up step-specific handlers
if (isReady && gitInstalled) {
this._wireFooter(container);
this._wireStep(container);
}
}
_renderLoading() {
return `<p style="text-align:center; color:var(--muted-foreground);"><span class="spin"></span> Loading wizard…</p>`;
}
_renderNoGit() {
return `
<p>The <strong>Git Sync</strong> plugin requires the <code>git</code> binary to be installed and reachable on the server's PATH.</p>
<p>If <code>git</code> is missing, ask your hosting provider to install it, or set a custom <strong>Git Binary Path</strong> on the settings form below the wizard.</p>
`;
}
_renderStep() {
switch (this.step) {
case 0: return this._step0();
case 1: return this._step1();
case 2: return this._step2();
case 3: return this._step3();
case 4: return this._step4();
}
return '';
}
_step0() {
return `
<p>This wizard walks you through setting up <strong>Git Sync</strong> in four steps. When done, your site will keep itself in sync with a remote git repository.</p>
<ol>
<li>Pick the hosting service and enter your access credentials.</li>
<li>Point Git Sync at the repository and verify the connection.</li>
<li>Optionally configure a webhook so the remote can notify your site of changes.</li>
<li>Choose which <code>user/</code> folders to keep in sync.</li>
</ol>
<p>Press <strong>Next</strong> to begin.</p>
`;
}
_step1() {
const sel = this.draft.service;
const services = [
{ id: 'github', label: 'GitHub' },
{ id: 'bitbucket', label: 'Bitbucket' },
{ id: 'gitlab', label: 'GitLab' },
{ id: 'allothers', label: 'Other Git' },
];
return `
<p>Choose the git host you'll be using and enter your username and password (or an access token / app password).</p>
<div class="hosting-grid">
${services.map(s => `
<div class="host-card ${sel === s.id ? 'selected' : ''}" data-svc="${s.id}">
<span class="name">${s.label}</span>
${SERVICES[s.id].create ? `<a class="small" href="${SERVICES[s.id].create}" target="_blank" rel="noopener">create account</a>` : `<span class="small">any git service with webhooks</span>`}
</div>
`).join('')}
</div>
<div style="margin-top:1rem;">
<label class="field">
<span class="lbl">
<span>Git User</span>
<label class="inline-checkbox">
<input type="checkbox" data-no-user ${this.draft.no_user ? 'checked' : ''} />
No user (token-only auth)
</label>
</span>
<input
type="text"
data-user
value="${(this.draft.user || '').replace(/"/g, '&quot;')}"
placeholder="${this.draft.no_user ? 'username not required' : 'Username, not email'}"
${this.draft.no_user ? 'disabled' : ''}
/>
</label>
<label class="field">
<span class="lbl"><span>Git Password or Token</span></span>
<input
type="password"
data-password
value="${(this.draft.password || '').replace(/"/g, '&quot;')}"
placeholder="${this.state?.settings?.password_stored ? 'Leave blank to reuse stored password' : 'Password or access token'}"
/>
</label>
</div>
`;
}
_step2() {
const placeholder = this.draft.service && SERVICES[this.draft.service]
? `https://${SERVICES[this.draft.service].host}/your-user/your-repo.git`
: 'https://github.com/your-user/your-repo.git';
const isValid = !this.draft.repository || GIT_REGEX.test(this.draft.repository);
return `
<p>Paste the full <strong>HTTPS</strong> clone URL of your repository. Most hosts list it on the project page next to "Clone".</p>
<p style="font-size:0.8125rem;color:var(--muted-foreground);">If you're starting from scratch, create the repo on the host first and check "initialize with a README" — Git Sync needs an initial commit to clone from.</p>
<label class="field">
<span class="lbl"><span>Git Repository</span></span>
<input
type="text"
data-repo
class="${!isValid ? 'invalid' : ''}"
value="${(this.draft.repository || '').replace(/"/g, '&quot;')}"
placeholder="${placeholder}"
/>
</label>
<label class="field">
<span class="lbl"><span>Branch (master / main)</span></span>
<input
type="text"
data-branch
value="${(this.draft.branch || '').replace(/"/g, '&quot;')}"
placeholder="${this.draft.service ? SERVICES[this.draft.service].branch : 'main'}"
/>
</label>
<div class="verify-row">
<button class="btn" data-test ${this.testing ? 'disabled' : ''}>
${this.testing
? `<span class="spin"></span> Testing…`
: `Verify Authentication, Connection &amp; Branch`}
</button>
</div>
${this.testResult ? `
<div class="test-result ${this.testResult.status === 'success' ? 'success' : 'error'}">
${this.testResult.message}
</div>
` : ''}
`;
}
_step3() {
const frontendUrl = this.frontendUrl || window.location.origin;
return `
<p>A webhook lets the remote repository tell your site about pushes so changes show up immediately. Set the URL below in the repo's webhook settings on your git host.</p>
<label class="field">
<span class="lbl"><span>Webhook URL path</span></span>
<input
type="text"
data-webhook
value="${(this.draft.webhook || '').replace(/"/g, '&quot;')}"
placeholder="/_git-sync"
/>
</label>
<p style="font-size:0.8125rem;">
Full URL: <code>${frontendUrl}<span data-webhook-preview>${this.draft.webhook || '/_git-sync'}</span></code>
</p>
<label class="inline-checkbox" style="margin: 0.75rem 0; display: block;">
<input type="checkbox" data-webhook-enabled ${this.draft.webhook_enabled ? 'checked' : ''} />
Use a webhook secret (recommended; not supported by Bitbucket)
</label>
${this.draft.webhook_enabled ? `
<label class="field">
<span class="lbl"><span>Webhook Secret</span></span>
<input
type="text"
data-webhook-secret
value="${(this.draft.webhook_secret || '').replace(/"/g, '&quot;')}"
placeholder="random secret"
/>
</label>
` : ''}
`;
}
_step4() {
const folders = [
{ id: 'pages', label: 'Pages', warn: false, hint: 'Page content for the site.' },
{ id: 'themes', label: 'Themes', warn: false, hint: 'Theme files. Manual sync usually required for themes.' },
{ id: 'plugins', label: 'Plugins', warn: false, hint: 'Plugin packages.' },
{ id: 'config', label: 'Config', warn: true, hint: 'Site configuration. May contain sensitive data.' },
{ id: 'data', label: 'Data', warn: true, hint: 'Plugin-stored data. May contain sensitive data.' },
];
return `
<p>Pick which <code>user/</code> folders to keep under git control. You can change this later from the settings form.</p>
<div class="folder-grid">
${folders.map(f => `
<label class="folder-card ${this.draft.folders.includes(f.id) ? 'selected' : ''}">
<input type="checkbox" data-folder="${f.id}" ${this.draft.folders.includes(f.id) ? 'checked' : ''} />
<div>
<div style="font-weight:600;font-size:0.875rem;">${f.label}</div>
<div style="font-size:0.75rem;color:var(--muted-foreground);">${f.hint}</div>
${f.warn ? `<div class="warn">⚠ Use a private repo if syncing this folder.</div>` : ''}
</div>
</label>
`).join('')}
</div>
${this.saveError ? `<div class="save-error">${this.saveError}</div>` : ''}
`;
}
_renderFooter() {
const isLast = this.step === this.maxStep;
const canNext = this._canAdvance();
return `
<div class="footer">
<button class="btn" data-cancel>Cancel</button>
<div class="footer-right">
${this.step > 0 ? `<button class="btn" data-prev>Previous</button>` : ''}
${!isLast
? `<button class="btn btn-primary" data-next ${!canNext ? 'disabled' : ''}>Next →</button>`
: `<button class="btn btn-primary" data-save ${this.saving ? 'disabled' : ''}>
${this.saving ? `<span class="spin"></span> Saving…` : 'Save & Finish'}
</button>`}
</div>
</div>
`;
}
_canAdvance() {
switch (this.step) {
case 0: return true;
case 1: {
if (!this.draft.service) return false;
if (!this.draft.no_user && !this.draft.user) return false;
return true;
}
case 2: {
if (!this.draft.repository || !GIT_REGEX.test(this.draft.repository)) return false;
if (!this.draft.branch) return false;
return true;
}
case 3: return true;
default: return true;
}
}
_wireFooter(root) {
root.querySelector('[data-cancel]')?.addEventListener('click', () => this.close());
root.querySelector('[data-prev]')?.addEventListener('click', () => {
this.step = Math.max(0, this.step - 1);
this.testResult = null;
this._render();
});
root.querySelector('[data-next]')?.addEventListener('click', () => {
if (!this._canAdvance()) return;
this.step = Math.min(this.maxStep, this.step + 1);
this.testResult = null;
this._render();
});
root.querySelector('[data-save]')?.addEventListener('click', () => this._save());
}
_wireStep(root) {
if (this.step === 1) {
root.querySelectorAll('[data-svc]').forEach((el) => {
el.addEventListener('click', () => {
this.draft.service = el.dataset.svc;
const svc = SERVICES[this.draft.service];
if (svc && (!this.draft.branch || ['master', 'main'].includes(this.draft.branch))) {
this.draft.branch = svc.branch;
}
this._render();
});
});
root.querySelector('[data-no-user]')?.addEventListener('change', (e) => {
this.draft.no_user = e.target.checked;
if (this.draft.no_user) this.draft.user = '';
this._render();
});
root.querySelector('[data-user]')?.addEventListener('input', (e) => {
this.draft.user = e.target.value;
this._updateNextButton();
});
root.querySelector('[data-password]')?.addEventListener('input', (e) => {
this.draft.password = e.target.value;
});
}
if (this.step === 2) {
root.querySelector('[data-repo]')?.addEventListener('input', (e) => {
this.draft.repository = e.target.value;
const isValid = !this.draft.repository || GIT_REGEX.test(this.draft.repository);
e.target.classList.toggle('invalid', !isValid);
this.testResult = null;
this._updateNextButton();
});
root.querySelector('[data-branch]')?.addEventListener('input', (e) => {
this.draft.branch = e.target.value;
this.testResult = null;
this._updateNextButton();
});
root.querySelector('[data-test]')?.addEventListener('click', () => this._testConnection());
}
if (this.step === 3) {
root.querySelector('[data-webhook]')?.addEventListener('input', (e) => {
this.draft.webhook = e.target.value;
const preview = root.querySelector('[data-webhook-preview]');
if (preview) preview.textContent = e.target.value || '/_git-sync';
});
root.querySelector('[data-webhook-enabled]')?.addEventListener('change', (e) => {
this.draft.webhook_enabled = e.target.checked;
this._render();
});
root.querySelector('[data-webhook-secret]')?.addEventListener('input', (e) => {
this.draft.webhook_secret = e.target.value;
});
}
if (this.step === 4) {
root.querySelectorAll('[data-folder]').forEach((cb) => {
cb.addEventListener('change', (e) => {
const id = e.target.dataset.folder;
if (e.target.checked) {
if (!this.draft.folders.includes(id)) this.draft.folders.push(id);
} else {
this.draft.folders = this.draft.folders.filter(f => f !== id);
}
e.target.closest('.folder-card').classList.toggle('selected', e.target.checked);
});
});
}
}
_updateNextButton() {
const next = this.shadow.querySelector('[data-next]');
if (next) {
const canAdvance = this._canAdvance();
next.toggleAttribute('disabled', !canAdvance);
}
}
async _testConnection() {
if (this.testing) return;
this.testing = true;
this.testResult = null;
this._render();
try {
const result = await apiCall('POST', '/git-sync/test-connection', {
user: this.draft.user,
password: this.draft.password,
repository: this.draft.repository,
branch: this.draft.branch,
no_user: this.draft.no_user,
});
this.testResult = result;
} catch (err) {
this.testResult = { status: 'error', message: err.message || String(err) };
} finally {
this.testing = false;
this._render();
}
}
async _save() {
if (this.saving) return;
this.saving = true;
this.saveError = '';
this._render();
try {
const repository = this.draft.repository;
const payload = {
repository,
no_user: this.draft.no_user,
user: this.draft.no_user ? '' : this.draft.user,
branch: this.draft.branch,
webhook: this.draft.webhook || undefined,
webhook_enabled: this.draft.webhook_enabled,
webhook_secret: this.draft.webhook_secret || undefined,
folders: this.draft.folders,
remote: { branch: this.draft.branch },
};
// Only send password if the user actually entered one — empty
// means "keep existing" on the server side.
if (this.draft.password) {
payload.password = this.draft.password;
}
await apiCall('PATCH', '/git-sync/data', payload);
// Notify the page so the form re-fetches its data.
window.dispatchEvent(new CustomEvent('grav:plugin-data-changed', {
detail: { plugin: 'git-sync' },
}));
this.close();
// Soft-reload the plugin page so the form reflects the new state.
// The page +page.svelte $effect on `slug` only fires on slug change,
// so we trigger an in-place reload via location.reload().
if (window.location.pathname.includes('/plugin/git-sync')) {
window.location.reload();
}
} catch (err) {
this.saveError = err.message || String(err);
this.saving = false;
this._render();
}
}
}
// Single shared instance — reopening the wizard reuses it.
const wizard = new WizardModal();
// ─── Page-action listener ───────────────────────────────────────────────
window.addEventListener('grav:plugin-page-action', (e) => {
const detail = e.detail || {};
if (detail.plugin !== 'git-sync') return;
if (!detail.action || detail.action.id !== 'wizard') return;
wizard.open();
});
// ─── Custom element (never instantiated, registered for completeness) ───
class GitSyncWidget extends HTMLElement {
connectedCallback() {
// The widget is registered with showFab: false, so this should not
// run. If something does try to mount it, render a tiny pointer
// back to the wizard so the operator can still get to it.
this.innerHTML = `<button type="button">Open Wizard</button>`;
this.querySelector('button').addEventListener('click', () => wizard.open());
}
}
customElements.define(TAG, GitSyncWidget);
+1
View File
@@ -0,0 +1 @@
import './wizard';
+379
View File
@@ -0,0 +1,379 @@
import Settings from 'git-sync';
import request from 'admin/utils/request';
import toastr from 'admin/utils/toastr';
import { config } from 'grav-config';
import $ from 'jquery';
import 'whatwg-fetch';
const GIT_REGEX = /(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/;
const WIZARD = $('[data-remodal-id="wizard"]');
const RESET_LOCAL = $('[data-remodal-id="reset-local"]');
const SERVICES = { 'github': 'github.com', 'bitbucket': 'bitbucket.org', 'gitlab': 'gitlab.com', 'allothers': 'allothers.repo' };
const BRANCHES = { 'github': 'main', 'bitbucket': 'master', 'gitlab': 'master', 'allothers': 'master' };
const TEMPLATES = {
REPO_URL: 'https://{placeholder}/getgrav/grav.git'
};
const openWizard = () => {
const modal = WIZARD.remodal({ closeOnConfirm: false });
const previous = WIZARD.find('[data-gitsync-action="previous"]');
const next = WIZARD.find('[data-gitsync-action="next"]');
const save = WIZARD.find('[data-gitsync-action="save"]');
STEP = 0;
WIZARD.find(`form > [class^=step-]:not(.step-${STEP}) > .panel`).hide().removeClass('hidden');
WIZARD.find(`form > [class="step-${STEP}"] > .panel`).show();
next.removeClass('hidden');
previous.addClass('hidden');
save.addClass('hidden');
const webhook = $('[name="data[webhook]"]').val();
const webhook_secret = $('[name="data[webhook_secret]"]').val();
$('[name="gitsync[repository]"]').trigger('change');
$('[name="gitsync[webhook]"]').val(webhook);
$('[name="gitsync[webhook_secret]"]').val(webhook_secret);
$('.gitsync-webhook').text(webhook);
modal.open();
};
const disableButton = (next) => {
next
.attr('disabled', 'disabled')
.addClass('hint--top');
};
const enableButton = (next) => {
next
.attr('disabled', null)
.removeClass('hint--top');
};
let STEP = 0;
let STEPS = 0;
let SERVICE = null;
$(document).on('closed', WIZARD, function(e) {
STEP = 0;
});
$(document).on('click', '[data-gitsync-useraction]', (event) => {
event.preventDefault();
const target = $(event.target).closest('[data-gitsync-useraction]');
const action = target.data('gitsyncUseraction');
const URI = `${config.current_url}.json`;
switch (action) {
case 'wizard':
openWizard();
break;
case 'sync':
const relativeURI = target.data('gitsync-uri');
target.find('i').removeClass('fa-cloud fa-git').addClass('fa-circle-o-notch fa-spin');
request(relativeURI || URI, {
method: 'post',
body: { task: 'synchronize' }
}, () => {
target.find('i').removeClass('fa-circle-o-notch fa-spin').addClass(relativeURI ? 'fa-git' : 'fa-cloud');
});
break;
case 'reset':
const modal = RESET_LOCAL.remodal({ closeOnConfirm: false });
modal.open();
if (!RESET_LOCAL.data('_reset_event_set_')) {
RESET_LOCAL.find('[data-gitsync-action="reset-local"]').one('click', () => {
modal.close();
RESET_LOCAL.data('_reset_event_set_', true);
target.find('i').removeClass('fa-history').addClass('fa-circle-o-notch fa-spin');
request(URI, {
method: 'post',
body: { task: 'resetlocal' }
}, () => {
RESET_LOCAL.data('_reset_event_set_', false);
target.find('i').removeClass('fa-circle-o-notch fa-spin').addClass('fa-history');
});
});
}
break;
}
});
$(document).on('click', '[data-gitsync-action]', (event) => {
event.preventDefault();
const target = $(event.target).closest('[data-gitsync-action]');
const previous = WIZARD.find('[data-gitsync-action="previous"]');
const next = WIZARD.find('[data-gitsync-action="next"]');
const save = WIZARD.find('[data-gitsync-action="save"]');
const action = target.data('gitsyncAction');
const user = $('[name="gitsync[repo_user]"]').val();
const noUser = $('[name="gitsync[no_user]"]').is(':checked');
const password = $('[name="gitsync[repo_password]"]').val();
const repository = $('[name="gitsync[repo_url]"]').val();
const branch = $('[name="gitsync[branch]"]').val();
const webhook = $('[name="gitsync[webhook]"]').val();
const webhook_enabled = $('[name="gitsync[webhook_enabled]"]').is(':checked');
const webhook_secret = $('[name="gitsync[webhook_secret]"]').val();
if (target.attr('disabled')) {
return;
}
let error = [];
if (!user && !noUser) {
error.push('Username is missing.');
}
/*
if (!password) {
error.push('Password is missing.');
}
*/
if (!repository) {
error.push('Repository is missing.');
}
if (['save', 'test'].includes(action)) {
target.find('.fa').removeClass(action === 'test' ? 'fa-plug' : 'fa-check').addClass('fa-spin fa-circle-o-notch');
if (error.length) {
toastr.error(error.join('<br />'));
target.find('.fa').removeClass('fa-spin fa-circle-o-notch').addClass(action === 'test' ? 'fa-plug' : 'fa-check');
return false;
}
}
if (action === 'save') {
const folders = $('[name="gitsync[folders]"]:checked').map((i, item) => item.value);
$('[name="data[repository]"]').val(repository);
$('[name="data[no_user]"]').val(noUser ? '1' : '0');
$('[name="data[user]"]').val(user);
$('[name="data[password]"]').val(password);
$('[name="data[branch]"]').val(branch);
$('[name="data[remote][branch]"]').val(branch);
$('[name="data[webhook]"]').val(webhook);
$(`[name="data[webhook_enabled]"][value="${webhook_enabled ? 1 : 0}"]`).prop('checked', true);
$('[name="data[webhook_secret]"]').val(webhook_secret);
const dataFolders = $('[name="data[folders][]"]');
if (dataFolders && dataFolders[0] && dataFolders[0].selectize) {
dataFolders[0].selectize.setValue(folders.toArray());
}
$('[name="task"][value="save"]').trigger('click');
return false;
}
if (action === 'test') {
const URI = `${config.current_url}.json`;
const test = global.btoa(JSON.stringify({
user: noUser ? '' : user,
password,
repository,
branch
}));
request(URI, {
method: 'post',
body: { test, task: 'testConnection' }
});
target.find('.fa').removeClass('fa-spin fa-circle-o-notch').addClass('fa-plug');
return false;
}
WIZARD.find(`.step-${STEP} > .panel`).slideUp();
STEP += action === 'next' ? +1 : -1;
WIZARD.find(`.step-${STEP} > .panel`).slideDown();
save.addClass('hidden');
if (action === 'next') {
previous.removeClass('hidden');
}
if (STEP <= 0) {
previous.addClass('hidden');
enableButton(next);
}
if (STEP > 0) {
next.removeClass('hidden');
}
if (STEP === 1) {
const selectedRepo = $('[name="gitsync[repository]"]:checked');
if (!selectedRepo.length) {
disableButton(next);
} else {
enableButton(next);
}
}
if (STEP === 2) {
const repoURL = $('[name="gitsync[repo_url]"]').val();
if (!repoURL.length || !branch) {
disableButton(next);
} else {
enableButton(next);
}
}
if (STEP === STEPS) {
next.addClass('hidden');
previous.removeClass('hidden');
save.removeClass('hidden');
}
});
$(document).on('input', '[name="gitsync[no_user]"]', (event) => {
const target = $(event.currentTarget);
const user = $('[name="gitsync[repo_user]"]');
if (target.is(':checked')) {
user
.val('')
.prop('disabled', 'disabled')
.attr('placeholder', '<username not required>');
} else {
user
.prop('disabled', null)
.attr('placeholder', 'Username, not email');
}
});
$(document).on('change', '[name="gitsync[repository]"]', () => {
enableButton(WIZARD.find('[data-gitsync-action="next"]'));
});
$(document).on('input', '[name="gitsync[repo_url]"]', (event) => {
const target = $(event.currentTarget);
const value = target.val();
const isGitURL = GIT_REGEX.test(value);
const next = WIZARD.find('[data-gitsync-action="next"]');
target.removeClass('invalid');
if (!isGitURL) {
target.addClass('invalid');
}
if (isGitURL && value.length) {
enableButton(next);
} else {
disableButton(next);
}
});
$(document).on('keyup', '[data-gitsync-uribase] [name="gitsync[webhook]"]', (event) => {
const target = $(event.currentTarget);
const value = target.val();
$('.gitsync-webhook').text(value);
});
$(document).on('keyup', '[data-gitsync-uribase] [name="gitsync[webhook_secret]"]', (event) => {
$('[data-gitsync-uribase] [name="gitsync[webhook_enabled]"]').trigger('change');
});
$(document).on('change', '[data-gitsync-uribase] [name="gitsync[webhook_enabled]"]', (event) => {
const target = $(event.currentTarget);
const checked = target.is(':checked');
const secret = $('[name="gitsync[webhook_secret]"]').val();
target.closest('.webhook-secret-wrapper').find('label:last-child')[checked ? 'removeClass' : 'addClass']('hidden');
$('.gitsync-webhook-secret').html(!checked || !secret.length ? '<em>leave empty</em>' : `<code>${secret}</code>`);
});
$(document).on('change', '[name="gitsync[repository]"]', (event) => {
const target = $(event.target);
if (!target.is(':checked')) {
return;
}
SERVICE = target.val();
Object.keys(SERVICES).forEach((service) => {
WIZARD.find(`.hidden-step-${service}`)[service === SERVICE ? 'removeClass' : 'addClass']('hidden');
if (service === SERVICE) {
WIZARD.find('.webhook-secret-wrapper')[service === 'bitbucket' ? 'addClass' : 'removeClass']('hidden');
WIZARD
.find('input[name="gitsync[repo_url]"][placeholder]')
.attr('placeholder', TEMPLATES.REPO_URL.replace(/\{placeholder\}/, SERVICES[service]))
.end()
.find('input[name="gitsync[branch]"]')
.attr('placeholder', BRANCHES[service])
.val(BRANCHES[service]);
}
});
});
$(document).on('click', '[data-access-tokens-details]', (event) => {
event.preventDefault();
const button = $(event.currentTarget);
const panel = button.closest('.access-tokens').find('.access-tokens-details');
panel.slideToggle(250, () => {
const isVisible = panel.is(':visible');
const icon = button.find('.fa');
icon.removeClass('fa-chevron-down fa-chevron-up').addClass(`fa-chevron-${isVisible ? 'up' : 'down'}`);
});
});
const showNotices = (element) => {
const target = $(element);
const selection = target.val().replace(/\//g, '-');
const column = target.closest('.columns').find('.column:last');
column.find('[class*="description-"]').addClass('hidden');
column.find(`.description-${selection}`).removeClass('hidden').hide().fadeIn({
duration: 250
});
};
$(document).on('input', '[data-remodal-id="wizard"] .step-4 input[type="checkbox"]', (event) => {
const target = $(event.currentTarget);
if (!target.is(':checked')) {
return;
}
showNotices(target);
});
$(document).on('mouseenter', '[data-remodal-id="wizard"] .step-4 .info-desc', (event) => {
const target = $(event.currentTarget).siblings('input[type="checkbox"]');
showNotices(target);
});
$(document).on('mouseleave', '[data-remodal-id="wizard"] .step-4 label', (event) => {
const target = $(event.currentTarget);
const container = target.closest('.columns');
const column = container.find('.column:last-child');
column.find('[class*="description-"]').addClass('hidden');
});
$(document).on('mouseleave', '[data-remodal-id="wizard"] .columns .column:first-child', (event) => {
const target = $(event.currentTarget);
const column = target.siblings('.column');
column.find('[class*="description-"]').addClass('hidden');
});
$(document).ready(() => {
STEPS = WIZARD.find('[class^="step-"]').length - 1;
WIZARD.wrapInner('<form></form>');
RESET_LOCAL.wrapInner('<form></form>');
if (WIZARD.length && (Settings.first_time || !Settings.git_installed)) {
openWizard();
}
});
export default Settings;
+301
View File
@@ -0,0 +1,301 @@
name: Git Sync
type: plugin
slug: git-sync
version: 3.4.3
description: Allows to synchronize portions of Grav with Git Repositories (GitHub, BitBucket, GitLab)
icon: git
author:
name: Trilby Media, LLC
email: hello@trilby.media
url: http://trilby.media
homepage: http://trilby.media
keywords: grav, plugin, git, sync, github, bitbucket, gitlab
issues: https://github.com/trilbymedia/grav-plugin-git-sync/issues
docs: https://github.com/trilbymedia/grav-plugin-git-sync
license: MIT
compatibility:
grav: ["1.7", "2.0"]
dependencies:
- { name: grav, version: ">=1.7.0" }
- { name: form, version: ">=3.0.0" }
form:
validation: strict
fields:
Basic:
type: section
title: Basic Settings
underline: true
enabled:
type: toggle
label: Plugin Status
highlight: 1
default: 0
options:
1: Enabled
0: Disabled
validate:
type: bool
sync.direction:
type: select
label: Sync direction
size: medium
default: both
options:
pull: One-way (Pull only)
both: Both-ways (Pull and Push)
folders:
type: select
multiple: true
label: Folders to Sync
classes: fancy
description: Removing folders after they have been synced may cause undesired results.
default:
- pages
options:
- pages
- themes
- plugins
- config
- data
selectize:
create: true
validate:
type: commalist
Sync:
type: section
title: Automatic Synchronization Settings
underline: true
SyncNotice:
type: hidden
markdown: true
text: |
! To improve the speed of saving pages you can disable automatic sync. Then, changes to a page will not be sent to the remote repository on every save. To sync your changes to the repository tap the GitSync button (<i class="fa fa-git"></i>) in the top left of the Administration Panel, or use the below Scheduler option to add the GitSync Syncronization Job to the Scheduler (<strong>Grav 1.6 required</strong>).
sync.on_save:
type: toggle
label: Sync on Page Save
help: Sync with the remote directory when a page is saved through the admin
default: 1
highlight: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
sync.on_delete:
type: toggle
label: Sync on Page Delete
help: Sync with the remote directory when a page is deleted through the admin
default: 1
highlight: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
sync.on_media:
type: toggle
label: Sync on Media Changes
help: Sync with the remote directory when a media is uploaded or deleted through the admin immediately (instead of only syncing when the page is saved)
default: 1
highlight: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
sync.cron_enable:
type: toggle
label: Add Sync to Scheduler
help: Add GitSync Job to the Scheduler so it can automatically perform synchronization at the given time
default: 0
highlight: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
sync.cron_at:
type: cron
label: Run Sync at
help: When should the Scheduler run the automatic GitSync synchronization job
default: "0 12,23 * * *"
Repo:
type: section
title: Git Repository Settings
underline: true
local_repository:
type: hidden
multiple: false
size: medium
label: Local Repository Path
repository:
type: text
label: Git Repository
placeholder: https://github.com/user/repository.git
no_user:
type: toggle
label: User not required
highlight: 0
default: 0
options:
1: Enabled
0: Disabled
description: With this setting enabled, the user can be left blank and it will be ignored from the authentication. Useful when only needing access tokens `token@host` rather than `user:password@host`
user:
type: text
label: Git User
placeholder: Username, not email
autocomplete: off
password:
type: enc-password
label: Git Password or Token
placeholder: Your Git Password or Token
description: Enter your password or token to encrypt and securely store it, then save the settings. It will not show up here for security reasons.
autocomplete: off
webhook:
type: text
label: Repository Web Hook URL
placeholder: /_git-sync
data-default@: '\Grav\Plugin\GitSyncPlugin::generateRandomWebhook'
webhook_enabled:
type: toggle
label: Web Hook Secret
highlight: 1
default: 0
options:
1: Enabled
0: Disabled
description: With this setting enabled, only authorized webhook calls will be able to trigger a synchronization (recommended)
webhook_secret:
type: text
label: Repository Web Hook Secret
placeholder: Your Web Hook Secret
data-default@: '\Grav\Plugin\GitSyncPlugin::generateWebhookSecret'
description: You can either use this randomly generated string or enter your own secret. <br /> **Bitbucket** does not yet support Webhook Secrets.
markdown: true
Advanced:
type: section
title: Advanced Git Settings
underline: true
branch:
type: text
default: master
label: Local Branch
placeholder: master
remote.name:
type: text
default: origin
label: Remote Name
placeholder: origin
remote.branch:
type: text
default: master
label: Remote Branch
placeholder: master
git.author:
type: select
default: gituser
label: Commits Author
options:
gituser: Use Git User Name
gitsync: Use GitSync Committer Name
gravuser: Use Grav User Name
gravfull: Use Grav User Full Name
git.message:
type: text
default: (Grav GitSync) Automatic Commit
label: Commit message
placeholder: (Grav GitSync) Automatic Commit
help: You can use {{pageTitle}} or {{pageRoute}} in your message as placeholders for the title or route of the page being saved
git.name:
type: text
default: GitSync
label: Committer Name
placeholder: GitSync
git.email:
type: text
default: git-sync@trilby.media
label: Committer Email
placeholder: git-sync@trilby.media
git.bin:
type: text
default: git
label: Git Binary Path
help: If the default `git` command doesn't work on your machine or if you want to specify a custom path, do it in here
placeholder: /usr/bin/git
git.ignore:
type: textarea
label: Git Ignore
help: Add custom git ignore rules to go along with GitSync. One per line
rows: 6
placeholder: |
node_modules
/.idea
git.private_key:
type: text
label: Private SSH Key
placeholder: ~/.ssh/id_rsa
markdown: true
description: >
In order to be able to sparse-checkout and push changes, it is expected you have an ssh-key configured for accessing the repository. This is usually found under `~/.ssh` and it needs to be configured for the same user that runs the web-server. <br />
<br />
Point it to the secret (not the public) and make also sure you have strict permissions to the file. (`-rw-------`). <br />
<br />
Example: `private_key: /home/www-data/.ssh/id_rsa`<br />
<br />
**IMPORTANT**: SSH keys with passphrase are __NOT__ supported. To remove a passphrase, run the `ssh-keygen -p` command and when asked for the new passphrase leave blank and return.
logging:
type: toggle
default: 0
label: Log Git Commands
help: Logs git commands. Useful to debug and troubleshoot git execution
highlight: 0
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
Actions:
type: section
title: Actions
underline: true
_wizard:
type: git-wizard
label: Text Variable
help: Text to add to the top of a page
@@ -0,0 +1,177 @@
<?php
namespace Grav\Plugin\GitSync;
use Grav\Common\Grav;
use Grav\Common\Plugin;
use Grav\Common\Utils;
use Grav\Plugin\Admin\AdminBaseController;
class AdminController extends AdminBaseController
{
protected $action;
protected $target;
protected $active;
protected $plugin;
protected $task_prefix = 'task';
/** @var GitSync */
public $git;
/**
* @param Plugin $plugin
*/
public function __construct(Plugin $plugin)
{
$this->grav = Grav::instance();
$this->active = false;
$uri = $this->grav['uri'];
$this->plugin = $plugin;
$post = !empty($_POST) ? $_POST : [];
$this->post = $this->getPost($post);
// Ensure the controller should be running
if (Utils::isAdminPlugin()) {
$routeDetails = $this->grav['admin']->getRouteDetails();
$target = array_pop($routeDetails);
$this->git = new GitSync();
// return null if this is not running
if ($target !== $plugin->name) {
return;
}
$this->action = !empty($this->post['action']) ? $this->post['action'] : $uri->param('action');
$this->target = $target;
$this->active = true;
$this->admin = Grav::instance()['admin'];
$task = !empty($post['task']) ? $post['task'] : $uri->param('task');
if ($task && ($this->target === $plugin->name || $uri->route() === '/lessons')) {
$this->task = $task;
$this->active = true;
}
}
}
public function taskTestConnection()
{
$post = $this->post;
$test = base64_decode($post['test']) ?: null;
$data = $test ? json_decode($test, false) : new \stdClass();
try {
$testResult = Helper::testRepository($data->user, $data->password, $data->repository, $data->branch);
if (!empty($testResult)) {
echo json_encode([
'status' => 'success',
'message' => 'The connection to the repository has been successful.'
]);
} else {
echo json_encode([
'status' => 'error',
'message' => 'Branch "' . $data->branch .'" not found in the repository.'
]);
}
} catch (\Exception $e) {
$invalid = str_replace($data->password, '{password}', $e->getMessage());
echo json_encode([
'status' => 'error',
'message' => $invalid
]);
}
exit;
}
public function taskSynchronize()
{
try {
$this->plugin->synchronize();
echo json_encode([
'status' => 'success',
'message' => 'GitSync has successfully synchronized with the repository.'
]);
} catch (\Exception $e) {
$invalid = str_replace($this->git->getConfig('password', null), '{password}', $e->getMessage());
echo json_encode([
'status' => 'error',
'message' => $invalid
]);
}
exit;
}
public function taskResetLocal()
{
try {
$this->plugin->reset();
echo json_encode([
'status' => 'success',
'message' => 'GitSync has successfully reset your local changes and synchronized with the repository.'
]);
} catch (\Exception $e) {
$invalid = str_replace($this->git->getConfig('password', null), '{password}', $e->getMessage());
echo json_encode([
'status' => 'error',
'message' => $invalid
]);
}
exit;
}
/**
* Performs a task or action on a post or target.
*
* @return bool
*/
public function execute()
{
$params = [];
// Handle Task & Action
if ($this->post && $this->task) {
// validate nonce
if (!$this->validateNonce()) {
return false;
}
$method = $this->task_prefix . ucfirst($this->task);
} elseif ($this->target) {
if (!$this->action) {
return false;
}
$method = strtolower($this->action) . ucfirst($this->target);
} else {
return false;
}
if (!method_exists($this, $method)) {
return false;
}
$success = $this->{$method}(...$params);
// Grab redirect parameter.
$redirect = $this->post['_redirect'] ?? null;
unset($this->post['_redirect']);
// Redirect if requested.
if ($redirect) {
$this->setRedirect($redirect);
}
return $success;
}
/**
* @return bool
*/
public function isActive()
{
return (bool) $this->active;
}
}
@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\GitSync\Api;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Plugins;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\GitSync\GitSync;
use Grav\Plugin\GitSync\Helper;
use Grav\Plugin\GitSyncPlugin;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Admin-Next API controller for git-sync.
*
* Endpoints back the blueprint-mode plugin settings page plus the wizard
* modal hosted by the auto-loaded floating widget script. Settings are
* persisted to config://plugins/git-sync.yaml — the same file admin-classic
* reads/writes — so the two admins stay interchangeable.
*/
class GitSyncApiController extends AbstractApiController
{
private function requireGitSyncPermission(ServerRequestInterface $request, string $level): void
{
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
if (!$this->hasPermission($user, 'api.access')) {
throw new ForbiddenException('API access is not enabled for this user.');
}
$required = $level === 'write'
? ['api.git-sync', 'api.git-sync.write', 'api.git-sync.admin']
: ['api.git-sync', 'api.git-sync.read', 'api.git-sync.write', 'api.git-sync.admin'];
foreach ($required as $perm) {
if ($this->hasPermission($user, $perm)) {
return;
}
}
throw new ForbiddenException("Missing required Git Sync '{$level}' permission");
}
/**
* GET /git-sync/data — current settings for the plugin form.
*
* The raw encrypted password never leaves the server — we only signal
* whether one is stored, so the enc-password field can show its
* "securely stored" placeholder.
*/
public function data(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'read');
$cfg = (array) $this->config->get('plugins.git-sync', []);
$sync = (array) ($cfg['sync'] ?? []);
$remote = (array) ($cfg['remote'] ?? []);
$git = (array) ($cfg['git'] ?? []);
return ApiResponse::create([
'enabled' => (bool) ($cfg['enabled'] ?? false),
'folders' => array_values((array) ($cfg['folders'] ?? ['pages'])),
'local_repository' => (string) ($cfg['local_repository'] ?? ''),
'repository' => (string) ($cfg['repository'] ?? ''),
'no_user' => (bool) ($cfg['no_user'] ?? false),
'user' => (string) ($cfg['user'] ?? ''),
// Form binds an empty string by default; server keeps the existing
// password unless the user types a new one. Storage state is
// exposed on the resolved blueprint (see onApiBlueprintResolved)
// so the enc-password component can render the right placeholder.
'password' => '',
'webhook' => (string) ($cfg['webhook'] ?? ''),
'webhook_enabled' => (bool) ($cfg['webhook_enabled'] ?? false),
'webhook_secret' => (string) ($cfg['webhook_secret'] ?? ''),
'branch' => (string) ($cfg['branch'] ?? 'master'),
'logging' => (bool) ($cfg['logging'] ?? false),
'sync' => [
'direction' => (string) ($sync['direction'] ?? 'both'),
'on_save' => (bool) ($sync['on_save'] ?? true),
'on_delete' => (bool) ($sync['on_delete'] ?? true),
'on_media' => (bool) ($sync['on_media'] ?? true),
'cron_enable' => (bool) ($sync['cron_enable'] ?? false),
'cron_at' => (string) ($sync['cron_at'] ?? '0 12,23 * * *'),
],
'remote' => [
'name' => (string) ($remote['name'] ?? 'origin'),
'branch' => (string) ($remote['branch'] ?? 'master'),
],
'git' => [
'author' => (string) ($git['author'] ?? 'gituser'),
'message' => (string) ($git['message'] ?? '(Grav GitSync) Automatic Commit'),
'name' => (string) ($git['name'] ?? 'GitSync'),
'email' => (string) ($git['email'] ?? 'git-sync@trilby.media'),
'bin' => (string) ($git['bin'] ?? 'git'),
'ignore' => (string) ($git['ignore'] ?? ''),
'private_key' => (string) ($git['private_key'] ?? ''),
],
]);
}
/**
* PATCH /git-sync/data — persist plugin settings.
*
* Mirrors the admin-classic onAdminSave password handling: if the form
* sends an empty password we keep whatever is currently stored (and
* encrypt it if it was somehow saved in plaintext); a non-empty value
* gets encrypted before write.
*/
public function save(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
$body = $this->getRequestBody($request);
$existing = (array) $this->config->get('plugins.git-sync', []);
$merged = $existing;
// Top-level scalars / lists
foreach (['enabled', 'folders', 'local_repository', 'repository', 'no_user',
'user', 'webhook', 'webhook_enabled', 'webhook_secret', 'branch', 'logging'] as $key) {
if (array_key_exists($key, $body)) {
$merged[$key] = $body[$key];
}
}
// Password — empty means "keep existing", non-empty means "encrypt & replace"
$newPassword = $body['password'] ?? null;
if ($newPassword === null || $newPassword === '') {
$current = (string) ($existing['password'] ?? '');
if ($current !== '' && !str_starts_with($current, 'gitsync-')) {
$merged['password'] = Helper::encrypt($current);
} else {
$merged['password'] = $current;
}
} else {
$merged['password'] = Helper::encrypt((string) $newPassword);
}
// Nested: sync / remote / git
foreach (['sync', 'remote', 'git'] as $section) {
if (isset($body[$section]) && is_array($body[$section])) {
$merged[$section] = array_merge((array) ($merged[$section] ?? []), $body[$section]);
}
}
// Auto-generate webhook / webhook_secret if blank, matching admin-classic's data-default@
if (empty($merged['webhook'])) {
$merged['webhook'] = GitSyncPlugin::generateRandomWebhook();
}
if (empty($merged['webhook_secret'])) {
$merged['webhook_secret'] = GitSyncPlugin::generateWebhookSecret();
}
$this->writePluginConfig($merged);
// Mirror admin-classic onAdminAfterSave: initialize repo / set remote
// when the plugin page form is saved with a configured repository.
if (Helper::isGitInstalled() && Helper::isGitSyncConfigured()) {
try {
$git = new GitSync();
$git->setConfig($merged);
$git->initializeRepository();
$git->setUser();
$git->addRemote();
} catch (\Throwable $e) {
// Don't fail the save — surface as a warning in the response.
return ApiResponse::create([
'message' => 'Settings saved, but repository setup ran into an issue: '
. Helper::preventReadablePassword($e->getMessage(), $merged['password'] ?? ''),
]);
}
}
return ApiResponse::create([
'message' => 'Git Sync settings saved.',
]);
}
/**
* POST /git-sync/sync — synchronize with the remote repository.
*/
public function sync(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
if (!Helper::isGitInstalled()) {
throw new ValidationException('Git is not installed or not on the configured PATH.');
}
if (!Helper::isGitSyncReady()) {
throw new ValidationException('Git Sync is not configured yet — run the Wizard first.');
}
@set_time_limit(0);
// Release the PHP session lock so the rest of admin-next stays
// responsive while the network-bound git pull/push finishes.
// Without this, every concurrent request from the same browser
// blocks behind this one and the UI feels frozen.
@session_write_close();
try {
$plugin = $this->getGitSyncPlugin();
$plugin->synchronize();
} catch (\Throwable $e) {
$password = (string) ($this->config->get('plugins.git-sync.password') ?? '');
throw new ValidationException(
Helper::preventReadablePassword($e->getMessage(), $password)
);
}
return ApiResponse::create([
'message' => 'Git Sync has successfully synchronized with the repository.',
]);
}
/**
* POST /git-sync/reset — discard local changes (git reset --hard HEAD).
*/
public function reset(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
if (!Helper::isGitInstalled()) {
throw new ValidationException('Git is not installed or not on the configured PATH.');
}
if (!Helper::isGitSyncReady()) {
throw new ValidationException('Git Sync is not configured yet — run the Wizard first.');
}
@set_time_limit(0);
@session_write_close();
try {
$plugin = $this->getGitSyncPlugin();
$plugin->reset();
} catch (\Throwable $e) {
$password = (string) ($this->config->get('plugins.git-sync.password') ?? '');
throw new ValidationException(
Helper::preventReadablePassword($e->getMessage(), $password)
);
}
return ApiResponse::create([
'message' => 'Git Sync has reset your local copy and re-synchronized with the repository.',
]);
}
/**
* POST /git-sync/test-connection — wizard "Verify Authentication, Connection and Branch".
*
* Body: { user, password, repository, branch, no_user }
*
* Mirrors AdminController::taskTestConnection. The credentials are NOT
* persisted — they exist only for the duration of this ls-remote probe.
*/
public function testConnection(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
if (!Helper::isGitInstalled()) {
throw new ValidationException('Git is not installed or not on the configured PATH.');
}
$body = $this->getRequestBody($request);
$user = (string) ($body['user'] ?? '');
$password = (string) ($body['password'] ?? '');
$repository = (string) ($body['repository'] ?? '');
$branch = (string) ($body['branch'] ?? '');
$noUser = (bool) ($body['no_user'] ?? false);
if ($repository === '') {
throw new ValidationException('Repository URL is required.');
}
if ($branch === '') {
throw new ValidationException('Branch is required.');
}
if ($noUser) {
$user = '';
}
try {
$result = Helper::testRepository($user, $password, $repository, $branch);
} catch (\Throwable $e) {
$message = str_replace($password, '{password}', $e->getMessage());
return ApiResponse::create([
'status' => 'error',
'message' => $message,
]);
}
if (empty($result)) {
return ApiResponse::create([
'status' => 'error',
'message' => "Branch \"{$branch}\" not found in the repository.",
]);
}
return ApiResponse::create([
'status' => 'success',
'message' => 'Connection to the repository was successful.',
]);
}
/**
* GET /git-sync/wizard/state — pre-flight + current settings for the wizard.
*
* The wizard reuses the saved repo / branch / webhook to pre-fill its
* inputs the second time around (admin-classic does the same via Twig).
*/
public function wizardState(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'read');
$cfg = (array) $this->config->get('plugins.git-sync', []);
$password = (string) ($cfg['password'] ?? '');
// Compute the public site URL the way Twig admin-classic does it
// (`uri.base ~ uri.rootUrl`). The browser-side `window.location.origin`
// alone misses Grav installs that live in a sub-folder, leaving the
// wizard's webhook URL preview wrong. Trailing slash trimmed so the
// client can append the webhook path cleanly.
$uri = $this->grav['uri'];
$frontendUrl = rtrim($uri->base() . $uri->rootUrl(), '/');
return ApiResponse::create([
'git_installed' => (bool) Helper::isGitInstalled(),
'git_initialized' => (bool) Helper::isGitInitialized(),
'configured' => (bool) Helper::isGitSyncConfigured(),
'frontend_url' => $frontendUrl,
'settings' => [
'repository' => (string) ($cfg['repository'] ?? ''),
'no_user' => (bool) ($cfg['no_user'] ?? false),
'user' => (string) ($cfg['user'] ?? ''),
'password_stored' => $password !== '',
'branch' => (string) ($cfg['branch'] ?? ''),
'webhook' => (string) ($cfg['webhook'] ?? GitSyncPlugin::generateRandomWebhook()),
'webhook_enabled' => (bool) ($cfg['webhook_enabled'] ?? false),
'webhook_secret' => (string) ($cfg['webhook_secret'] ?? GitSyncPlugin::generateWebhookSecret()),
'folders' => array_values((array) ($cfg['folders'] ?? ['pages'])),
],
]);
}
/**
* Resolve the live GitSyncPlugin instance.
*
* `$grav['plugins']->get('git-sync')` returns a Data wrapper around the
* blueprint, not the plugin instance — this fetches the actual plugin
* via Plugins::getPlugin(), which is what we need to call synchronize()
* and reset().
*/
private function getGitSyncPlugin(): GitSyncPlugin
{
$plugin = Plugins::getPlugin('git-sync');
if (!$plugin instanceof GitSyncPlugin) {
throw new ValidationException('Git Sync plugin is not loaded.');
}
return $plugin;
}
private function writePluginConfig(array $data): void
{
$locator = $this->grav['locator'];
$pluginsDir = $locator->findResource('config://plugins', true, true);
if (!$pluginsDir) {
throw new ValidationException('Could not resolve config://plugins directory.');
}
if (!is_dir($pluginsDir)) {
@mkdir($pluginsDir, 0775, true);
}
$file = CompiledYamlFile::instance($pluginsDir . '/git-sync.yaml');
$file->save($data);
$file->free();
$this->config->set('plugins.git-sync', $data);
$cache = $this->grav['cache'] ?? null;
if ($cache && method_exists($cache, 'clearCache')) {
$cache->clearCache('standard');
}
}
}
+551
View File
@@ -0,0 +1,551 @@
<?php
namespace Grav\Plugin\GitSync;
use Grav\Common\Grav;
use Grav\Common\Plugin;
use Grav\Common\Utils;
use http\Exception\RuntimeException;
use RocketTheme\Toolbox\File\File;
use SebastianBergmann\Git\Git;
class GitSync extends Git
{
/** @var static */
static public $instance;
/** @var Grav */
protected $grav;
/** @var Plugin */
protected $plugin;
/** @var array */
protected $config;
/** @var string */
protected $repositoryPath;
/** @var string|null */
private $user;
/** @var string|null */
private $password;
public function __construct()
{
$this->grav = Grav::instance();
$this->config = $this->grav['config']->get('plugins.git-sync') ?? [];
$this->repositoryPath = isset($this->config['local_repository']) && $this->config['local_repository'] ? $this->config['local_repository'] : USER_DIR;
parent::__construct($this->repositoryPath);
static::$instance = $this;
$this->user = isset($this->config['no_user']) && $this->config['no_user'] ? '' : ($this->config['user'] ?? null);
$this->password = $this->config['password'] ?? null;
unset($this->config['user'], $this->config['password']);
}
/**
* @return static
*/
public static function instance()
{
if (null === static::$instance) {
static::$instance = new static;
}
return static::$instance;
}
/**
* @return string|null
*/
public function getUser()
{
return $this->user;
}
/**
* @return string|null
*/
public function getPassword()
{
return $this->password;
}
/**
* @param array $config
*/
public function setConfig($config)
{
$this->config = $config ?? [];
$this->user = $this->config['user'] ?? null;
$this->password = $this->config['password'] ?? null;
}
/**
* @return array
*/
public function getRuntimeInformation()
{
$result = [
'repositoryPath' => $this->repositoryPath,
'username' => $this->user,
'password' => $this->password
];
foreach ($this->config as $key => $item) {
if (is_array($item)) {
$count = count($item);
$arr = $item;
if ($count === 0) {// empty array, could still be associative
$arr = '[]';
} else if (isset($item[0])) {// fast check for plain array with numeric keys
$arr = '[\'' . implode('\', \'', $item) . '\']';
}
$result[$key] = $arr;
} else {
$result[$key] = $item;
}
}
return $result;
}
/**
* @param string $url
* @return string[]
*/
public function testRepository($url, $branch)
{
if (!preg_match(Helper::GIT_REGEX, $url)) {
throw new \RuntimeException("Git Repository value does not match the supported format.");
}
$branch = $branch ? '"' . $branch . '"' : '';
return $this->execute("ls-remote \"{$url}\" {$branch}");
}
/**
* @return bool
*/
public function initializeRepository()
{
if (!Helper::isGitInitialized()) {
$branch = $this->getRemote('branch', null);
$local_branch = $this->getConfig('branch', $branch);
$this->execute('init');
$this->execute('checkout -b ' . $local_branch, true);
}
$this->enableSparseCheckout();
return true;
}
/**
* @param string|null $name
* @param string|null $email
* @return bool
*/
public function setUser($name = null, $email = null)
{
$gitConfig = $this->getConfig('git', []) ?? [];
// Fall back to defaults when the config value is missing OR an empty
// string — `??` alone leaves a blank name/email in place, which makes
// git reject the commit with "fatal: empty ident name ... not allowed".
$name = $name ?: (($gitConfig['name'] ?? '') ?: 'GitSync');
$email = $email ?: (($gitConfig['email'] ?? '') ?: 'git-sync@trilby.media');
$privateKey = $this->getGitConfig('private_key', null);
$this->execute("config user.name \"{$name}\"");
$this->execute("config user.email \"{$email}\"");
if ($privateKey) {
$this->execute('config core.sshCommand "ssh -i ' . $privateKey . ' -F /dev/null"');
} else {
$this->execute('config --unset core.sshCommand');
}
return true;
}
/**
* @param string|null $name
* @return bool
*/
public function hasRemote($name = null)
{
$name = $this->getRemote('name', $name);
try {
/** @var string $version */
$version = Helper::isGitInstalled(true);
// remote get-url 'name' supported from 2.7.0 and above
if (version_compare($version, '2.7.0', '>=')) {
$command = "remote get-url \"{$name}\"";
} else {
$command = "config --get remote.{$name}.url";
}
$this->execute($command);
} catch (\Exception $e) {
return false;
}
return true;
}
public function enableSparseCheckout()
{
$folders = $this->config['folders'] ?? ['pages'];
$this->execute('config core.sparsecheckout true');
$sparse = [];
foreach ($folders as $folder) {
$sparse[] = $folder . '/';
$sparse[] = $folder . '/*';
}
$file = File::instance(rtrim($this->repositoryPath, '/') . '/.git/info/sparse-checkout');
$file->save(implode("\r\n", $sparse));
$file->free();
$ignore = ['/*'];
foreach ($folders as $folder) {
$folder = rtrim($folder,'/');
$nested = substr_count($folder, '/');
if ($nested) {
$subfolders = explode('/', $folder);
$nested_tracking = '';
foreach ($subfolders as $index => $subfolder) {
$last = $index === (count($subfolders) - 1);
$nested_tracking .= $subfolder . '/';
if (!in_array('!/' . $nested_tracking, $ignore, true)) {
$ignore[] = rtrim($nested_tracking . (!$last ? '*' : ''), '/');
$ignore[] = rtrim('!/' . $nested_tracking, '/');
}
}
} else {
$ignore[] = '!/' . $folder;
}
}
$ignoreEntries = explode("\n", $this->getGitConfig('ignore', ''));
$ignore = array_merge($ignore, $ignoreEntries);
$file = File::instance(rtrim($this->repositoryPath, '/') . '/.gitignore');
$file->save(implode("\r\n", $ignore));
$file->free();
}
/**
* @param string|null $alias
* @param string|null $url
* @param bool $authenticated
* @return string[]
*/
public function addRemote($alias = null, $url = null, $authenticated = false)
{
$alias = $this->getRemote('name', $alias);
$url = $this->getConfig('repository', $url);
if ($authenticated) {
$user = $this->user ?? '';
$password = $this->password ? Helper::decrypt($this->password) : '';
$url = Helper::prepareRepository($user, $password, $url);
}
$command = $this->hasRemote($alias) ? 'set-url' : 'add';
return $this->execute("remote {$command} {$alias} \"{$url}\"");
}
/**
* @return string[]
*/
public function add()
{
/** @var string $version */
$version = Helper::isGitInstalled(true);
$add = 'add';
// With the introduction of customizable paths,
// it appears that the add command should always
// add everything that is not committed to ensure
// there are no orphan changes left behind
/*
$folders = $this->config['folders'] ?? ['pages'];
$paths = [];
foreach ($folders as $folder) {
$paths[] = $folder;
}
*/
$paths = ['.'];
if (version_compare($version, '2.0', '<')) {
$add .= ' --all';
}
return $this->execute($add . ' ' . implode(' ', $paths));
}
/**
* @param string $message
* @return string[]
*/
public function commit($message = '(Grav GitSync) Automatic Commit')
{
$authorType = $this->getGitConfig('author', 'gituser');
if (defined('GRAV_CLI') && in_array($authorType, ['gravuser', 'gravfull'])) {
$authorType = 'gituser';
}
// get message from config, it any, or stick to the default one
$config = $this->getConfig('git', null);
$message = $config['message'] ?? $message;
// get Page Title and Route from Post
$uri = $this->grav['uri'];
$page_title = $uri->post('data.header.title');
$page_route = $uri->post('data.route');
$pageTitle = $page_title ?: 'NO TITLE FOUND';
$pageRoute = $page_route ?: 'NO ROUTE FOUND';
// include page title and route in the message, if placeholders exist
$message = str_replace('{{pageTitle}}', $pageTitle, $message);
/** @var string $message */
$message = str_replace('{{pageRoute}}', $pageRoute, $message);
$gitConfig = $this->getConfig('git', []) ?? [];
switch ($authorType) {
case 'gitsync':
$user = $gitConfig['name'] ?? 'GitSync';
$email = $gitConfig['email'] ?? 'git-sync@trilby.media';
break;
case 'gravuser':
$user = $this->grav['session']->user->username ?? 'GitSync';
$email = $this->grav['session']->user->email ?? 'git-sync@trilby.media';
break;
case 'gravfull':
$user = $this->grav['session']->user->fullname ?? 'GitSync';
$email = $this->grav['session']->user->email ?? 'git-sync@trilby.media';
break;
case 'gituser':
default:
$user = $this->user ?? 'GitSync';
$email = $gitConfig['email'] ?? 'git-sync@trilby.media';
break;
}
// Guard against empty values from any source (e.g. a Grav user with no
// full name set, or a blank committer field) — an empty author name
// triggers git's "fatal: empty ident name ... not allowed".
$user = $user ?: 'GitSync';
$email = $email ?: 'git-sync@trilby.media';
$author = $user . ' <' . $email . '>';
$author = '--author="' . $author . '"';
$message .= ' from ' . $user;
$this->add();
return $this->execute('commit ' . $author . ' -m ' . escapeshellarg($message));
}
/**
* @param string|null $name
* @param string|null $branch
* @return string[]
*/
public function fetch($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
return $this->execute("fetch {$name} {$branch}");
}
/**
* @param string|null $name
* @param string|null $branch
* @return string[]
*/
public function pull($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
/** @var string $version */
$version = Helper::isGitInstalled(true);
$unrelated_histories = '--allow-unrelated-histories';
// --allow-unrelated-histories starts at 2.9.0
if (version_compare($version, '2.9.0', '<')) {
$unrelated_histories = '';
}
return $this->execute("pull {$unrelated_histories} --ff -X theirs {$name} {$branch}");
}
/**
* @param string|null $name
* @param string|null $branch
* @return string[]
*/
public function push($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
$local_branch = $this->getConfig('branch', null);
return $this->execute("push {$name} {$local_branch}:{$branch}");
}
/**
* @param string|null $name
* @param string|null $branch
* @return bool
*/
public function sync($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
$this->addRemote(null, null, true);
$this->fetch($name, $branch);
$this->pull($name, $branch);
if ($this->grav['config']->get('plugins.git-sync.sync.direction', 'both') == 'both') {
$this->push($name, $branch);
}
$this->addRemote();
return true;
}
/**
* @return string[]
*/
public function reset()
{
return $this->execute('reset --hard HEAD');
}
/**
* @return bool
*/
public function isWorkingCopyClean()
{
$message = 'nothing to commit';
$output = $this->execute('status');
return strpos($output[count($output) - 1], $message) === 0;
}
/**
* @return bool
*/
public function hasChangesToCommit()
{
$folders = $this->config['folders'] ?? ['pages'];
$paths = [];
foreach ($folders as $folder) {
$folder = explode('/', $folder);
$paths[] = array_shift($folder);
}
$message = 'nothing to commit';
$output = $this->execute('status ' . implode(' ', $paths));
return strpos($output[count($output) - 1], $message) !== 0;
}
/**
* @param string $command
* @param bool $quiet
* @return string[]
*/
public function execute($command, $quiet = false)
{
try {
$bin = Helper::getGitBinary($this->getGitConfig('bin', 'git'));
/** @var string $version */
$version = Helper::isGitInstalled(true);
// -C <path> supported from 1.8.5 and above
if (version_compare($version, '1.8.5', '>=')) {
$command = $bin . ' -C ' . escapeshellarg($this->repositoryPath) . ' ' . $command;
} else {
$command = 'cd ' . $this->repositoryPath . ' && ' . $bin . ' ' . $command;
}
$command .= ' 2>&1';
if (DIRECTORY_SEPARATOR === '/') {
$command = 'LC_ALL=C ' . $command;
}
if ($this->getConfig('logging', false)) {
$log_command = Helper::preventReadablePassword($command, $this->password ?? '');
$this->grav['log']->notice('gitsync[command]: ' . $log_command);
exec($command, $output, $returnValue);
$log_output = Helper::preventReadablePassword(implode("\n", $output), $this->password ?? '');
$this->grav['log']->notice('gitsync[output]: ' . $log_output);
} else {
exec($command, $output, $returnValue);
}
if ($returnValue !== 0 && $returnValue !== 5 && !$quiet) {
throw new \RuntimeException(implode("\r\n", $output));
}
return $output;
} catch (\RuntimeException $e) {
$message = $e->getMessage();
$message = Helper::preventReadablePassword($message, $this->password ?? '');
// handle scary messages
if (Utils::contains($message, 'remote: error: cannot lock ref')) {
$message = 'GitSync: An error occurred while trying to synchronize. This could mean GitSync is already running. Please try again.';
}
throw new \RuntimeException($message);
}
return 0;
}
/**
* @param string $type
* @param mixed $value
* @return mixed
*/
public function getGitConfig($type, $value)
{
return $this->config['git'][$type] ?? $value;
}
/**
* @param string $type
* @param mixed $value
* @return mixed
*/
public function getRemote($type, $value)
{
return $value ?: ($this->config['remote'][$type] ?? $value);
}
/**
* @param string $type
* @param mixed $value
* @return mixed
*/
public function getConfig($type, $value)
{
return $value ?: ($this->config[$type] ?? $value);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace Grav\Plugin\GitSync;
use Defuse\Crypto\Crypto;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Utils;
use SebastianBergmann\Git\RuntimeException;
class Helper
{
/** @var string */
private static $hash = '594ef69d-6c29-45f7-893a-f1b4342687d3';
/** @var string */
const GIT_REGEX = '/(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/';
/**
* Checks if git-sync is properly configured with a repository URL
*
* @return bool
*/
public static function isGitSyncConfigured()
{
$config = Grav::instance()['config']->get('plugins.git-sync');
$repository = $config['repository'] ?? null;
return !empty($repository);
}
/**
* Checks if git-sync is ready to use (installed, configured, and initialized)
*
* @return bool
*/
public static function isGitSyncReady()
{
return static::isGitInstalled() && static::isGitSyncConfigured() && static::isGitInitialized();
}
/**
* Checks if the user/ folder is already initialized
*
* @return bool
*/
public static function isGitInitialized()
{
/** @var Config $grav */
$config = Grav::instance()['config']->get('plugins.git-sync');
$repositoryPath = isset($config['local_repository']) && $config['local_repository'] ? $config['local_repository'] : USER_DIR;
return file_exists(rtrim($repositoryPath, '/') . '/.git');
}
/**
* @param bool $version
* @return bool|string
*/
public static function isGitInstalled($version = false)
{
$bin = Helper::getGitBinary();
exec($bin . ' --version', $output, $returnValue);
$installed = $returnValue === 0;
if ($version && $output) {
$output = explode(' ', array_shift($output));
$versions = array_filter($output, static function($item) {
return version_compare($item, '0.0.1', '>=');
});
$installed = array_shift($versions);
}
return $installed;
}
/**
* @param bool $override
* @return string
*/
public static function getGitBinary($override = false)
{
/** @var Config $grav */
$config = Grav::instance()['config'];
return $override ?: $config->get('plugins.git-sync.git.bin', 'git');
}
/**
* @param string $user
* @param string $password
* @param string $repository
* @return string
*/
public static function prepareRepository($user, $password, $repository)
{
$user = $user ? urlencode($user) . ':' : '';
$password = urlencode($password);
if (Utils::startsWith($repository, 'ssh://')) {
return $repository;
}
return str_replace('://', "://{$user}{$password}@", $repository);
}
/**
* @param string $user
* @param string $password
* @param string $repository
* @return string[]
*/
public static function testRepository($user, $password, $repository, $branch)
{
$git = new GitSync();
$repository = self::prepareRepository($user, $password, $repository);
try {
return $git->testRepository($repository, $branch);
} catch (RuntimeException $e) {
return [$e->getMessage()];
}
}
/**
* @param string $password
* @return string
* @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException
*/
public static function encrypt($password)
{
return 'gitsync-' . Crypto::encryptWithPassword($password, self::$hash);
}
/**
* @param string $enc_password
* @return string
*/
public static function decrypt($enc_password)
{
if (strpos($enc_password, 'gitsync-') === 0) {
$enc_password = substr($enc_password, 8);
return Crypto::decryptWithPassword($enc_password, self::$hash);
}
return $enc_password;
}
/**
* @return bool
*/
public static function synchronize()
{
if (!self::isGitInstalled() || !self::isGitInitialized()) {
return true;
}
$git = new GitSync();
if ($git->hasChangesToCommit()) {
$git->commit();
}
// synchronize with remote
$git->sync();
return true;
}
/**
* @param string $str
* @param string $password
* @return string
*/
public static function preventReadablePassword($str, $password)
{
$encoded = urlencode(self::decrypt($password));
return str_replace($encoded, '{password}', $str);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php namespace Grav\Plugin\Console;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\GitSync\GitSync;
/**
* Class InitCommand
*
* @package Grav\Plugin\Console
*/
class InitCommand extends ConsoleCommand
{
protected function configure()
{
$this
->setName('init')
->setDescription('Initializes your git repository')
->setHelp('The <info>init</info> command runs the same git commands as the onAdminAfterSave function. Use this to manually initialize git-sync (useful for automated deployments).')
;
}
protected function serve()
{
require_once __DIR__ . '/../vendor/autoload.php';
$plugin = new GitSync();
$repository = $plugin->getConfig('repository', false);
$this->output->writeln('');
if (!$repository) {
$this->output->writeln('<red>ERROR:</red> No repository has been configured!');
}
$this->output->writeln('Initializing <cyan>' . $repository . '</cyan>');
$this->output->write('Starting initialization...');
$plugin->initializeRepository();
$plugin->setUser();
$plugin->addRemote();
$this->output->writeln('completed.');
return 0;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace Grav\Plugin\Console;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\GitSync\GitSync;
use Grav\Plugin\GitSync\Helper;
use Grav\Common\Grav;
use RocketTheme\Toolbox\File\YamlFile;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputOption;
/**
* Class LogCommand
*
* @package Grav\Plugin\Console
*/
class PasswdCommand extends ConsoleCommand
{
/** @var array */
protected $options = [];
protected function configure()
{
$this
->setName('passwd')
->setDescription('Allows to change the user and/or password programmatically')
->addOption(
'user',
'u',
InputOption::VALUE_REQUIRED,
'The username. Use empty double quotes if you need an empty username.'
)
->addOption(
'password',
'p',
InputOption::VALUE_REQUIRED,
"The password."
)
->setHelp('The <info>%command.name%</info> command allows to change the user and/or password. Useful when running automated scripts or needing to programmatically set them without admin access.')
;
}
protected function serve()
{
require_once __DIR__ . '/../vendor/autoload.php';
$grav = Grav::instance();
$config = $grav['config'];
$locator = $grav['locator'];
$filename = 'config://plugins/git-sync.yaml';
$file = YamlFile::instance($locator->findResource($filename, true, true));
$this->options = [
'user' => $this->input->getOption('user'),
'password' => $this->input->getOption('password')
];
if ($this->options['password'] !== null) {
$this->options['password'] = Helper::encrypt($this->options['password']);
}
$user = $this->options['user'] !== null ? $this->options['user'] : $config->get('plugins.git-sync.user');
$password = $this->options['password'] !== null ? $this->options['password'] : $config->get('plugins.git-sync.password');
$config->set('plugins.git-sync.user', $user);
$config->set('plugins.git-sync.password', $password);
$content = $grav['config']->get('plugins.git-sync');
$file->save($content);
$file->free();
$this->output->writeln('');
$this->output->writeln('<green>User / Password updated.</green>');
$this->output->writeln('');
return 0;
}
private function console_header($readable, $cmd = '', $remote_action = false)
{
$this->output->writeln(
"<yellow>$readable</yellow>" . ($cmd ? "(<info>$cmd</info>)" : ''). ($remote_action ? '...' : '')
);
}
private function console_log($lines, $password)
{
foreach ($lines as $line) {
$this->output->writeln(' ' . Helper::preventReadablePassword($line, $password));
}
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Grav\Plugin\Console;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\GitSync\GitSync;
use Grav\Plugin\GitSync\Helper;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputOption;
/**
* Class LogCommand
*
* @package Grav\Plugin\Console
*/
class StatusCommand extends ConsoleCommand
{
protected function configure()
{
$this
->setName('status')
->setDescription('Checks the status of plugin config, git and git workspace. No files get modified!')
->addOption(
'fetch', 'f',
InputOption::VALUE_NONE,
'additionally do a git fetch to look updates (changes not files in workspace)'
)
->setHelp(<<<'EOF'
The <info>%command.name%</info> command checks if the plugin is usable the way it has been configured.
While doing this it prints the available information for your inspection.
<comment>No files in the workspace are modified when running this test.</comment>
The <info>--fetch</info> option can be used to see differences between the remote in the <info>git status</info> (last check)
It also returns with an error code and a helpful message when something is not normal:
<error>100</error> : <info>git</info> binary not working as expected
<error>50</error> : <info>repositoryFolder</info> and git workspace root do not match
<error>10</error> : <info>repository</info> is not configured
<error>5</error> : state of workspace not clean
<error>1</error> : Some checks can throw a <info>RuntimeException</info> which is not caught, read the message for details
EOF
)
;
}
protected function serve()
{
require_once __DIR__ . '/../vendor/autoload.php';
$plugin = new GitSync();
$this->output->writeln('');
$this->console_header('plugin runtime information:');
$info = $plugin->getRuntimeInformation();
$info['isGitInitialized'] = Helper::isGitInitialized();
$info['gitVersion'] = Helper::isGitInstalled(true);
ksort($info);
dump($info);
if (!Helper::isGitInstalled()) {
throw new RuntimeException('git binary not found', 100);
}
$this->console_header('detect git workspace root:');
$git_root = $plugin->execute('rev-parse --show-toplevel');
$this->console_log($git_root, '');
if (rtrim($info['repositoryPath'], '/') !== rtrim($git_root[0], '/')) {
throw new RuntimeException('git root and repositoryPath do not match', 50);
}
// needed to prevent output in logs:
$password = Helper::decrypt($plugin->getPassword() ?? '');
$this->console_header('local git config:');
$this->console_log(
$plugin->execute('config --local -l'), $password
);
$this->console_header(
'Testing connection to repository', 'git ls-remote', true
);
$repository = $plugin->getConfig('repository', false);
if (!$repository) {
throw new RuntimeException('No repository has been configured', 10);
}
$testRepository = $plugin->testRepository(
Helper::prepareRepository(
$plugin->getUser() ?? '',
$password,
$repository),
$plugin->getRemote('branch', null),
);
$this->console_log($testRepository, $password);
$fetched = false;
if ($this->input->getOption('fetch')) {
$remote = $plugin->getRemote('name', '');
$this->console_header(
'Looking for updates', "git fetch $remote", true
);
$this->console_log($plugin->fetch($remote), $password);
$fetched = true;
}
$this->console_header(
'Checking workspace status', 'git status', true
);
$git_status = $plugin->execute('status');
$this->console_log($git_status, $password);
if (!$plugin->isWorkingCopyClean()) {
throw new RuntimeException('Working state is not clean.', 5);
}
if ($fetched) {
$uptodate = strpos($git_status[1], 'branch is up-to-date with') > 0;
if ($uptodate) {
$this->console_header(
'Congrats: You should be able to run the <info>sync</info> command without problems!'
);
} else {
$this->output->writeln('<yellow>You are not in sync!</yellow>');
$this->output->writeln('Take a look at the output of git status to see more details.');
$this->output->writeln('In most cases the <info>sync</info> command is able to fix this.');
}
} else {
$this->console_header('Looks good: use <info>--fetch</info> option to check for updates.');
}
return 0;
}
private function console_header($readable, $cmd = '', $remote_action = false)
{
$this->output->writeln(
"<yellow>$readable</yellow>" . ($cmd ? "(<info>$cmd</info>)" : ''). ($remote_action ? '...' : '')
);
}
private function console_log($lines, $password)
{
foreach ($lines as $line) {
$this->output->writeln(' ' . Helper::preventReadablePassword($line, $password));
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Grav\Plugin\Console;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\GitSync\GitSync;
/**
* Class LogCommand
*
* @package Grav\Plugin\Console
*/
class SyncCommand extends ConsoleCommand
{
protected function configure()
{
$this
->setName('sync')
->setDescription('Performs a synchronization of your site')
->setHelp('The <info>sync</info> command performs a synchronization of your site. Useful if you want to run a periodic crontab job to automate it.')
;
}
protected function serve()
{
require_once __DIR__ . '/../vendor/autoload.php';
$plugin = new GitSync();
$repository = $plugin->getConfig('repository', false);
$this->output->writeln('');
if (!$repository) {
$this->output->writeln('<red>ERROR:</red> No repository has been configured');
}
$this->output->writeln('Synchronizing with <cyan>' . $repository . '</cyan>');
if ($plugin->hasChangesToCommit()) {
$this->output->writeln('Changes detected, adding and committing...');
$plugin->add();
$plugin->commit();
}
$this->output->write('Starting Synchronization...');
$plugin->sync();
$this->output->writeln('completed.');
return 0;
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"require": {
"php": ">=7.1.3",
"ext-json": "*",
"ext-openssl": "*",
"sebastian/git": "^2.1",
"defuse/php-encryption": "^2.0"
},
"replace": {
"paragonie/random_compat": "9.99.99"
},
"autoload": {
"psr-4": {
"Grav\\Plugin\\GitSync\\": "classes/",
"Grav\\Plugin\\Console\\": "cli/"
},
"classmap": [
"git-sync.php"
]
},
"license": "Apache-2.0",
"authors": [
{
"name": "Trilby Media, LLC",
"email": "devs@trilbymedia.com"
}
]
}
+139
View File
@@ -0,0 +1,139 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5e875c300e0af3cf13a009bb9c591afd",
"packages": [
{
"name": "defuse/php-encryption",
"version": "v2.3.1",
"source": {
"type": "git",
"url": "https://github.com/defuse/php-encryption.git",
"reference": "77880488b9954b7884c25555c2a0ea9e7053f9d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/defuse/php-encryption/zipball/77880488b9954b7884c25555c2a0ea9e7053f9d2",
"reference": "77880488b9954b7884c25555c2a0ea9e7053f9d2",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"paragonie/random_compat": ">= 2",
"php": ">=5.6.0"
},
"require-dev": {
"phpunit/phpunit": "^4|^5|^6|^7|^8|^9"
},
"bin": [
"bin/generate-defuse-key"
],
"type": "library",
"autoload": {
"psr-4": {
"Defuse\\Crypto\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Hornby",
"email": "taylor@defuse.ca",
"homepage": "https://defuse.ca/"
},
{
"name": "Scott Arciszewski",
"email": "info@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "Secure PHP Encryption Library",
"keywords": [
"aes",
"authenticated encryption",
"cipher",
"crypto",
"cryptography",
"encrypt",
"encryption",
"openssl",
"security",
"symmetric key cryptography"
],
"support": {
"issues": "https://github.com/defuse/php-encryption/issues",
"source": "https://github.com/defuse/php-encryption/tree/v2.3.1"
},
"time": "2021-04-09T23:57:26+00:00"
},
{
"name": "sebastian/git",
"version": "2.1.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/git.git",
"reference": "815bbbc963cf35e5413df195aa29df58243ecd24"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/git/zipball/815bbbc963cf35e5413df195aa29df58243ecd24",
"reference": "815bbbc963cf35e5413df195aa29df58243ecd24",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
}
},
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Sebastian Bergmann",
"email": "sebastian@phpunit.de"
}
],
"description": "Simple wrapper for Git",
"homepage": "http://www.github.com/sebastianbergmann/git",
"keywords": [
"git"
],
"support": {
"issues": "https://github.com/sebastianbergmann/git/issues",
"source": "https://github.com/sebastianbergmann/git/tree/master"
},
"abandoned": true,
"time": "2017-01-23T20:57:12+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=7.1.3",
"ext-json": "*",
"ext-openssl": "*"
},
"platform-dev": [],
"plugin-api-version": "2.0.0"
}
@@ -0,0 +1,22 @@
@font-face {
font-family: 'gitsync';
src:
url('../fonts/gitsync.ttf?ivrc6k') format('truetype'),
url('../fonts/gitsync.woff?ivrc6k') format('woff'),
url('../fonts/gitsync.svg?ivrc6k#gitsync') format('svg');
font-weight: normal;
font-style: normal;
}
.fa.fa-git,
.fa.fa-git-square {
font-family: 'gitsync' !important;
vertical-align: middle;
}
.fa-git-square:before {
content: "\e900" !important;
}
.fa-git:before {
content: "\e901" !important;
}
+137
View File
@@ -0,0 +1,137 @@
[data-remodal-id="wizard"] .button[disabled], [data-remodal-id="wizard"] .button[disabled]:hover, [data-remodal-id="reset-local"] .button[disabled], [data-remodal-id="reset-local"] .button[disabled]:hover {
background: #ccc;
cursor: default; }
[data-remodal-id="wizard"] .button[disabled]:active, [data-remodal-id="reset-local"] .button[disabled]:active {
margin: 0; }
[data-remodal-id="wizard"] form [class^="step-"] a:not(.button):not([target]), [data-remodal-id="reset-local"] form [class^="step-"] a:not(.button):not([target]) {
vertical-align: middle;
color: #5591c7; }
[data-remodal-id="wizard"] form [class^="step-"] a:not(.button):not([target]):hover, [data-remodal-id="reset-local"] form [class^="step-"] a:not(.button):not([target]):hover {
color: #366188; }
[data-remodal-id="wizard"] .step-1 a, [data-remodal-id="reset-local"] .step-1 a {
vertical-align: middle; }
[data-remodal-id="wizard"] .access-tokens h3, [data-remodal-id="reset-local"] .access-tokens h3 {
margin-top: 1rem; }
[data-remodal-id="wizard"] .access-tokens a, [data-remodal-id="reset-local"] .access-tokens a {
vertical-align: inherit !important; }
[data-remodal-id="wizard"] .access-tokens-details > p, [data-remodal-id="reset-local"] .access-tokens-details > p {
margin-top: 0; }
[data-remodal-id="wizard"] .access-tokens-details > div > ul, [data-remodal-id="reset-local"] .access-tokens-details > div > ul {
margin-bottom: 0; }
[data-remodal-id="wizard"] .center, [data-remodal-id="reset-local"] .center {
text-align: center; }
[data-remodal-id="wizard"] h1, [data-remodal-id="reset-local"] h1 {
margin-bottom: 0;
padding-top: 0.5rem;
border-top: 3px solid transparent; }
[data-remodal-id="wizard"] .wizard-padding, [data-remodal-id="reset-local"] .wizard-padding {
padding: 0 3rem; }
[data-remodal-id="wizard"] .wizard-padding p, [data-remodal-id="reset-local"] .wizard-padding p {
padding: 0; }
[data-remodal-id="wizard"] input[disabled], [data-remodal-id="reset-local"] input[disabled] {
background: #efefef;
border-color: #ddd; }
[data-remodal-id="wizard"] input.invalid, [data-remodal-id="reset-local"] input.invalid {
border-color: #f4516d;
color: #f4516d; }
[data-remodal-id="wizard"] label.disabled, [data-remodal-id="reset-local"] label.disabled {
color: #ccc; }
[data-remodal-id="wizard"] label img, [data-remodal-id="reset-local"] label img {
max-width: 100px;
display: inline-block;
vertical-align: middle;
margin-left: 0.5rem; }
[data-remodal-id="wizard"] label a, [data-remodal-id="reset-local"] label a {
margin-left: 0.5rem; }
[data-remodal-id="wizard"] .columns, [data-remodal-id="reset-local"] .columns {
display: flex;
align-items: center;
justify-content: center; }
[data-remodal-id="wizard"] .columns .column, [data-remodal-id="reset-local"] .columns .column {
flex: 1; }
[data-remodal-id="wizard"] .columns .column:first-child, [data-remodal-id="reset-local"] .columns .column:first-child {
width: 35%;
flex: none;
border-right: 1px solid #ddd;
margin-right: 2rem; }
[data-remodal-id="wizard"] .step-1 div.column:nth-child(1), [data-remodal-id="reset-local"] .step-1 div.column:nth-child(1) {
flex-grow: 1.5;
width: 15%; }
[data-remodal-id="wizard"] .step-1 div.column:nth-child(1) > label:nth-child(4), [data-remodal-id="reset-local"] .step-1 div.column:nth-child(1) > label:nth-child(4) {
width: 110%; }
[data-remodal-id="wizard"] .step-1 div.column:nth-child(2), [data-remodal-id="reset-local"] .step-1 div.column:nth-child(2) {
margin-left: 25px; }
[data-remodal-id="wizard"] .step-1 div.column:nth-child(2) > label:nth-child(1) > input:nth-child(1), [data-remodal-id="wizard"] .step-1 div.column:nth-child(2) > label:nth-child(2) > input:nth-child(1), [data-remodal-id="reset-local"] .step-1 div.column:nth-child(2) > label:nth-child(1) > input:nth-child(1), [data-remodal-id="reset-local"] .step-1 div.column:nth-child(2) > label:nth-child(2) > input:nth-child(1) {
width: 100%; }
[data-remodal-id="wizard"] .step-1 .no_user, [data-remodal-id="reset-local"] .step-1 .no_user {
float: right;
font-size: .8rem;
padding: 0; }
[data-remodal-id="wizard"] .step-1 .no_user input, [data-remodal-id="reset-local"] .step-1 .no_user input {
margin-right: 0; }
[data-remodal-id="wizard"] .step-2 .info, [data-remodal-id="reset-local"] .step-2 .info {
margin: 0.2rem 0;
padding: 0.5rem 1.5rem; }
[data-remodal-id="wizard"] .step-2 ol, [data-remodal-id="wizard"] .step-2 ul, [data-remodal-id="reset-local"] .step-2 ol, [data-remodal-id="reset-local"] .step-2 ul {
padding-left: 1rem; }
[data-remodal-id="wizard"] .step-4 .info, [data-remodal-id="reset-local"] .step-4 .info {
font-size: 100%;
margin: 0.2rem 0; }
[data-remodal-id="wizard"] .step-4 .alert, [data-remodal-id="wizard"] .step-4 .warning, [data-remodal-id="reset-local"] .step-4 .alert, [data-remodal-id="reset-local"] .step-4 .warning {
padding: 0.5rem 1.5rem; }
[data-remodal-id="wizard"] .step-4 .fa.fa-warning, [data-remodal-id="reset-local"] .step-4 .fa.fa-warning {
color: #ff7d3b;
font-size: 1.2rem; }
[data-remodal-id="wizard"] .step-4 .wizard-padding label > *, [data-remodal-id="reset-local"] .step-4 .wizard-padding label > * {
vertical-align: middle; }
[data-remodal-id="wizard"] .step-4 .info-desc, [data-remodal-id="reset-local"] .step-4 .info-desc {
color: #0091ff;
float: right;
margin-right: .5rem;
font-size: 1.2rem; }
[data-remodal-id="wizard"] .step-4 hr, [data-remodal-id="reset-local"] .step-4 hr {
margin: .5rem 0; }
#admin-main [data-grav-field="git-wizard"] {
margin: 0 1rem; }
#admin-main [data-grav-field="git-wizard"] .danger.button-bar {
margin: 0;
padding: 0;
background: transparent; }
#admin-main [data-grav-field="git-wizard"] .button {
top: 0 !important;
-webkit-transform: translateY(0) !important;
-moz-transform: translateY(0) !important;
-ms-transform: translateY(0) !important;
-o-transform: translateY(0) !important;
transform: translateY(0) !important; }
/*# sourceMappingURL=git-sync.css.map */
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="gitsync" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="git-square" horiz-adv-x="878" d="M332.571 203.428c0-30.857-28-37.714-53.143-37.714-24.571 0-61.143 4-61.143 36 0 31.429 30.857 36.571 56 36.571 24 0 58.286-4 58.286-34.857zM312 469.714c0-28.571-11.429-48.571-42.286-48.571-31.429 0-44 18.286-44 48s11.429 51.429 44 51.429c29.143 0 42.286-24 42.286-50.857zM406.857 512.571v71.429c-24.571-9.143-50.857-16.571-77.143-16.571-18.857 10.857-40.571 16.571-62.857 16.571-65.143 0-116.571-48-116.571-114.286 0-35.429 23.429-84.571 58.857-96.571v-1.714c-18.286-8-21.714-30.286-21.714-48.571 0-18.857 6.857-34.286 23.429-44v-1.714c-38.857-12.571-64.571-37.143-64.571-79.429 0-72.571 69.143-93.143 129.714-93.143 73.143 0 128 26.857 128 107.429 0 57.143-52 74.286-99.429 82.857-16 2.857-43.429 14.286-43.429 34.286 0 18.857 10.286 26.857 28 29.714 58.286 11.429 95.429 56.571 95.429 116.571 0 10.286-2.286 20-5.714 29.714 9.143 2.286 18.857 4.571 28 7.429zM440.571 273.143h78.286c-1.143 15.429-1.143 31.429-1.143 46.857v221.143c0 13.143 0 26.286 1.143 39.429h-78.286c1.714-13.143 1.714-27.429 1.714-40.571v-224c0-14.286 0-28.571-1.714-42.857zM731.429 282.286v69.143c-11.429-8-25.143-12-38.857-12-25.714 0-30.286 25.714-30.286 46.857v128.571h29.714c10.286 0 20-1.143 30.286-1.143v66.857h-60c0 19.429-1.143 38.857 1.714 58.286h-80c1.714-10.286 2.286-20.571 2.286-31.429v-26.857h-34.286v-66.857c6.857 0.571 13.714 1.714 21.143 1.714 4 0 8.571-0.571 13.143-0.571v-1.143h-1.143v-124c0-61.714 9.143-121.143 84.571-121.143 21.143 0 42.857 3.429 61.714 13.714zM528 685.714c0 26.857-20 52-48 52s-48.571-24.571-48.571-52c0-26.857 21.143-50.857 48.571-50.857s48 24.571 48 50.857zM877.714 713.143v-548.571c0-90.857-73.714-164.571-164.571-164.571h-548.571c-90.857 0-164.571 73.714-164.571 164.571v548.571c0 90.857 73.714 164.571 164.571 164.571h548.571c90.857 0 164.571-73.714 164.571-164.571z" />
<glyph unicode="&#xe901;" glyph-name="git" d="M340 85.714c0 50.286-55.429 57.143-94.286 57.143-40.571 0-90.286-8.571-90.286-59.429 0-51.429 58.857-57.714 98.286-57.714 41.714 0 86.286 10.286 86.286 60zM306.286 517.143c0 42.857-20.571 81.714-68 81.714-52.571 0-70.857-34.857-70.857-82.857 0-47.429 20.571-77.143 70.857-77.143 49.714 0 68 32 68 78.286zM460 702.286v-115.429c-14.857-5.143-29.714-9.143-45.143-12.571 5.714-15.429 9.143-31.429 9.143-48 0-96.571-59.429-170.286-154.286-188-28.571-5.714-45.143-17.714-45.143-48.571 0-87.429 230.857-28 230.857-189.143 0-130.857-88.571-173.714-207.429-173.714-97.714 0-209.143 32.571-209.143 150.286 0 68.571 41.714 108 104 128.571v2.286c-26.286 16-38.286 41.143-38.286 72 0 29.143 6.286 65.143 36 78.286v2.286c-57.714 19.429-95.429 98.857-95.429 156.571 0 106.857 82.857 185.143 188.571 185.143 35.429 0 70.857-9.143 101.714-26.857 42.857 0 85.143 11.429 124.571 26.857zM641.714 198.857h-126.857c2.286 25.714 2.286 50.857 2.286 76.571v348c0 24.571 0.571 49.143-2.286 73.143h126.857c-2.857-23.429-2.286-47.429-2.286-70.857v-350.286c0-25.714 0-50.857 2.286-76.571zM985.143 325.714v-112c-30.286-16.571-65.143-22.286-99.429-22.286-122.286 0-136.571 96.571-136.571 196v200.571h1.143v2.286c-7.429 0-14.286 1.143-21.143 1.143-11.429 0-22.857-1.714-33.714-3.429v108.571h54.857v43.429c0 17.143-0.571 34.286-3.429 50.857h129.714c-4.571-31.429-3.429-62.857-3.429-94.286h97.714v-108.571c-16.571 0-33.143 2.286-49.143 2.286h-48.571v-208.571c0-33.714 7.429-74.857 49.714-74.857 22.286 0 44 6.286 62.286 18.857zM656 866.857c0-42.857-33.143-82.857-77.143-82.857-45.143 0-78.857 39.429-78.857 82.857 0 44 33.143 84 78.857 84 45.143 0 77.143-41.143 77.143-84z" />
</font></defs></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.
+750
View File
@@ -0,0 +1,750 @@
<?php
namespace Grav\Plugin;
use Composer\Autoload\ClassLoader;
use Grav\Common\Config\Config;
use Grav\Common\Data\Data;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Plugin;
use Grav\Common\Scheduler\Scheduler;
use Grav\Events\PermissionsRegisterEvent;
use Grav\Framework\Acl\PermissionsReader;
use Grav\Plugin\GitSync\AdminController;
use Grav\Plugin\GitSync\GitSync;
use Grav\Plugin\GitSync\Helper;
use RocketTheme\Toolbox\Event\Event;
/**
* Class GitSyncPlugin
*
* @package Grav\Plugin
*/
class GitSyncPlugin extends Plugin
{
/** @var AdminController|null */
protected $controller;
/** @var GitSync */
protected $git;
/**
* @return array
*/
public static function getSubscribedEvents()
{
return [
'onPluginsInitialized' => [
['autoload', 100000],
['onPluginsInitialized', 1000]
],
'onPageInitialized' => ['onPageInitialized', 0],
'onFormProcessed' => ['onFormProcessed', 0],
'onSchedulerInitialized' => ['onSchedulerInitialized', 0],
// Admin-Next (API plugin) integration
'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
'onApiSidebarItems' => ['onApiSidebarItems', 0],
'onApiMenubarItems' => ['onApiMenubarItems', 0],
'onApiMenubarAction' => ['onApiMenubarAction', 0],
'onApiPluginPageInfo' => ['onApiPluginPageInfo', 0],
'onApiBlueprintResolved' => ['onApiBlueprintResolved', 0],
'onApiFloatingWidgets' => ['onApiFloatingWidgets', 0],
PermissionsRegisterEvent::class => [
['onRegisterPermissions', 100],
],
];
}
/**
* [onPluginsInitialized:100000] Composer autoload.
*
* @return ClassLoader
*/
public function autoload() : ClassLoader
{
return require __DIR__ . '/vendor/autoload.php';
}
/**
* @return string
*/
public static function generateWebhookSecret()
{
return static::generateHash(24);
}
/**
* @return string
*/
public static function generateRandomWebhook()
{
return '/_git-sync-' . static::generateHash(6);
}
/**
* Initialize the plugin
*/
public function onPluginsInitialized()
{
$this->enable(['gitsync' => ['synchronize', 0]]);
$this->init();
// Auto-sync triggers — page save / delete / media events.
//
// These need to be subscribed regardless of context because the API
// plugin (admin-next backend) registers its AdminProxy AFTER
// onPluginsInitialized has already fired, so an isAdmin() check
// at boot misses every API-driven save / delete / media event.
// The handlers themselves gate internally on object type and
// admin path, and the events simply never fire on the frontend
// or in CLI, so registering them globally is safe.
$this->enable([
'onAdminSave' => ['onAdminSave', 0],
'onAdminAfterSave' => ['onAdminAfterSave', 0],
'onAdminAfterSaveAs' => ['onAdminAfterSaveAs', 0],
'onAdminAfterDelete' => ['onAdminAfterDelete', 0],
'onAdminAfterAddMedia' => ['onAdminAfterMedia', 0],
'onAdminAfterDelMedia' => ['onAdminAfterMedia', 0],
]);
// Admin-classic-only subs (Twig assets, sidebar entry, quick-tray button).
if ($this->isAdmin()) {
$this->enable([
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
'onTwigSiteVariables' => ['onTwigSiteVariables', 0],
'onAdminMenu' => ['onAdminMenu', 0],
]);
return;
}
$config = $this->config->get('plugins.' . $this->name);
$route = $this->grav['uri']->route();
$webhook = $config['webhook'] ?? false;
$secret = $config['webhook_secret'] ?? false;
$enabled = $config['webhook_enabled'] ?? false;
if ($enabled && $route === $webhook && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($secret) {
if (!$this->isRequestAuthorized($secret)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode([
'status' => 'error',
'message' => 'Unauthorized request'
]);
exit;
}
}
try {
$this->synchronize();
header('Content-Type: application/json');
echo json_encode([
'status' => 'success',
'message' => 'GitSync completed the synchronization'
]);
} catch (\Exception $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'status' => 'error',
'message' => 'GitSync failed to synchronize'
]);
}
exit;
}
}
/**
* Returns true if the request contains a valid signature or token
* @param string $secret local secret
* @return bool whether or not the request is authorized
*/
public function isRequestAuthorized($secret)
{
if (isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) {
$payload = file_get_contents('php://input') ?: '';
return $this->isGithubSignatureValid($secret, $_SERVER['HTTP_X_HUB_SIGNATURE'], $payload);
}
if (isset($_SERVER['HTTP_X_GITLAB_TOKEN'])) {
return $this->isGitlabTokenValid($secret, $_SERVER['HTTP_X_GITLAB_TOKEN']);
} else {
$payload = file_get_contents('php://input');
return $this->isGiteaSecretValid($secret, $payload);
}
return false;
}
/**
* Hashes the webhook request body with the client secret and
* checks if it matches the webhook signature header
* @param string $secret The webhook secret
* @param string $signatureHeader The signature of the webhook request
* @param string $payload The webhook request body
* @return bool Whether the signature is valid or not
*/
public function isGithubSignatureValid($secret, $signatureHeader, $payload)
{
[$algorithm, $signature] = explode('=', $signatureHeader);
return $signature === hash_hmac($algorithm, $payload, $secret);
}
/**
* Returns true if given Gitlab token matches secret
* @param string $secret local secret
* @param string $token token received from Gitlab webhook request
* @return bool whether or not secret and token match
*/
public function isGitlabTokenValid($secret, $token)
{
return $secret === $token;
}
/**
* Returns true if secret contained in the payload matches the client
* secret
* @param string $secret The webhook secret
* @param string $payload The webhook request body
* @return boolean Whether the client secret matches the payload secret or
* not
*/
public function isGiteaSecretValid($secret, $payload)
{
$payload = json_decode($payload, true);
if (!empty($payload) && isset($payload['secret'])) {
return $secret === $payload['secret'];
}
return false;
}
public function onAdminMenu()
{
$base = rtrim($this->grav['base_url'], '/') . '/' . trim($this->grav['admin']->base, '/');
$options = [
'hint' => Helper::isGitInitialized() ? 'Synchronize GitSync' : 'Configure GitSync',
'class' => 'gitsync-sync',
'location' => 'pages',
'route' => Helper::isGitInitialized() ? 'admin' : 'admin/plugins/git-sync',
'icon' => 'fa-' . $this->grav['plugins']->get('git-sync')->blueprints()->get('icon')
];
if (Helper::isGitInstalled()) {
if (Helper::isGitInitialized()) {
$options['data'] = [
'gitsync-useraction' => 'sync',
'gitsync-uri' => $base . '/plugins/git-sync'
];
}
$this->grav['twig']->plugins_quick_tray['GitSync'] = $options;
}
}
public function onApiRegisterRoutes(Event $event): void
{
$routes = $event['routes'];
$controller = \Grav\Plugin\GitSync\Api\GitSyncApiController::class;
$routes->group('/git-sync', function ($group) use ($controller) {
$group->get('/data', [$controller, 'data']);
$group->patch('/data', [$controller, 'save']);
$group->post('/sync', [$controller, 'sync']);
$group->post('/reset', [$controller, 'reset']);
$group->post('/test-connection', [$controller, 'testConnection']);
$group->get('/wizard/state', [$controller, 'wizardState']);
});
}
public function onApiSidebarItems(Event $event): void
{
$items = $event['items'] ?? [];
$items[] = [
'id' => 'git-sync',
'plugin' => 'git-sync',
'label' => 'Git Sync',
'icon' => 'fa-code-branch',
'route' => '/plugin/git-sync',
'priority' => 5,
// Match the read-level any-of check in GitSyncApiController:
// anyone with read / write / admin (or the parent) sees the item.
'authorize' => ['api.git-sync', 'api.git-sync.read', 'api.git-sync.write', 'api.git-sync.admin'],
];
$event['items'] = $items;
}
public function onApiMenubarItems(Event $event): void
{
if (!Helper::isGitInstalled() || !Helper::isGitSyncReady()) {
return;
}
$items = $event['items'] ?? [];
$items[] = [
'id' => 'git-sync-quick',
'plugin' => 'git-sync',
'label' => 'Synchronize Git Sync',
'icon' => 'fa-code-branch',
'action' => 'sync',
// Sync is a write action — only show the menubar button to users
// who can actually run it.
'authorize' => ['api.git-sync', 'api.git-sync.write', 'api.git-sync.admin'],
];
$event['items'] = $items;
}
public function onApiMenubarAction(Event $event): void
{
if ($event['plugin'] !== 'git-sync') {
return;
}
if ($event['action'] === 'sync') {
// Release the session lock so the rest of admin-next stays
// responsive while git pull/push runs over the network.
@set_time_limit(0);
@session_write_close();
try {
$this->synchronize();
$event['result'] = [
'status' => 'success',
'message' => 'GitSync has successfully synchronized with the repository.',
];
} catch (\Throwable $e) {
$event['result'] = [
'status' => 'error',
'message' => Helper::preventReadablePassword($e->getMessage(), $this->git ? $this->git->getPassword() ?? '' : ''),
];
}
}
}
public function onApiPluginPageInfo(Event $event): void
{
if ($event['plugin'] !== 'git-sync') {
return;
}
$event['definition'] = [
'id' => 'git-sync',
'plugin' => 'git-sync',
'title' => 'Git Sync',
'icon' => 'fa-code-branch',
'page_type' => 'blueprint',
'blueprint' => 'git-sync',
'data_endpoint' => '/git-sync/data',
'save_endpoint' => '/git-sync/data',
'actions' => [
[
'id' => 'wizard',
'label' => 'Wizard',
'icon' => 'fa-wand-magic-sparkles',
// No endpoint — admin-next dispatches grav:plugin-page-action
// and the auto-loaded git-sync widget script catches it.
],
[
'id' => 'sync',
'label' => 'Synchronize',
'icon' => 'fa-cloud-arrow-up',
'endpoint' => '/git-sync/sync',
],
[
'id' => 'reset',
'label' => 'Reset Local Copy',
'icon' => 'fa-clock-rotate-left',
'endpoint' => '/git-sync/reset',
'confirm' => 'Discard all local changes and re-pull from the remote? Any uncommitted edits will be lost.',
],
[
'id' => 'save',
'label' => 'Save',
'icon' => 'fa-check',
'primary' => true,
],
],
];
}
/**
* Strip admin-classic-only fields from the blueprint sent to admin-next
* and annotate the enc-password field with current stored/encrypted state
* so its custom component can render the right placeholder.
*
* The wizard / sync / reset buttons are now header actions; the in-form
* `_wizard` field and its surrounding `Actions` section have nothing to
* render in admin-next. The YAML stays intact for admin-classic.
*/
public function onApiBlueprintResolved(Event $event): void
{
$context = $event['context'] ?? null;
if ($context !== 'plugin' && $context !== 'plugin-page') {
return;
}
if (($event['plugin'] ?? null) !== 'git-sync') {
return;
}
// Generic Plugins → Git Sync detail page collapses to just an
// enable / disable toggle plus a pointer to the dedicated page.
// The full form + Wizard / Sync / Reset actions live at
// /admin/plugin/git-sync (the sidebar entry), so the two pages
// don't overlap.
if ($context === 'plugin') {
$event['fields'] = [
[
'name' => 'admin_next_notice',
'type' => 'display',
'markdown' => true,
'content' => "**Git Sync** has its own admin page with the full configuration form, the setup Wizard, and the Synchronize / Reset actions. Open it from the **Git Sync** entry in the sidebar.",
],
[
'name' => 'enabled',
'type' => 'toggle',
'label' => 'Plugin Status',
'highlight' => 1,
'default' => 0,
'options' => [
['value' => '1', 'label' => 'Enabled'],
['value' => '0', 'label' => 'Disabled'],
],
'validate' => ['type' => 'bool'],
],
];
return;
}
// Dedicated plugin page (context: plugin-page) — strip the
// admin-classic-only wizard launcher + its Actions section, and
// annotate the password field with current storage state for the
// enc-password component.
$stored = (string) ($this->config->get('plugins.git-sync.password') ?? '');
$isStored = $stored !== '';
$isEncrypted = $isStored && str_starts_with($stored, 'gitsync-');
$fields = $event['fields'];
$filtered = [];
foreach ($fields as $field) {
$name = $field['name'] ?? '';
$type = $field['type'] ?? '';
if ($name === 'Actions' || $name === '_wizard' || $type === 'git-wizard') {
continue;
}
if ($name === 'password') {
$field['password_stored'] = $isStored;
$field['password_encrypted'] = $isEncrypted;
}
$filtered[] = $field;
}
$event['fields'] = $filtered;
}
/**
* Register the wizard host as an auto-loaded floating widget with no FAB.
*
* The widget panel is never opened by the user — the script just needs to
* be loaded so its top-level event listener can catch the `wizard` action
* dispatched from the plugin page header and render the modal as a portal.
*/
public function onApiFloatingWidgets(Event $event): void
{
$widgets = $event['widgets'] ?? [];
$widgets[] = [
'id' => 'git-sync',
'plugin' => 'git-sync',
'label' => 'Git Sync Wizard',
'icon' => 'fa-wand-magic-sparkles',
'autoLoad' => true,
'showFab' => false,
];
$event['widgets'] = $widgets;
}
public function onRegisterPermissions(PermissionsRegisterEvent $event): void
{
$permissions = $event->permissions;
$actions = PermissionsReader::fromYaml('plugin://git-sync/permissions.yaml');
$permissions->addActions($actions);
}
public function init()
{
if ($this->isAdmin()) {
/** @var AdminController controller */
$this->controller = new AdminController($this);
$this->git = &$this->controller->git;
} else {
$this->git = new GitSync();
}
}
/**
* @return bool
*/
public function synchronize()
{
// Skip if git-sync is not properly configured
if (!Helper::isGitSyncReady()) {
return true;
}
$this->grav->fireEvent('onGitSyncBeforeSynchronize');
if ($this->git->hasChangesToCommit()) {
$this->git->commit();
}
// synchronize with remote
$this->git->sync();
$this->grav->fireEvent('onGitSyncAfterSynchronize');
return true;
}
public function onSchedulerInitialized(Event $event)
{
/** @var Config $config */
$config = Grav::instance()['config'];
$run_at = $config->get('plugins.git-sync.sync.cron_at', '0 12,23 * * *');
if ($config->get('plugins.git-sync.sync.cron_enable', false)) {
/** @var Scheduler $scheduler */
$scheduler = $event['scheduler'];
$job = $scheduler->addFunction('Grav\Plugin\GitSync\Helper::synchronize', [], 'GitSync');
$job->at($run_at);
}
}
/**
* @return bool
*/
public function reset()
{
// Skip if git-sync is not properly configured
if (!Helper::isGitSyncReady()) {
return true;
}
$this->grav->fireEvent('onGitSyncBeforeReset');
$this->git->reset();
$this->grav->fireEvent('onGitSyncAfterReset');
return true;
}
/**
* Add current directory to twig lookup paths.
*/
public function onTwigTemplatePaths()
{
$this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
}
/**
* Set needed variables to display cart.
*
* @return bool
*/
public function onTwigSiteVariables()
{
// workaround for admin plugin issue that doesn't properly unsubscribe events upon plugin uninstall
if (!class_exists(Helper::class)) {
return false;
}
$user = $this->grav['user'];
if (!$user->authenticated) {
return false;
}
$settings = [
'first_time' => !Helper::isGitInitialized(),
'git_installed' => Helper::isGitInstalled()
];
$this->grav['twig']->twig_vars['git_sync'] = $settings;
$adminPath = trim($this->grav['admin']->base, '/');
if ($this->grav['uri']->path() === "/$adminPath/plugins/git-sync") {
$this->grav['assets']->addCss('plugin://git-sync/css-compiled/git-sync.css');
} else {
$this->grav['assets']->addInlineJs('var GitSync = ' . json_encode($settings) . ';');
}
$this->grav['assets']->addJs('plugin://git-sync/js/vendor.js', ['loading' => 'defer', 'priority' => 0]);
$this->grav['assets']->addJs('plugin://git-sync/js/app.js', ['loading' => 'defer', 'priority' => 0]);
$this->grav['assets']->addCss('plugin://git-sync/css-compiled/git-sync-icon.css');
return true;
}
public function onPageInitialized()
{
if ($this->controller && $this->controller->isActive()) {
$this->controller->execute();
$this->controller->redirect();
}
}
/**
* @param Event $event
* @return Data|true
*/
public function onAdminSave(Event $event)
{
$obj = $event['object'];
$adminPath = trim($this->grav['admin']->base, '/');
$isPluginRoute = $this->grav['uri']->path() === "/$adminPath/plugins/" . $this->name;
if ($obj instanceof Data) {
if (!$isPluginRoute || !Helper::isGitInstalled()) {
return true;
}
// empty password, keep current one or encrypt if haven't already
$password = $obj->get('password', false);
if (!$password) { // set to !()
$current_password = $this->git->getPassword();
// password exists but was never encrypted
if ($current_password && strpos($current_password, 'gitsync-') !== 0) {
$current_password = Helper::encrypt($current_password);
}
} else {
// password is getting changed
$current_password = Helper::encrypt($password);
}
$obj->set('password', $current_password);
}
return $obj;
}
/**
* @param Event $event
*/
public function onAdminAfterSave(Event $event)
{
$obj = $event['object'];
$adminPath = trim($this->grav['admin']->base, '/');
$uriPath = $this->grav['uri']->path();
$isPluginRoute = $uriPath === "/$adminPath/plugins/" . $this->name;
if ($obj instanceof PageInterface && !$this->grav['config']->get('plugins.git-sync.sync.on_save', true)) {
return;
}
if ($obj instanceof Data) {
$folders = $this->git->getConfig('folders', $event['object']->get('folders', []));
$data_type = preg_replace('#^/' . preg_quote($adminPath, '#') . '/#', '', $uriPath);
$data_type = explode('/', $data_type);
$data_type = array_shift($data_type);
if (null === $data_type || !Helper::isGitInstalled() || (!$isPluginRoute && !in_array($this->getFolderMapping($data_type), $folders, true))) {
return;
}
if ($isPluginRoute) {
$this->git->setConfig($obj->toArray());
// Only initialize repository if properly configured
if (Helper::isGitSyncConfigured()) {
// initialize git if not done yet
$this->git->initializeRepository();
// set committer and remote data
$this->git->setUser();
$this->git->addRemote();
}
}
}
$this->synchronize();
}
public function onAdminAfterSaveAs()
{
if ($this->grav['config']->get('plugins.git-sync.sync.on_save', true))
{
$this->synchronize();
}
}
public function onAdminAfterDelete()
{
if ($this->grav['config']->get('plugins.git-sync.sync.on_delete', true))
{
$this->synchronize();
}
}
public function onAdminAfterMedia()
{
if ($this->grav['config']->get('plugins.git-sync.sync.on_media', true))
{
$this->synchronize();
}
}
/**
* @param Event $event
*/
public function onFormProcessed(Event $event)
{
$action = $event['action'];
if ($action === 'gitsync') {
$this->synchronize();
}
}
/**
* @param string $data_type
* @return string|null
*/
public function getFolderMapping($data_type)
{
switch ($data_type) {
case 'user':
return 'accounts';
case 'themes':
return 'config';
case 'config':
case 'data':
case 'plugins':
case 'pages':
return $data_type;
}
return null;
}
/**
* @param int $len
* @return string
*/
protected static function generateHash(int $len): string
{
$bytes = openssl_random_pseudo_bytes($len, $isStrong);
if ($bytes === false) {
throw new \RuntimeException('Could not generate hash');
}
if ($isStrong === false) {
// It's ok not to be strong [EA].
$isStrong = true;
}
return bin2hex($bytes);
}
}
+3
View File
@@ -0,0 +1,3 @@
enabled: true
folders:
- pages
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
gosass -input scss/ -output css/ -sourcemap -watch -style compressed
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="bitbucket" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="863.566px" height="267.72px" viewBox="0 0 863.566 267.72" style="enable-background:new 0 0 863.566 267.72;"
xml:space="preserve">
<style type="text/css">
.st0{fill:#205081;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M432.851,136.489c-9.223,0-18.545,2.275-24.859,4.641v-34.558c0-1.738-1.414-3.146-3.15-3.146h-18.628
c-1.736,0-3.13,1.408-3.13,3.146v119.01c0,1.51,1.086,2.826,2.577,3.092c11.492,2.096,25.048,2.83,33.187,2.83
c34.984,0,42.139-14.87,42.139-37.211v-26.049C460.984,147.162,451.519,136.489,432.851,136.489z M419.926,209.871
c-4.717,0-8.624-0.182-11.935-0.571v-46.559c5.13-2.299,12.346-4.616,18.132-4.616c7.058,0,9.938,2.766,9.938,9.568v27.313
C436.061,204.862,433.568,209.871,419.926,209.871z"/>
<path class="st0" d="M546.674,138.31H528.07c-1.736,0-3.146,1.413-3.146,3.127v58.95c-6.71,3.401-16.155,6.929-21.777,6.929
c-4.193,0-5.742-1.549-5.742-5.746v-60.133c0-1.714-1.432-3.127-3.19-3.127h-18.583c-1.762,0-3.154,1.413-3.154,3.127v62.509
c0,18.283,7.195,27.559,21.369,27.559c9.872,0,23.308-3.392,34.652-8.726l1.372,4.645c0.409,1.344,1.617,2.265,3.028,2.265
l13.775-0.004c1.741,0,3.153-1.412,3.153-3.147v-85.1C549.827,139.723,548.415,138.31,546.674,138.31z"/>
<path class="st0" d="M621.62,210.423c-0.121-0.877-0.633-1.647-1.33-2.147c-0.732-0.489-1.636-0.653-2.493-0.46
c-5.337,1.216-10.939,1.874-16.195,1.874c-11.798,0-15.213-3.005-15.213-13.394v-24.6c0-10.397,3.415-13.402,15.213-13.402
c2.806,0,9.794,0.403,16.195,1.869c0.857,0.196,1.761,0.034,2.493-0.471c0.719-0.484,1.227-1.261,1.33-2.142l2.212-14.727
c0.22-1.552-0.694-3.03-2.212-3.474c-7.175-2.112-15.583-2.861-20.549-2.861c-28.132,0-39.604,10.246-39.604,35.392v24.231
c0,25.137,11.472,35.392,39.604,35.392c6.826,0,14.7-1.096,20.549-2.855c1.518-0.449,2.432-1.938,2.212-3.483L621.62,210.423z"
/>
<path class="st0" d="M749.865,136.489c-24.967,0-38.149,12.434-38.149,35.941v23.499c0,24.265,12.364,35.574,38.889,35.574
c10.792,0,22.304-1.892,31.566-5.171c1.472-0.53,2.353-2.075,2.045-3.621l-2.761-13.316c-0.184-0.855-0.716-1.61-1.452-2.05
c-0.755-0.459-1.655-0.58-2.491-0.337c-8.567,2.414-16.687,3.597-24.907,3.597c-13.556,0-15.97-4.393-15.97-13.587v-3.217
h46.213c1.755,0,3.148-1.393,3.148-3.156v-17.13C785.995,148.248,774.524,136.489,749.865,136.489z M736.635,173.262v-3.019
c0-8.77,4.279-12.857,13.419-12.857c10.1,0,12.117,4.928,12.117,12.857v3.019H736.635z"/>
<path class="st0" d="M302.116,138.269H283.51c-1.739,0-3.154,1.418-3.154,3.148v85.085c0,1.745,1.415,3.152,3.154,3.152h18.605
c1.779,0,3.168-1.407,3.168-3.152v-85.085C305.284,139.687,303.895,138.269,302.116,138.269z"/>
<path class="st0" d="M302.116,103.416H283.51c-1.739,0-3.154,1.428-3.154,3.157v16.029c0,1.763,1.415,3.159,3.154,3.159h18.605
c1.779,0,3.168-1.396,3.168-3.159v-16.029C305.284,104.844,303.895,103.416,302.116,103.416z"/>
<g>
<path class="st0" d="M255.251,164.776c7.711-4.242,11.059-10.188,11.059-19.331v-12.513c0-18.323-10.995-27.217-33.572-27.217
h-45.939c-1.721,0-3.133,1.408-3.133,3.145v117.642c0,1.745,1.412,3.152,3.133,3.152h49.213
c21.917,0,34.493-10.643,34.493-29.211v-12.922C270.504,176.444,264.881,168.172,255.251,164.776z M209.308,127.347h20.691
c9.366,0,10.676,4.087,10.676,8.463v10.721c0,6.972-3.255,9.95-10.86,9.95h-4.474c-1.762,0-3.174,1.417-3.174,3.157v14.781
c0,1.751,1.412,3.161,3.174,3.161h8.093c8.119,0,11.413,3.14,11.413,10.849v9.097c0,7.843-3.374,10.488-13.415,10.488h-22.122
V127.347z"/>
</g>
<path class="st0" d="M372.022,211.568c-0.123-0.886-0.612-1.666-1.347-2.166c-0.741-0.489-1.659-0.652-2.518-0.433
c-3.291,0.82-6.441,1.268-8.872,1.268c-4.602,0-6.646-2.003-6.646-6.476v-45.085h18.074c1.74,0,3.148-1.408,3.148-3.151v-14.069
c0-1.734-1.408-3.148-3.148-3.148h-18.074v-21.676c0-0.917-0.41-1.779-1.083-2.372c-0.694-0.606-1.617-0.88-2.52-0.749
l-18.605,2.59c-1.554,0.225-2.713,1.556-2.713,3.129v19.078h-10.145c-1.737,0-3.152,1.413-3.152,3.148v14.069
c0,1.743,1.415,3.151,3.152,3.151h10.145v48.007c0,16.472,8.646,24.835,25.738,24.835c4.868,0,13.128-1.127,18.526-2.999
c1.451-0.499,2.311-1.938,2.085-3.454L372.022,211.568z"/>
<path class="st0" d="M847.374,211.564c-0.122-0.886-0.632-1.666-1.373-2.168c-0.733-0.487-1.657-0.652-2.493-0.427
c-3.314,0.816-6.441,1.268-8.893,1.268c-4.582,0-6.665-2.009-6.665-6.481v-45.085h18.096c1.757,0,3.144-1.417,3.144-3.146
v-14.069c0-1.734-1.387-3.149-3.144-3.149H827.95v-21.68c0-0.919-0.368-1.78-1.066-2.371c-0.695-0.603-1.613-0.877-2.514-0.745
l-18.607,2.59c-1.553,0.22-2.722,1.551-2.722,3.113v19.093h-10.138c-1.722,0-3.147,1.414-3.147,3.149v14.069
c0,1.729,1.426,3.146,3.147,3.146h10.138v48.013c0,16.466,8.671,24.829,25.746,24.829c4.887,0,13.125-1.126,18.524-2.997
c1.433-0.501,2.326-1.934,2.104-3.454L847.374,211.564z"/>
</g>
</g>
</g>
</g>
<path class="st0" d="M708.825,224.391l-24.88-41.074l24.001-40.449c0.578-0.975,0.589-2.184,0.028-3.166
c-0.561-0.984-1.605-1.592-2.737-1.592h-20.553c-1.126,0-2.166,0.602-2.729,1.576l-22.832,39.584v-72.678
c0-1.741-1.411-3.15-3.15-3.15h-18.606c-1.739,0-3.15,1.409-3.15,3.15v119.911c0,1.74,1.411,3.15,3.15,3.15h18.606
c1.739,0,3.15-1.41,3.15-3.15v-39.244l24.295,40.654c0.568,0.951,1.596,1.535,2.704,1.535h20.173c0.006,0,0.014,0,0.02,0
c1.74,0,3.15-1.412,3.15-3.15C709.466,225.58,709.228,224.92,708.825,224.391z"/>
<path class="st0" d="M89.219,94.963v0.002V94.963c-33.675,0-61.172,9.053-61.172,20.294c0,2.961,7.343,45.415,10.256,62.251
c1.306,7.551,20.818,18.621,50.9,18.621l0.031-0.09v0.09c30.081,0,49.593-11.07,50.899-18.621
c2.913-16.836,10.256-59.29,10.256-62.251C150.39,104.017,122.894,94.963,89.219,94.963z M89.219,182.49
c-10.739,0-19.445-8.707-19.445-19.445c0-10.74,8.706-19.445,19.445-19.445c10.739,0,19.445,8.705,19.445,19.445
C108.664,173.783,99.958,182.49,89.219,182.49z M89.208,121.53c-21.636-0.035-39.169-3.794-39.162-8.398
c0.007-4.604,17.554-8.307,39.19-8.272c21.636,0.034,39.169,3.793,39.162,8.398C128.39,117.863,110.844,121.564,89.208,121.53z"/>
<path class="st0" d="M133.185,194.382c-0.93,0-1.675,0.658-1.675,0.658s-15.064,11.929-42.29,11.93
c-27.227-0.001-42.29-11.93-42.29-11.93s-0.746-0.658-1.675-0.658c-1.111,0-2.164,0.746-2.164,2.393
c0,0.174,0.017,0.348,0.049,0.518c2.338,12.514,4.046,21.393,4.346,22.744c2.041,9.205,20.048,16.151,41.733,16.152l0,0h0.001h0.001
l0,0c21.686-0.001,39.692-6.947,41.733-16.152c0.3-1.352,2.008-10.23,4.346-22.744c0.032-0.17,0.049-0.344,0.049-0.518
C135.349,195.128,134.295,194.382,133.185,194.382z"/>
<circle class="st0" cx="89.2" cy="163.039" r="9.745"/>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 2002 542" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g transform="matrix(1,0,0,1,-199.492,-929.431)">
<g id="Layer-1" transform="matrix(4.16667,0,0,4.16667,0,0)">
<path d="M140.375,278.65L100.019,278.65C98.978,278.65 98.133,279.495 98.133,280.537L98.133,300.267C98.133,301.308 98.978,302.156 100.019,302.156L115.762,302.156L115.762,326.67C115.762,326.67 112.227,327.875 102.454,327.875C90.924,327.875 74.817,323.662 74.817,288.243C74.817,252.818 91.589,248.157 107.335,248.157C120.965,248.157 126.837,250.556 130.573,251.713C131.747,252.072 132.833,250.903 132.833,249.862L137.335,230.798C137.335,230.31 137.17,229.723 136.614,229.325C135.097,228.242 125.84,223.063 102.454,223.063C75.513,223.063 47.878,234.525 47.878,289.625C47.878,344.726 79.518,352.937 106.18,352.937C128.256,352.937 141.648,343.504 141.648,343.504C142.2,343.199 142.26,342.427 142.26,342.074L142.26,280.537C142.26,279.495 141.416,278.65 140.375,278.65" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
<path d="M348.353,229.665C348.353,228.615 347.521,227.768 346.48,227.768L323.757,227.768C322.719,227.768 321.875,228.615 321.875,229.665C321.875,229.67 321.881,273.578 321.881,273.578L286.462,273.578L286.462,229.665C286.462,228.615 285.626,227.768 284.587,227.768L261.866,227.768C260.832,227.768 259.989,228.615 259.989,229.665L259.989,348.568C259.989,349.617 260.832,350.471 261.866,350.471L284.587,350.471C285.626,350.471 286.462,349.617 286.462,348.568L286.462,297.709L321.881,297.709C321.881,297.709 321.82,348.564 321.82,348.568C321.82,349.617 322.663,350.471 323.702,350.471L346.478,350.471C347.519,350.471 348.351,349.617 348.353,348.568L348.353,229.665Z" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
<path d="M183.254,245.268C183.254,237.086 176.694,230.475 168.601,230.475C160.516,230.475 153.951,237.086 153.951,245.268C153.951,253.442 160.516,260.072 168.601,260.072C176.694,260.072 183.254,253.442 183.254,245.268" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
<path d="M181.629,323.486L181.629,268.6C181.629,267.558 180.788,266.706 179.749,266.706L157.098,266.706C156.059,266.706 155.129,267.778 155.129,268.819L155.129,347.455C155.129,349.765 156.569,350.453 158.433,350.453L178.841,350.453C181.08,350.453 181.629,349.353 181.629,347.418L181.629,323.486Z" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
<path d="M434.71,266.885L412.161,266.885C411.127,266.885 410.285,267.738 410.285,268.786L410.285,327.088C410.285,327.088 404.557,331.28 396.426,331.28C388.296,331.28 386.138,327.59 386.138,319.629L386.138,268.786C386.138,267.738 385.298,266.885 384.263,266.885L361.378,266.885C360.345,266.885 359.499,267.738 359.499,268.786L359.499,323.479C359.499,347.125 372.678,352.91 390.808,352.91C405.681,352.91 417.673,344.694 417.673,344.694C417.673,344.694 418.244,349.024 418.502,349.537C418.761,350.049 419.434,350.567 420.161,350.567L434.72,350.502C435.753,350.502 436.599,349.649 436.599,348.604L436.592,268.786C436.592,267.738 435.749,266.885 434.71,266.885" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
<path d="M487.445,331.207C479.624,330.969 474.319,327.42 474.319,327.42L474.319,289.766C474.319,289.766 479.552,286.558 485.973,285.984C494.093,285.257 501.918,287.71 501.918,307.08C501.918,327.506 498.386,331.537 487.445,331.207M496.339,264.214C483.532,264.214 474.821,269.928 474.821,269.928L474.821,229.665C474.821,228.615 473.982,227.768 472.946,227.768L450.159,227.768C449.123,227.768 448.281,228.615 448.281,229.665L448.281,348.568C448.281,349.617 449.123,350.471 450.162,350.471L465.971,350.471C466.683,350.471 467.222,350.103 467.621,349.461C468.013,348.822 468.581,343.978 468.581,343.978C468.581,343.978 477.898,352.809 495.538,352.809C516.246,352.809 528.122,342.305 528.122,305.654C528.122,269.003 509.155,264.214 496.339,264.214" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
<path d="M246.935,266.695L229.891,266.695C229.891,266.695 229.866,244.182 229.866,244.177C229.866,243.324 229.426,242.899 228.44,242.899L205.212,242.899C204.309,242.899 203.825,243.296 203.825,244.164L203.825,267.433C203.825,267.433 192.184,270.243 191.397,270.47C190.615,270.698 190.038,271.421 190.038,272.283L190.038,286.905C190.038,287.957 190.877,288.805 191.915,288.805L203.825,288.805L203.825,323.984C203.825,350.113 222.154,352.679 234.521,352.679C240.171,352.679 246.932,350.865 248.047,350.453C248.723,350.205 249.116,349.506 249.116,348.748L249.134,332.662C249.134,331.613 248.248,330.763 247.249,330.763C246.256,330.763 243.716,331.168 241.099,331.168C232.728,331.168 229.891,327.275 229.891,322.236C229.891,317.201 229.89,288.805 229.89,288.805L246.935,288.805C247.974,288.805 248.817,287.957 248.817,286.905L248.817,268.59C248.817,267.54 247.974,266.695 246.935,266.695" style="fill:rgb(14,13,13);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+77
View File
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1202px" height="455px" viewBox="0 0 1202 455" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->
<title>wm_no_bg</title>
<desc>Created with Sketch.</desc>
<defs>
<path id="path-1" d="M0,1173.3333 L1999.99995,1173.3333 L1999.99995,0 L0,0 L0,1173.3333 L0,1173.3333 Z"></path>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="gitlab_logo" sketch:type="MSLayerGroup" transform="translate(-439.000000, -360.000000)">
<g id="g10" transform="translate(1000.000000, 587.333300) scale(1, -1) translate(-1000.000000, -587.333300) translate(0.000000, 0.333300)">
<g id="g12" transform="translate(1316.014500, 505.244121)" fill="#8C929D" sketch:type="MSShapeGroup">
<path d="M22.6666661,162.666663 L0.835999979,162.666663 L0.905333311,0.178666662 L89.2199978,0.178666662 L89.2199978,20.2719995 L22.7359994,20.2719995 L22.6666661,162.666663 L22.6666661,162.666663 Z" id="path14"></path>
</g>
<g id="g16">
<g id="g18-Clipped">
<mask id="mask-2" sketch:name="path22" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="path22"></g>
<g id="g18" mask="url(#mask-2)">
<g transform="translate(438.666658, 358.666658)">
<g id="g24" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(977.327440, 143.284396)">
<path d="M73.3333315,31.9999992 C67.8759983,26.294666 58.6973319,20.5879995 46.2933322,20.5879995 C29.6719993,20.5879995 22.9746661,28.7746659 22.9746661,39.4426657 C22.9746661,55.5666653 34.1373325,63.2573318 57.9533319,63.2573318 C62.4186651,63.2573318 69.6119983,62.7613318 73.3333315,62.0173318 L73.3333315,31.9999992 L73.3333315,31.9999992 Z M50.7586654,130.48533 C33.1293325,130.48533 16.9599996,124.235997 4.34266656,113.83333 L12.0586664,100.467997 C20.9893328,105.677331 31.9053325,110.887997 47.5333321,110.887997 C65.394665,110.887997 73.3333315,101.707997 73.3333315,86.3279978 L73.3333315,78.3893314 C69.8599983,79.1333314 62.6666651,79.6306647 58.2013319,79.6306647 C19.9973328,79.6306647 0.647999984,66.234665 0.647999984,38.2013324 C0.647999984,13.1466663 16.0279996,0.494666654 39.3466657,0.494666654 C55.0546653,0.494666654 70.1079982,7.68933314 75.3173315,19.3479995 L79.2866647,3.47199991 L94.6679976,3.47199991 L94.6679976,86.5759978 C94.6679976,112.871997 83.2559979,130.48533 50.7586654,130.48533 L50.7586654,130.48533 Z" id="path26" fill="#8C929D" sketch:type="MSShapeGroup"></path>
</g>
<g id="g28" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(1099.766904, 143.128930)">
<path d="M42.6666656,19.9999995 C34.4799991,19.9999995 27.2853327,20.9919995 21.8279995,23.4733327 L21.8279995,90.8253311 L21.8279995,98.6386642 C29.2706659,104.841331 38.4493324,109.306664 50.1093321,109.306664 C71.1946649,109.306664 79.3813313,94.4226643 79.3813313,70.3586649 C79.3813313,36.1253324 66.2333317,19.9999995 42.6666656,19.9999995 M51.841332,130.64133 C32.3306659,130.64133 21.8279995,117.367997 21.8279995,117.367997 L21.8279995,138.330663 L21.7586661,166.114663 L11.9159997,166.114663 L0.425333323,166.114663 L0.494666654,7.59599981 C11.1613331,3.13066659 25.7973327,0.65066665 41.6746656,0.65066665 C82.3586646,0.65066665 101.955997,26.6973327 101.955997,71.5986649 C101.955997,107.073331 83.8426646,130.64133 51.841332,130.64133" id="path30" fill="#8C929D" sketch:type="MSShapeGroup"></path>
</g>
<g id="g32" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(584.042117, 143.630796)">
<path d="M78.6666647,147.999996 C98.0159975,147.999996 110.419997,141.550663 118.606664,135.099997 L127.987997,151.350663 C115.20133,162.563996 98.0026642,168.590662 79.6586647,168.590662 C33.2693325,168.590662 0.771999981,140.30933 0.771999981,83.2533313 C0.771999981,23.4666661 35.8306658,0.147999996 75.9373314,0.147999996 C96.0319976,0.147999996 113.149331,4.86133321 124.311997,9.57466643 L123.854664,73.4533315 L123.854664,80.9893313 L123.854664,93.5479977 L64.3173317,93.5479977 L64.3173317,73.4533315 L102.271997,73.4533315 L102.729331,24.9559994 C97.7679976,22.4746661 89.0853311,20.4906662 77.4266647,20.4906662 C45.1773322,20.4906662 23.5946661,40.7773323 23.5946661,83.5013312 C23.5946661,126.91333 45.9213322,147.999996 78.6666647,147.999996" id="path34" fill="#8C929D" sketch:type="MSShapeGroup"></path>
</g>
<g id="g36" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(793.569045, 142.577463)">
<path d="M22.6666661,166.666662 L1.33199997,166.666662 L1.4013333,139.378663 L1.4013333,128.214663 L1.4013333,121.707997 L1.4013333,110.353331 L1.4013333,45.3586655 L1.4013333,45.1106655 C1.4013333,18.8146662 12.813333,1.2013333 45.3106655,1.2013333 C49.7999988,1.2013333 54.193332,1.60933329 58.4586652,2.38399994 L58.4586652,21.5426661 C55.3706653,21.0693328 52.0746654,20.7999995 48.5359988,20.7999995 C30.6746659,20.7999995 22.7359994,29.9786659 22.7359994,45.3586655 L22.7359994,110.353331 L58.4586652,110.353331 L58.4586652,128.214663 L22.7359994,128.214663 L22.6666661,166.666662 L22.6666661,166.666662 Z" id="path38" fill="#8C929D" sketch:type="MSShapeGroup"></path>
</g>
<path d="M740.766646,146.755996 L762.101312,146.755996 L762.101312,270.793327 L740.766646,270.793327 L740.766646,146.755996 L740.766646,146.755996 Z" id="path40" fill="#8C929D" sketch:type="MSShapeGroup"></path>
<path d="M740.766646,287.909326 L762.101312,287.909326 L762.101312,309.243992 L740.766646,309.243992 L740.766646,287.909326 L740.766646,287.909326 Z" id="path42" fill="#8C929D" sketch:type="MSShapeGroup"></path>
<g id="g44" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(0.532000, 0.774933)">
<path d="M491.999988,194.666662 L464.441322,279.481326 L409.82399,447.578655 C407.014656,456.226655 394.778657,456.226655 391.96799,447.578655 L337.349325,279.481326 L155.982663,279.481326 L101.362664,447.578655 C98.5533309,456.226655 86.3173312,456.226655 83.5066646,447.578655 L28.8893326,279.481326 L1.33199997,194.666662 C-1.18266664,186.930662 1.57199996,178.455996 8.1519998,173.674662 L246.665327,0.385333324 L485.179988,173.674662 C491.759988,178.455996 494.513321,186.930662 491.999988,194.666662" id="path46" fill="#FC6D26" sketch:type="MSShapeGroup"></path>
</g>
<g id="g48" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(155.197863, 1.160267)">
<path d="M91.9999977,0 L91.9999977,0 L182.683995,279.095993 L1.31599997,279.095993 L91.9999977,0 L91.9999977,0 Z" id="path50" fill="#E24329" sketch:type="MSShapeGroup"></path>
</g>
<g id="g52" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(247.197860, 1.160267)">
<g id="path54"></g>
</g>
<g id="g56" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(28.531199, 1.160800)">
<path d="M218.666661,0 L127.982663,279.09466 L0.890666644,279.09466 L218.666661,0 L218.666661,0 Z" id="path58" fill="#FC6D26" sketch:type="MSShapeGroup"></path>
</g>
<g id="g60" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(247.197860, 1.160800)">
<g id="path62"></g>
</g>
<g id="g64" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(0.088533, 0.255867)">
<path d="M29.3333326,279.999993 L29.3333326,279.999993 L1.77466662,195.185328 C-0.738666648,187.449329 2.01466662,178.974662 8.59599979,174.194662 L247.109327,0.905333311 L29.3333326,279.999993 L29.3333326,279.999993 Z" id="path66" fill="#FCA326" sketch:type="MSShapeGroup"></path>
</g>
<g id="g68" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(247.197860, 1.160267)">
<g id="path70"></g>
</g>
<g id="g72" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(29.421866, 280.255593)">
<path d="M0,0 L127.091997,0 L72.4733315,168.097329 C69.6626649,176.746662 57.4266652,176.746662 54.617332,168.097329 L0,0 L0,0 Z" id="path74" fill="#E24329" sketch:type="MSShapeGroup"></path>
</g>
<g id="g76" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(247.197860, 1.160800)">
<path d="M0,0 L90.6839977,279.09466 L217.775995,279.09466 L0,0 L0,0 Z" id="path78" fill="#FC6D26" sketch:type="MSShapeGroup"></path>
</g>
<g id="g80" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(246.307061, 0.255867)">
<path d="M218.666661,279.999993 L218.666661,279.999993 L246.225327,195.185328 C248.73866,187.449329 245.985327,178.974662 239.403994,174.194662 L0.890666644,0.905333311 L218.666661,279.999993 L218.666661,279.999993 Z" id="path82" fill="#FCA326" sketch:type="MSShapeGroup"></path>
</g>
<g id="g84" stroke-width="1" fill="none" sketch:type="MSLayerGroup" transform="translate(336.973725, 280.255593)">
<path d="M127.999997,0 L0.907999977,0 L55.5266653,168.097329 C58.3373319,176.746662 70.5733316,176.746662 73.3826648,168.097329 L127.999997,0 L127.999997,0 Z" id="path86" fill="#E24329" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 272.96 114.01"
version="1.1"
id="svg10"
sodipodi:docname="gitlogo.svg"
inkscape:version="0.92.0 r15299">
<metadata
id="metadata16">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs14" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1070"
inkscape:window-height="735"
id="namedview12"
showgrid="false"
inkscape:zoom="1.0807445"
inkscape:cx="115.66102"
inkscape:cy="55.154427"
inkscape:window-x="2350"
inkscape:window-y="328"
inkscape:window-maximized="0"
inkscape:current-layer="svg10" />
<path
fill="#f05133"
d="M111.78,51.977,62.035,2.2381c-2.8622-2.8648-7.5082-2.8648-10.374,0l-10.329,10.33,13.102,13.102c3.0459-1.0284,6.5371-0.33888,8.9639,2.0884,2.4394,2.4424,3.124,5.9634,2.0698,9.0195l12.628,12.628c3.0551-1.0528,6.58-0.37262,9.0195,2.0712,3.4106,3.4096,3.4106,8.9345,0,12.345-3.4111,3.4116-8.936,3.4116-12.349,0-2.5645-2.5665-3.1988-6.3345-1.8999-9.4942l-11.777-11.777-0.001,30.991c0.8315,0.41162,1.6162,0.961,2.3091,1.6509,3.4096,3.4092,3.4096,8.9331,0,12.348-3.4106,3.4091-8.938,3.4091-12.345,0-3.4101-3.4146-3.4101-8.9385,0-12.348,0.84275-0.84125,1.8179-1.478,2.8584-1.9048v-31.279c-1.041-0.425-2.015-1.057-2.859-1.905-2.583-2.581-3.2051-6.372-1.8804-9.5439l-12.916-12.918-34.106,34.105c-2.8657,2.867-2.8657,7.513,0,10.378l49.742,49.739c2.8638,2.8648,7.5082,2.8648,10.376,0l49.512-49.504c2.8648-2.8662,2.8648-7.5136,0-10.379"
id="path8" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:46.82492828px;line-height:27.43648148px;font-family:'Helvetica Rounded LT Std';-inkscape-font-specification:'Helvetica Rounded LT Std';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#999999;fill-opacity:1;stroke:none;stroke-width:1.0974592px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;"
x="128.30931"
y="69.483566"
id="text20"
transform="scale(0.9595033,1.0422059)"><tspan
sodipodi:role="line"
id="tspan18"
x="128.30931"
y="69.483566"
style="fill:#999999;stroke-width:1.0974592px;font-family: 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif;font-weight: 300">Others</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
{
"name": "trilby-grav-plugin-gitsync",
"version": "2.1.0",
"description": "Git Sync Grav Plugin",
"main": "app/main.js",
"scripts": {
"watch": "webpack --watch --progress --color --mode development --config webpack.conf.js",
"dev": "webpack --progress --mode development --colors --config webpack.conf.js",
"prod": "webpack --mode production --config webpack.conf.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/trilbymedia/grav-plugin-git-sync.git"
},
"author": "Trilby Media, LLC",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/trilbymedia/grav-plugin-git-sync/issues"
},
"homepage": "https://github.com/trilbymedia/grav-plugin-git-sync#readme",
"devDependencies": {
"@babel/core": "^7.14.3",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-json-strings": "^7.14.2",
"@babel/plugin-proposal-object-rest-spread": "^7.14.4",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-import-meta": "^7.10.4",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.14.4",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.2",
"css-loader": "^5.2.6",
"eslint": "^7.27.0",
"eslint-loader": "^4.0.2",
"exports-loader": "^3.0.0",
"imports-loader": "^3.0.0",
"json-loader": "^0.5.7",
"style-loader": "^2.0.0",
"uglifyjs-webpack-plugin": "^2.2.0",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.0",
"whatwg-fetch": "^3.6.2"
}
}
+18
View File
@@ -0,0 +1,18 @@
actions:
api.git-sync:
type: action
label: Git Sync
description: Permissions for the Git Sync plugin via the admin-next API.
actions:
api.git-sync.admin:
type: action
label: Admin
description: Full administrative access to Git Sync (read, write, run actions).
api.git-sync.write:
type: action
label: Write
description: Save settings, run synchronize / reset / test-connection.
api.git-sync.read:
type: action
label: Read
description: View Git Sync configuration and wizard state.
@@ -0,0 +1,8 @@
$page-background: #f7f7f7;
$content-text: #666;
$header-background: $page-background;
$calendar-text: $content-text;
$calendar-details-background: #dcdcdc;
$calendar-details-background-alt: #cecece;
$calendar-day-background: #ffffff;
$toast-danger-background: #C62828;
+7
View File
@@ -0,0 +1,7 @@
// Vendors
@import "vendor/bourbon/bourbon";
// Load Template Configuration
@import "configuration/colors";
@import "plugin/wizard";
+191
View File
@@ -0,0 +1,191 @@
[data-remodal-id="wizard"], [data-remodal-id="reset-local"] {
.button[disabled] {
&, &:hover {
background: #ccc;
cursor: default;
}
&:active {
margin: 0;
}
}
form [class^="step-"] a:not(.button):not([target]) {
vertical-align: middle;
color: #5591c7;
&:hover {
color: #366188;
}
}
.step-1 a {
vertical-align: middle;
}
.access-tokens {
h3 {
margin-top: 1rem;
}
a {
vertical-align: inherit !important;
}
}
.access-tokens-details {
> p {
margin-top: 0;
}
> div > ul {
margin-bottom: 0;
}
}
.center {
text-align: center;
}
h1 {
margin-bottom: 0;
padding-top: 0.5rem;
border-top: 3px solid transparent;
}
.wizard-padding {
padding: 0 3rem;
p {
padding: 0;
}
}
input[disabled] {
background: #efefef;
border-color: #ddd;
}
input.invalid {
border-color: #f4516d;
color: #f4516d;
}
label {
&.disabled {
color: #ccc;
}
img {
max-width: 100px;
display: inline-block;
vertical-align: middle;
margin-left: 0.5rem;
}
a {
margin-left: 0.5rem;
}
}
.columns {
display: flex;
align-items: center;
justify-content: center;
.column {
flex: 1;
&:first-child {
width: 35%;
flex: none;
border-right: 1px solid #ddd;
margin-right: 2rem;
}
}
}
.step-1 {
div.column:nth-child(1) {
flex-grow: 1.5;
width: 15%;
> label:nth-child(4) {
width: 110%;
}
}
div.column:nth-child(2) {
margin-left: 25px;
}
div.column:nth-child(2) > label:nth-child(1) > input:nth-child(1), div.column:nth-child(2) > label:nth-child(2) > input:nth-child(1) {
width: 100%;
}
.no_user {
float: right;
font-size: .8rem;
padding: 0;
input {
margin-right: 0;
}
}
}
.step-2 {
.info {
margin: 0.2rem 0;
padding: 0.5rem 1.5rem;
}
ol, ul {
padding-left: 1rem;
}
}
.step-4 {
.info {
font-size: 100%;
margin: 0.2rem 0;
}
.alert, .warning {
padding: 0.5rem 1.5rem;
}
.fa.fa-warning {
color: #ff7d3b;
font-size: 1.2rem;
}
.wizard-padding label > * {
vertical-align: middle;
}
.info-desc {
color: #0091ff;
float: right;
margin-right: .5rem;
font-size: 1.2rem;
}
hr {
margin: .5rem 0;
}
}
}
#admin-main [data-grav-field="git-wizard"] {
margin: 0 1rem;
.danger.button-bar {
margin: 0;
padding: 0;
background: transparent;
}
.button {
top: 0 !important;
@include transform(translateY(0) !important);
}
}
@@ -0,0 +1,411 @@
// The following features have been deprecated and will be removed in the next MAJOR version release
@mixin inline-block {
display: inline-block;
@warn "The inline-block mixin is deprecated and will be removed in the next major version release";
}
@mixin button ($style: simple, $base-color: #4294f0, $text-size: inherit, $padding: 7px 18px) {
@if type-of($style) == string and type-of($base-color) == color {
@include buttonstyle($style, $base-color, $text-size, $padding);
}
@if type-of($style) == string and type-of($base-color) == number {
$padding: $text-size;
$text-size: $base-color;
$base-color: #4294f0;
@if $padding == inherit {
$padding: 7px 18px;
}
@include buttonstyle($style, $base-color, $text-size, $padding);
}
@if type-of($style) == color and type-of($base-color) == color {
$base-color: $style;
$style: simple;
@include buttonstyle($style, $base-color, $text-size, $padding);
}
@if type-of($style) == color and type-of($base-color) == number {
$padding: $text-size;
$text-size: $base-color;
$base-color: $style;
$style: simple;
@if $padding == inherit {
$padding: 7px 18px;
}
@include buttonstyle($style, $base-color, $text-size, $padding);
}
@if type-of($style) == number {
$padding: $base-color;
$text-size: $style;
$base-color: #4294f0;
$style: simple;
@if $padding == #4294f0 {
$padding: 7px 18px;
}
@include buttonstyle($style, $base-color, $text-size, $padding);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@warn "The button mixin is deprecated and will be removed in the next major version release";
}
// Selector Style Button
@mixin buttonstyle($type, $b-color, $t-size, $pad) {
// Grayscale button
@if $type == simple and $b-color == grayscale($b-color) {
@include simple($b-color, true, $t-size, $pad);
}
@if $type == shiny and $b-color == grayscale($b-color) {
@include shiny($b-color, true, $t-size, $pad);
}
@if $type == pill and $b-color == grayscale($b-color) {
@include pill($b-color, true, $t-size, $pad);
}
@if $type == flat and $b-color == grayscale($b-color) {
@include flat($b-color, true, $t-size, $pad);
}
// Colored button
@if $type == simple {
@include simple($b-color, false, $t-size, $pad);
}
@else if $type == shiny {
@include shiny($b-color, false, $t-size, $pad);
}
@else if $type == pill {
@include pill($b-color, false, $t-size, $pad);
}
@else if $type == flat {
@include flat($b-color, false, $t-size, $pad);
}
}
// Simple Button
@mixin simple($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
$color: hsl(0, 0, 100%);
$border: adjust-color($base-color, $saturation: 9%, $lightness: -14%);
$inset-shadow: adjust-color($base-color, $saturation: -8%, $lightness: 15%);
$stop-gradient: adjust-color($base-color, $saturation: 9%, $lightness: -11%);
$text-shadow: adjust-color($base-color, $saturation: 15%, $lightness: -18%);
@if is-light($base-color) {
$color: hsl(0, 0, 20%);
$text-shadow: adjust-color($base-color, $saturation: 10%, $lightness: 4%);
}
@if $grayscale == true {
$border: grayscale($border);
$inset-shadow: grayscale($inset-shadow);
$stop-gradient: grayscale($stop-gradient);
$text-shadow: grayscale($text-shadow);
}
border: 1px solid $border;
border-radius: 3px;
box-shadow: inset 0 1px 0 0 $inset-shadow;
color: $color;
display: inline-block;
font-size: $textsize;
font-weight: bold;
@include linear-gradient ($base-color, $stop-gradient);
padding: $padding;
text-decoration: none;
text-shadow: 0 1px 0 $text-shadow;
background-clip: padding-box;
&:hover:not(:disabled) {
$base-color-hover: adjust-color($base-color, $saturation: -4%, $lightness: -5%);
$inset-shadow-hover: adjust-color($base-color, $saturation: -7%, $lightness: 5%);
$stop-gradient-hover: adjust-color($base-color, $saturation: 8%, $lightness: -14%);
@if $grayscale == true {
$base-color-hover: grayscale($base-color-hover);
$inset-shadow-hover: grayscale($inset-shadow-hover);
$stop-gradient-hover: grayscale($stop-gradient-hover);
}
@include linear-gradient ($base-color-hover, $stop-gradient-hover);
box-shadow: inset 0 1px 0 0 $inset-shadow-hover;
cursor: pointer;
}
&:active:not(:disabled),
&:focus:not(:disabled) {
$border-active: adjust-color($base-color, $saturation: 9%, $lightness: -14%);
$inset-shadow-active: adjust-color($base-color, $saturation: 7%, $lightness: -17%);
@if $grayscale == true {
$border-active: grayscale($border-active);
$inset-shadow-active: grayscale($inset-shadow-active);
}
border: 1px solid $border-active;
box-shadow: inset 0 0 8px 4px $inset-shadow-active, inset 0 0 8px 4px $inset-shadow-active;
}
}
// Shiny Button
@mixin shiny($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
$color: hsl(0, 0, 100%);
$border: adjust-color($base-color, $red: -117, $green: -111, $blue: -81);
$border-bottom: adjust-color($base-color, $red: -126, $green: -127, $blue: -122);
$fourth-stop: adjust-color($base-color, $red: -79, $green: -70, $blue: -46);
$inset-shadow: adjust-color($base-color, $red: 37, $green: 29, $blue: 12);
$second-stop: adjust-color($base-color, $red: -56, $green: -50, $blue: -33);
$text-shadow: adjust-color($base-color, $red: -140, $green: -141, $blue: -114);
$third-stop: adjust-color($base-color, $red: -86, $green: -75, $blue: -48);
@if is-light($base-color) {
$color: hsl(0, 0, 20%);
$text-shadow: adjust-color($base-color, $saturation: 10%, $lightness: 4%);
}
@if $grayscale == true {
$border: grayscale($border);
$border-bottom: grayscale($border-bottom);
$fourth-stop: grayscale($fourth-stop);
$inset-shadow: grayscale($inset-shadow);
$second-stop: grayscale($second-stop);
$text-shadow: grayscale($text-shadow);
$third-stop: grayscale($third-stop);
}
@include linear-gradient(top, $base-color 0%, $second-stop 50%, $third-stop 50%, $fourth-stop 100%);
border: 1px solid $border;
border-bottom: 1px solid $border-bottom;
border-radius: 5px;
box-shadow: inset 0 1px 0 0 $inset-shadow;
color: $color;
display: inline-block;
font-size: $textsize;
font-weight: bold;
padding: $padding;
text-align: center;
text-decoration: none;
text-shadow: 0 -1px 1px $text-shadow;
&:hover:not(:disabled) {
$first-stop-hover: adjust-color($base-color, $red: -13, $green: -15, $blue: -18);
$second-stop-hover: adjust-color($base-color, $red: -66, $green: -62, $blue: -51);
$third-stop-hover: adjust-color($base-color, $red: -93, $green: -85, $blue: -66);
$fourth-stop-hover: adjust-color($base-color, $red: -86, $green: -80, $blue: -63);
@if $grayscale == true {
$first-stop-hover: grayscale($first-stop-hover);
$second-stop-hover: grayscale($second-stop-hover);
$third-stop-hover: grayscale($third-stop-hover);
$fourth-stop-hover: grayscale($fourth-stop-hover);
}
@include linear-gradient(top, $first-stop-hover 0%,
$second-stop-hover 50%,
$third-stop-hover 50%,
$fourth-stop-hover 100%);
cursor: pointer;
}
&:active:not(:disabled),
&:focus:not(:disabled) {
$inset-shadow-active: adjust-color($base-color, $red: -111, $green: -116, $blue: -122);
@if $grayscale == true {
$inset-shadow-active: grayscale($inset-shadow-active);
}
box-shadow: inset 0 0 20px 0 $inset-shadow-active;
}
}
// Pill Button
@mixin pill($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
$color: hsl(0, 0, 100%);
$border-bottom: adjust-color($base-color, $hue: 8, $saturation: -11%, $lightness: -26%);
$border-sides: adjust-color($base-color, $hue: 4, $saturation: -21%, $lightness: -21%);
$border-top: adjust-color($base-color, $hue: -1, $saturation: -30%, $lightness: -15%);
$inset-shadow: adjust-color($base-color, $hue: -1, $saturation: -1%, $lightness: 7%);
$stop-gradient: adjust-color($base-color, $hue: 8, $saturation: 14%, $lightness: -10%);
$text-shadow: adjust-color($base-color, $hue: 5, $saturation: -19%, $lightness: -15%);
@if is-light($base-color) {
$color: hsl(0, 0, 20%);
$text-shadow: adjust-color($base-color, $saturation: 10%, $lightness: 4%);
}
@if $grayscale == true {
$border-bottom: grayscale($border-bottom);
$border-sides: grayscale($border-sides);
$border-top: grayscale($border-top);
$inset-shadow: grayscale($inset-shadow);
$stop-gradient: grayscale($stop-gradient);
$text-shadow: grayscale($text-shadow);
}
border: 1px solid $border-top;
border-color: $border-top $border-sides $border-bottom;
border-radius: 16px;
box-shadow: inset 0 1px 0 0 $inset-shadow;
color: $color;
display: inline-block;
font-size: $textsize;
font-weight: normal;
line-height: 1;
@include linear-gradient ($base-color, $stop-gradient);
padding: $padding;
text-align: center;
text-decoration: none;
text-shadow: 0 -1px 1px $text-shadow;
background-clip: padding-box;
&:hover:not(:disabled) {
$base-color-hover: adjust-color($base-color, $lightness: -4.5%);
$border-bottom: adjust-color($base-color, $hue: 8, $saturation: 13.5%, $lightness: -32%);
$border-sides: adjust-color($base-color, $hue: 4, $saturation: -2%, $lightness: -27%);
$border-top: adjust-color($base-color, $hue: -1, $saturation: -17%, $lightness: -21%);
$inset-shadow-hover: adjust-color($base-color, $saturation: -1%, $lightness: 3%);
$stop-gradient-hover: adjust-color($base-color, $hue: 8, $saturation: -4%, $lightness: -15.5%);
$text-shadow-hover: adjust-color($base-color, $hue: 5, $saturation: -5%, $lightness: -22%);
@if $grayscale == true {
$base-color-hover: grayscale($base-color-hover);
$border-bottom: grayscale($border-bottom);
$border-sides: grayscale($border-sides);
$border-top: grayscale($border-top);
$inset-shadow-hover: grayscale($inset-shadow-hover);
$stop-gradient-hover: grayscale($stop-gradient-hover);
$text-shadow-hover: grayscale($text-shadow-hover);
}
@include linear-gradient ($base-color-hover, $stop-gradient-hover);
background-clip: padding-box;
border: 1px solid $border-top;
border-color: $border-top $border-sides $border-bottom;
box-shadow: inset 0 1px 0 0 $inset-shadow-hover;
cursor: pointer;
text-shadow: 0 -1px 1px $text-shadow-hover;
}
&:active:not(:disabled),
&:focus:not(:disabled) {
$active-color: adjust-color($base-color, $hue: 4, $saturation: -12%, $lightness: -10%);
$border-active: adjust-color($base-color, $hue: 6, $saturation: -2.5%, $lightness: -30%);
$border-bottom-active: adjust-color($base-color, $hue: 11, $saturation: 6%, $lightness: -31%);
$inset-shadow-active: adjust-color($base-color, $hue: 9, $saturation: 2%, $lightness: -21.5%);
$text-shadow-active: adjust-color($base-color, $hue: 5, $saturation: -12%, $lightness: -21.5%);
@if $grayscale == true {
$active-color: grayscale($active-color);
$border-active: grayscale($border-active);
$border-bottom-active: grayscale($border-bottom-active);
$inset-shadow-active: grayscale($inset-shadow-active);
$text-shadow-active: grayscale($text-shadow-active);
}
background: $active-color;
border: 1px solid $border-active;
border-bottom: 1px solid $border-bottom-active;
box-shadow: inset 0 0 6px 3px $inset-shadow-active;
text-shadow: 0 -1px 1px $text-shadow-active;
}
}
// Flat Button
@mixin flat($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
$color: hsl(0, 0, 100%);
@if is-light($base-color) {
$color: hsl(0, 0, 20%);
}
background-color: $base-color;
border-radius: 3px;
border: 0;
color: $color;
display: inline-block;
font-size: $textsize;
font-weight: bold;
padding: $padding;
text-decoration: none;
background-clip: padding-box;
&:hover:not(:disabled){
$base-color-hover: adjust-color($base-color, $saturation: 4%, $lightness: 5%);
@if $grayscale == true {
$base-color-hover: grayscale($base-color-hover);
}
background-color: $base-color-hover;
cursor: pointer;
}
&:active:not(:disabled),
&:focus:not(:disabled) {
$base-color-active: adjust-color($base-color, $saturation: -4%, $lightness: -5%);
@if $grayscale == true {
$base-color-active: grayscale($base-color-active);
}
background-color: $base-color-active;
cursor: pointer;
}
}
// Flexible grid
@function flex-grid($columns, $container-columns: $fg-max-columns) {
$width: $columns * $fg-column + ($columns - 1) * $fg-gutter;
$container-width: $container-columns * $fg-column + ($container-columns - 1) * $fg-gutter;
@return percentage($width / $container-width);
@warn "The flex-grid function is deprecated and will be removed in the next major version release";
}
// Flexible gutter
@function flex-gutter($container-columns: $fg-max-columns, $gutter: $fg-gutter) {
$container-width: $container-columns * $fg-column + ($container-columns - 1) * $fg-gutter;
@return percentage($gutter / $container-width);
@warn "The flex-gutter function is deprecated and will be removed in the next major version release";
}
@function grid-width($n) {
@return $n * $gw-column + ($n - 1) * $gw-gutter;
@warn "The grid-width function is deprecated and will be removed in the next major version release";
}
@function golden-ratio($value, $increment) {
@return modular-scale($increment, $value, $ratio: $golden);
@warn "The golden-ratio function is deprecated and will be removed in the next major version release. Please use the modular-scale function, instead.";
}
@mixin box-sizing($box) {
@include prefixer(box-sizing, $box, webkit moz spec);
@warn "The box-sizing mixin is deprecated and will be removed in the next major version release. This property can now be used un-prefixed.";
}
+87
View File
@@ -0,0 +1,87 @@
// Bourbon 4.2.7
// http://bourbon.io
// Copyright 2011-2015 thoughtbot, inc.
// MIT License
@import "settings/prefixer";
@import "settings/px-to-em";
@import "settings/asset-pipeline";
@import "functions/assign-inputs";
@import "functions/contains";
@import "functions/contains-falsy";
@import "functions/is-length";
@import "functions/is-light";
@import "functions/is-number";
@import "functions/is-size";
@import "functions/px-to-em";
@import "functions/px-to-rem";
@import "functions/shade";
@import "functions/strip-units";
@import "functions/tint";
@import "functions/transition-property-name";
@import "functions/unpack";
@import "functions/modular-scale";
@import "helpers/convert-units";
@import "helpers/directional-values";
@import "helpers/font-source-declaration";
@import "helpers/gradient-positions-parser";
@import "helpers/linear-angle-parser";
@import "helpers/linear-gradient-parser";
@import "helpers/linear-positions-parser";
@import "helpers/linear-side-corner-parser";
@import "helpers/radial-arg-parser";
@import "helpers/radial-positions-parser";
@import "helpers/radial-gradient-parser";
@import "helpers/render-gradients";
@import "helpers/shape-size-stripper";
@import "helpers/str-to-num";
@import "css3/animation";
@import "css3/appearance";
@import "css3/backface-visibility";
@import "css3/background";
@import "css3/background-image";
@import "css3/border-image";
@import "css3/calc";
@import "css3/columns";
@import "css3/filter";
@import "css3/flex-box";
@import "css3/font-face";
@import "css3/font-feature-settings";
@import "css3/hidpi-media-query";
@import "css3/hyphens";
@import "css3/image-rendering";
@import "css3/keyframes";
@import "css3/linear-gradient";
@import "css3/perspective";
@import "css3/placeholder";
@import "css3/radial-gradient";
@import "css3/selection";
@import "css3/text-decoration";
@import "css3/transform";
@import "css3/transition";
@import "css3/user-select";
@import "addons/border-color";
@import "addons/border-radius";
@import "addons/border-style";
@import "addons/border-width";
@import "addons/buttons";
@import "addons/clearfix";
@import "addons/ellipsis";
@import "addons/font-stacks";
@import "addons/hide-text";
@import "addons/margin";
@import "addons/padding";
@import "addons/position";
@import "addons/prefixer";
@import "addons/retina-image";
@import "addons/size";
@import "addons/text-inputs";
@import "addons/timing-functions";
@import "addons/triangle";
@import "addons/word-wrap";
@import "bourbon-deprecated-upcoming";
@@ -0,0 +1,26 @@
@charset "UTF-8";
/// Provides a quick method for targeting `border-color` on specific sides of a box. Use a `null` value to “skip” a side.
///
/// @param {Arglist} $vals
/// List of arguments
///
/// @example scss - Usage
/// .element {
/// @include border-color(#a60b55 #76cd9c null #e8ae1a);
/// }
///
/// @example css - CSS Output
/// .element {
/// border-left-color: #e8ae1a;
/// border-right-color: #76cd9c;
/// border-top-color: #a60b55;
/// }
///
/// @require {mixin} directional-property
///
/// @output `border-color`
@mixin border-color($vals...) {
@include directional-property(border, color, $vals...);
}
@@ -0,0 +1,48 @@
@charset "UTF-8";
/// Provides a quick method for targeting `border-radius` on both corners on the side of a box.
///
/// @param {Number} $radii
/// List of arguments
///
/// @example scss - Usage
/// .element-one {
/// @include border-top-radius(5px);
/// }
///
/// .element-two {
/// @include border-left-radius(3px);
/// }
///
/// @example css - CSS Output
/// .element-one {
/// border-top-left-radius: 5px;
/// border-top-right-radius: 5px;
/// }
///
/// .element-two {
/// border-bottom-left-radius: 3px;
/// border-top-left-radius: 3px;
/// }
///
/// @output `border-radius`
@mixin border-top-radius($radii) {
border-top-left-radius: $radii;
border-top-right-radius: $radii;
}
@mixin border-right-radius($radii) {
border-bottom-right-radius: $radii;
border-top-right-radius: $radii;
}
@mixin border-bottom-radius($radii) {
border-bottom-left-radius: $radii;
border-bottom-right-radius: $radii;
}
@mixin border-left-radius($radii) {
border-bottom-left-radius: $radii;
border-top-left-radius: $radii;
}
@@ -0,0 +1,25 @@
@charset "UTF-8";
/// Provides a quick method for targeting `border-style` on specific sides of a box. Use a `null` value to “skip” a side.
///
/// @param {Arglist} $vals
/// List of arguments
///
/// @example scss - Usage
/// .element {
/// @include border-style(dashed null solid);
/// }
///
/// @example css - CSS Output
/// .element {
/// border-bottom-style: solid;
/// border-top-style: dashed;
/// }
///
/// @require {mixin} directional-property
///
/// @output `border-style`
@mixin border-style($vals...) {
@include directional-property(border, style, $vals...);
}
@@ -0,0 +1,25 @@
@charset "UTF-8";
/// Provides a quick method for targeting `border-width` on specific sides of a box. Use a `null` value to “skip” a side.
///
/// @param {Arglist} $vals
/// List of arguments
///
/// @example scss - Usage
/// .element {
/// @include border-width(1em null 20px);
/// }
///
/// @example css - CSS Output
/// .element {
/// border-bottom-width: 20px;
/// border-top-width: 1em;
/// }
///
/// @require {mixin} directional-property
///
/// @output `border-width`
@mixin border-width($vals...) {
@include directional-property(border, width, $vals...);
}
@@ -0,0 +1,64 @@
@charset "UTF-8";
/// Generates variables for all buttons. Please note that you must use interpolation on the variable: `#{$all-buttons}`.
///
/// @example scss - Usage
/// #{$all-buttons} {
/// background-color: #f00;
/// }
///
/// #{$all-buttons-focus},
/// #{$all-buttons-hover} {
/// background-color: #0f0;
/// }
///
/// #{$all-buttons-active} {
/// background-color: #00f;
/// }
///
/// @example css - CSS Output
/// button,
/// input[type="button"],
/// input[type="reset"],
/// input[type="submit"] {
/// background-color: #f00;
/// }
///
/// button:focus,
/// input[type="button"]:focus,
/// input[type="reset"]:focus,
/// input[type="submit"]:focus,
/// button:hover,
/// input[type="button"]:hover,
/// input[type="reset"]:hover,
/// input[type="submit"]:hover {
/// background-color: #0f0;
/// }
///
/// button:active,
/// input[type="button"]:active,
/// input[type="reset"]:active,
/// input[type="submit"]:active {
/// background-color: #00f;
/// }
///
/// @require assign-inputs
///
/// @type List
///
/// @todo Remove double assigned variables (Lines 5962) in v5.0.0
$buttons-list: 'button',
'input[type="button"]',
'input[type="reset"]',
'input[type="submit"]';
$all-buttons: assign-inputs($buttons-list);
$all-buttons-active: assign-inputs($buttons-list, active);
$all-buttons-focus: assign-inputs($buttons-list, focus);
$all-buttons-hover: assign-inputs($buttons-list, hover);
$all-button-inputs: $all-buttons;
$all-button-inputs-active: $all-buttons-active;
$all-button-inputs-focus: $all-buttons-focus;
$all-button-inputs-hover: $all-buttons-hover;
@@ -0,0 +1,25 @@
@charset "UTF-8";
/// Provides an easy way to include a clearfix for containing floats.
///
/// @link http://cssmojo.com/latest_new_clearfix_so_far/
///
/// @example scss - Usage
/// .element {
/// @include clearfix;
/// }
///
/// @example css - CSS Output
/// .element::after {
/// clear: both;
/// content: "";
/// display: table;
/// }
@mixin clearfix {
&::after {
clear: both;
content: "";
display: table;
}
}
@@ -0,0 +1,30 @@
@charset "UTF-8";
/// Truncates text and adds an ellipsis to represent overflow.
///
/// @param {Number} $width [100%]
/// Max-width for the string to respect before being truncated
///
/// @example scss - Usage
/// .element {
/// @include ellipsis;
/// }
///
/// @example css - CSS Output
/// .element {
/// display: inline-block;
/// max-width: 100%;
/// overflow: hidden;
/// text-overflow: ellipsis;
/// white-space: nowrap;
/// word-wrap: normal;
/// }
@mixin ellipsis($width: 100%) {
display: inline-block;
max-width: $width;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal;
}
@@ -0,0 +1,31 @@
@charset "UTF-8";
/// Georgia font stack.
///
/// @type List
$georgia: "Georgia", "Cambria", "Times New Roman", "Times", serif;
/// Helvetica font stack.
///
/// @type List
$helvetica: "Helvetica Neue", "Helvetica", "Roboto", "Arial", sans-serif;
/// Lucida Grande font stack.
///
/// @type List
$lucida-grande: "Lucida Grande", "Tahoma", "Verdana", "Arial", sans-serif;
/// Monospace font stack.
///
/// @type List
$monospace: "Bitstream Vera Sans Mono", "Consolas", "Courier", monospace;
/// Verdana font stack.
///
/// @type List
$verdana: "Verdana", "Geneva", sans-serif;
@@ -0,0 +1,27 @@
/// Hides the text in an element, commonly used to show an image. Some elements will need block-level styles applied.
///
/// @link http://zeldman.com/2012/03/01/replacing-the-9999px-hack-new-image-replacement
///
/// @example scss - Usage
/// .element {
/// @include hide-text;
/// }
///
/// @example css - CSS Output
/// .element {
/// overflow: hidden;
/// text-indent: 101%;
/// white-space: nowrap;
/// }
///
/// @todo Remove height argument in v5.0.0
@mixin hide-text($height: null) {
overflow: hidden;
text-indent: 101%;
white-space: nowrap;
@if $height {
@warn "The `hide-text` mixin has changed and no longer requires a height. The height argument will no longer be accepted in v5.0.0";
}
}
@@ -0,0 +1,26 @@
@charset "UTF-8";
/// Provides a quick method for targeting `margin` on specific sides of a box. Use a `null` value to “skip” a side.
///
/// @param {Arglist} $vals
/// List of arguments
///
/// @example scss - Usage
/// .element {
/// @include margin(null 10px 3em 20vh);
/// }
///
/// @example css - CSS Output
/// .element {
/// margin-bottom: 3em;
/// margin-left: 20vh;
/// margin-right: 10px;
/// }
///
/// @require {mixin} directional-property
///
/// @output `margin`
@mixin margin($vals...) {
@include directional-property(margin, false, $vals...);
}
@@ -0,0 +1,26 @@
@charset "UTF-8";
/// Provides a quick method for targeting `padding` on specific sides of a box. Use a `null` value to “skip” a side.
///
/// @param {Arglist} $vals
/// List of arguments
///
/// @example scss - Usage
/// .element {
/// @include padding(12vh null 10px 5%);
/// }
///
/// @example css - CSS Output
/// .element {
/// padding-bottom: 10px;
/// padding-left: 5%;
/// padding-top: 12vh;
/// }
///
/// @require {mixin} directional-property
///
/// @output `padding`
@mixin padding($vals...) {
@include directional-property(padding, false, $vals...);
}
@@ -0,0 +1,48 @@
@charset "UTF-8";
/// Provides a quick method for setting an elements position. Use a `null` value to “skip” a side.
///
/// @param {Position} $position [relative]
/// A CSS position value
///
/// @param {Arglist} $coordinates [null null null null]
/// List of values that correspond to the 4-value syntax for the edges of a box
///
/// @example scss - Usage
/// .element {
/// @include position(absolute, 0 null null 10em);
/// }
///
/// @example css - CSS Output
/// .element {
/// left: 10em;
/// position: absolute;
/// top: 0;
/// }
///
/// @require {function} is-length
/// @require {function} unpack
@mixin position($position: relative, $coordinates: null null null null) {
@if type-of($position) == list {
$coordinates: $position;
$position: relative;
}
$coordinates: unpack($coordinates);
$offsets: (
top: nth($coordinates, 1),
right: nth($coordinates, 2),
bottom: nth($coordinates, 3),
left: nth($coordinates, 4)
);
position: $position;
@each $offset, $value in $offsets {
@if is-length($value) {
#{$offset}: $value;
}
}
}
@@ -0,0 +1,66 @@
@charset "UTF-8";
/// A mixin for generating vendor prefixes on non-standardized properties.
///
/// @param {String} $property
/// Property to prefix
///
/// @param {*} $value
/// Value to use
///
/// @param {List} $prefixes
/// Prefixes to define
///
/// @example scss - Usage
/// .element {
/// @include prefixer(border-radius, 10px, webkit ms spec);
/// }
///
/// @example css - CSS Output
/// .element {
/// -webkit-border-radius: 10px;
/// -moz-border-radius: 10px;
/// border-radius: 10px;
/// }
///
/// @require {variable} $prefix-for-webkit
/// @require {variable} $prefix-for-mozilla
/// @require {variable} $prefix-for-microsoft
/// @require {variable} $prefix-for-opera
/// @require {variable} $prefix-for-spec
@mixin prefixer($property, $value, $prefixes) {
@each $prefix in $prefixes {
@if $prefix == webkit {
@if $prefix-for-webkit {
-webkit-#{$property}: $value;
}
} @else if $prefix == moz {
@if $prefix-for-mozilla {
-moz-#{$property}: $value;
}
} @else if $prefix == ms {
@if $prefix-for-microsoft {
-ms-#{$property}: $value;
}
} @else if $prefix == o {
@if $prefix-for-opera {
-o-#{$property}: $value;
}
} @else if $prefix == spec {
@if $prefix-for-spec {
#{$property}: $value;
}
} @else {
@warn "Unrecognized prefix: #{$prefix}";
}
}
}
@mixin disable-prefix-for-all() {
$prefix-for-webkit: false !global;
$prefix-for-mozilla: false !global;
$prefix-for-microsoft: false !global;
$prefix-for-opera: false !global;
$prefix-for-spec: false !global;
}
@@ -0,0 +1,25 @@
@mixin retina-image($filename, $background-size, $extension: png, $retina-filename: null, $retina-suffix: _2x, $asset-pipeline: $asset-pipeline) {
@if $asset-pipeline {
background-image: image-url("#{$filename}.#{$extension}");
} @else {
background-image: url("#{$filename}.#{$extension}");
}
@include hidpi {
@if $asset-pipeline {
@if $retina-filename {
background-image: image-url("#{$retina-filename}.#{$extension}");
} @else {
background-image: image-url("#{$filename}#{$retina-suffix}.#{$extension}");
}
} @else {
@if $retina-filename {
background-image: url("#{$retina-filename}.#{$extension}");
} @else {
background-image: url("#{$filename}#{$retina-suffix}.#{$extension}");
}
}
background-size: $background-size;
}
}
+51
View File
@@ -0,0 +1,51 @@
@charset "UTF-8";
/// Sets the `width` and `height` of the element.
///
/// @param {List} $size
/// A list of at most 2 size values.
///
/// If there is only a single value in `$size` it is used for both width and height. All units are supported.
///
/// @example scss - Usage
/// .first-element {
/// @include size(2em);
/// }
///
/// .second-element {
/// @include size(auto 10em);
/// }
///
/// @example css - CSS Output
/// .first-element {
/// width: 2em;
/// height: 2em;
/// }
///
/// .second-element {
/// width: auto;
/// height: 10em;
/// }
///
/// @todo Refactor in 5.0.0 to use a comma-separated argument
@mixin size($value) {
$width: nth($value, 1);
$height: $width;
@if length($value) > 1 {
$height: nth($value, 2);
}
@if is-size($height) {
height: $height;
} @else {
@warn "`#{$height}` is not a valid length for the `$height` parameter in the `size` mixin.";
}
@if is-size($width) {
width: $width;
} @else {
@warn "`#{$width}` is not a valid length for the `$width` parameter in the `size` mixin.";
}
}
@@ -0,0 +1,113 @@
@charset "UTF-8";
/// Generates variables for all text-based inputs. Please note that you must use interpolation on the variable: `#{$all-text-inputs}`.
///
/// @example scss - Usage
/// #{$all-text-inputs} {
/// border: 1px solid #f00;
/// }
///
/// #{$all-text-inputs-focus},
/// #{$all-text-inputs-hover} {
/// border: 1px solid #0f0;
/// }
///
/// #{$all-text-inputs-active} {
/// border: 1px solid #00f;
/// }
///
/// @example css - CSS Output
/// input[type="color"],
/// input[type="date"],
/// input[type="datetime"],
/// input[type="datetime-local"],
/// input[type="email"],
/// input[type="month"],
/// input[type="number"],
/// input[type="password"],
/// input[type="search"],
/// input[type="tel"],
/// input[type="text"],
/// input[type="time"],
/// input[type="url"],
/// input[type="week"],
/// textarea {
/// border: 1px solid #f00;
/// }
///
/// input[type="color"]:focus,
/// input[type="date"]:focus,
/// input[type="datetime"]:focus,
/// input[type="datetime-local"]:focus,
/// input[type="email"]:focus,
/// input[type="month"]:focus,
/// input[type="number"]:focus,
/// input[type="password"]:focus,
/// input[type="search"]:focus,
/// input[type="tel"]:focus,
/// input[type="text"]:focus,
/// input[type="time"]:focus,
/// input[type="url"]:focus,
/// input[type="week"]:focus,
/// textarea:focus,
/// input[type="color"]:hover,
/// input[type="date"]:hover,
/// input[type="datetime"]:hover,
/// input[type="datetime-local"]:hover,
/// input[type="email"]:hover,
/// input[type="month"]:hover,
/// input[type="number"]:hover,
/// input[type="password"]:hover,
/// input[type="search"]:hover,
/// input[type="tel"]:hover,
/// input[type="text"]:hover,
/// input[type="time"]:hover,
/// input[type="url"]:hover,
/// input[type="week"]:hover,
/// textarea:hover {
/// border: 1px solid #0f0;
/// }
///
/// input[type="color"]:active,
/// input[type="date"]:active,
/// input[type="datetime"]:active,
/// input[type="datetime-local"]:active,
/// input[type="email"]:active,
/// input[type="month"]:active,
/// input[type="number"]:active,
/// input[type="password"]:active,
/// input[type="search"]:active,
/// input[type="tel"]:active,
/// input[type="text"]:active,
/// input[type="time"]:active,
/// input[type="url"]:active,
/// input[type="week"]:active,
/// textarea:active {
/// border: 1px solid #00f;
/// }
///
/// @require assign-inputs
///
/// @type List
$text-inputs-list: 'input[type="color"]',
'input[type="date"]',
'input[type="datetime"]',
'input[type="datetime-local"]',
'input[type="email"]',
'input[type="month"]',
'input[type="number"]',
'input[type="password"]',
'input[type="search"]',
'input[type="tel"]',
'input[type="text"]',
'input[type="time"]',
'input[type="url"]',
'input[type="week"]',
'input:not([type])',
'textarea';
$all-text-inputs: assign-inputs($text-inputs-list);
$all-text-inputs-active: assign-inputs($text-inputs-list, active);
$all-text-inputs-focus: assign-inputs($text-inputs-list, focus);
$all-text-inputs-hover: assign-inputs($text-inputs-list, hover);
@@ -0,0 +1,34 @@
@charset "UTF-8";
/// CSS cubic-bezier timing functions. Timing functions courtesy of jquery.easie (github.com/jaukia/easie)
///
/// Timing functions are the same as demoed here: http://jqueryui.com/resources/demos/effect/easing.html
///
/// @type cubic-bezier
$ease-in-quad: cubic-bezier(0.550, 0.085, 0.680, 0.530);
$ease-in-cubic: cubic-bezier(0.550, 0.055, 0.675, 0.190);
$ease-in-quart: cubic-bezier(0.895, 0.030, 0.685, 0.220);
$ease-in-quint: cubic-bezier(0.755, 0.050, 0.855, 0.060);
$ease-in-sine: cubic-bezier(0.470, 0.000, 0.745, 0.715);
$ease-in-expo: cubic-bezier(0.950, 0.050, 0.795, 0.035);
$ease-in-circ: cubic-bezier(0.600, 0.040, 0.980, 0.335);
$ease-in-back: cubic-bezier(0.600, -0.280, 0.735, 0.045);
$ease-out-quad: cubic-bezier(0.250, 0.460, 0.450, 0.940);
$ease-out-cubic: cubic-bezier(0.215, 0.610, 0.355, 1.000);
$ease-out-quart: cubic-bezier(0.165, 0.840, 0.440, 1.000);
$ease-out-quint: cubic-bezier(0.230, 1.000, 0.320, 1.000);
$ease-out-sine: cubic-bezier(0.390, 0.575, 0.565, 1.000);
$ease-out-expo: cubic-bezier(0.190, 1.000, 0.220, 1.000);
$ease-out-circ: cubic-bezier(0.075, 0.820, 0.165, 1.000);
$ease-out-back: cubic-bezier(0.175, 0.885, 0.320, 1.275);
$ease-in-out-quad: cubic-bezier(0.455, 0.030, 0.515, 0.955);
$ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1.000);
$ease-in-out-quart: cubic-bezier(0.770, 0.000, 0.175, 1.000);
$ease-in-out-quint: cubic-bezier(0.860, 0.000, 0.070, 1.000);
$ease-in-out-sine: cubic-bezier(0.445, 0.050, 0.550, 0.950);
$ease-in-out-expo: cubic-bezier(1.000, 0.000, 0.000, 1.000);
$ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.150, 0.860);
$ease-in-out-back: cubic-bezier(0.680, -0.550, 0.265, 1.550);
@@ -0,0 +1,63 @@
@mixin triangle($size, $color, $direction) {
$width: nth($size, 1);
$height: nth($size, length($size));
$foreground-color: nth($color, 1);
$background-color: if(length($color) == 2, nth($color, 2), transparent);
height: 0;
width: 0;
@if ($direction == up) or ($direction == down) or ($direction == right) or ($direction == left) {
$width: $width / 2;
$height: if(length($size) > 1, $height, $height/2);
@if $direction == up {
border-bottom: $height solid $foreground-color;
border-left: $width solid $background-color;
border-right: $width solid $background-color;
} @else if $direction == right {
border-bottom: $width solid $background-color;
border-left: $height solid $foreground-color;
border-top: $width solid $background-color;
} @else if $direction == down {
border-left: $width solid $background-color;
border-right: $width solid $background-color;
border-top: $height solid $foreground-color;
} @else if $direction == left {
border-bottom: $width solid $background-color;
border-right: $height solid $foreground-color;
border-top: $width solid $background-color;
}
} @else if ($direction == up-right) or ($direction == up-left) {
border-top: $height solid $foreground-color;
@if $direction == up-right {
border-left: $width solid $background-color;
} @else if $direction == up-left {
border-right: $width solid $background-color;
}
} @else if ($direction == down-right) or ($direction == down-left) {
border-bottom: $height solid $foreground-color;
@if $direction == down-right {
border-left: $width solid $background-color;
} @else if $direction == down-left {
border-right: $width solid $background-color;
}
} @else if ($direction == inset-up) {
border-color: $background-color $background-color $foreground-color;
border-style: solid;
border-width: $height $width;
} @else if ($direction == inset-down) {
border-color: $foreground-color $background-color $background-color;
border-style: solid;
border-width: $height $width;
} @else if ($direction == inset-right) {
border-color: $background-color $background-color $background-color $foreground-color;
border-style: solid;
border-width: $width $height;
} @else if ($direction == inset-left) {
border-color: $background-color $foreground-color $background-color $background-color;
border-style: solid;
border-width: $width $height;
}
}
@@ -0,0 +1,29 @@
@charset "UTF-8";
/// Provides an easy way to change the `word-wrap` property.
///
/// @param {String} $wrap [break-word]
/// Value for the `word-break` property.
///
/// @example scss - Usage
/// .wrapper {
/// @include word-wrap(break-word);
/// }
///
/// @example css - CSS Output
/// .wrapper {
/// overflow-wrap: break-word;
/// word-break: break-all;
/// word-wrap: break-word;
/// }
@mixin word-wrap($wrap: break-word) {
overflow-wrap: $wrap;
word-wrap: $wrap;
@if $wrap == break-word {
word-break: break-all;
} @else {
word-break: $wrap;
}
}
@@ -0,0 +1,43 @@
// http://www.w3.org/TR/css3-animations/#the-animation-name-property-
// Each of these mixins support comma separated lists of values, which allows different transitions for individual properties to be described in a single style rule. Each value in the list corresponds to the value at that same position in the other properties.
@mixin animation($animations...) {
@include prefixer(animation, $animations, webkit moz spec);
}
@mixin animation-name($names...) {
@include prefixer(animation-name, $names, webkit moz spec);
}
@mixin animation-duration($times...) {
@include prefixer(animation-duration, $times, webkit moz spec);
}
@mixin animation-timing-function($motions...) {
// ease | linear | ease-in | ease-out | ease-in-out
@include prefixer(animation-timing-function, $motions, webkit moz spec);
}
@mixin animation-iteration-count($values...) {
// infinite | <number>
@include prefixer(animation-iteration-count, $values, webkit moz spec);
}
@mixin animation-direction($directions...) {
// normal | alternate
@include prefixer(animation-direction, $directions, webkit moz spec);
}
@mixin animation-play-state($states...) {
// running | paused
@include prefixer(animation-play-state, $states, webkit moz spec);
}
@mixin animation-delay($times...) {
@include prefixer(animation-delay, $times, webkit moz spec);
}
@mixin animation-fill-mode($modes...) {
// none | forwards | backwards | both
@include prefixer(animation-fill-mode, $modes, webkit moz spec);
}
@@ -0,0 +1,3 @@
@mixin appearance($value) {
@include prefixer(appearance, $value, webkit moz ms o spec);
}
@@ -0,0 +1,3 @@
@mixin backface-visibility($visibility) {
@include prefixer(backface-visibility, $visibility, webkit spec);
}
@@ -0,0 +1,42 @@
//************************************************************************//
// Background-image property for adding multiple background images with
// gradients, or for stringing multiple gradients together.
//************************************************************************//
@mixin background-image($images...) {
$webkit-images: ();
$spec-images: ();
@each $image in $images {
$webkit-image: ();
$spec-image: ();
@if (type-of($image) == string) {
$url-str: str-slice($image, 1, 3);
$gradient-type: str-slice($image, 1, 6);
@if $url-str == "url" {
$webkit-image: $image;
$spec-image: $image;
}
@else if $gradient-type == "linear" {
$gradients: _linear-gradient-parser($image);
$webkit-image: map-get($gradients, webkit-image);
$spec-image: map-get($gradients, spec-image);
}
@else if $gradient-type == "radial" {
$gradients: _radial-gradient-parser($image);
$webkit-image: map-get($gradients, webkit-image);
$spec-image: map-get($gradients, spec-image);
}
}
$webkit-images: append($webkit-images, $webkit-image, comma);
$spec-images: append($spec-images, $spec-image, comma);
}
background-image: $webkit-images;
background-image: $spec-images;
}
@@ -0,0 +1,55 @@
//************************************************************************//
// Background property for adding multiple backgrounds using shorthand
// notation.
//************************************************************************//
@mixin background($backgrounds...) {
$webkit-backgrounds: ();
$spec-backgrounds: ();
@each $background in $backgrounds {
$webkit-background: ();
$spec-background: ();
$background-type: type-of($background);
@if $background-type == string or $background-type == list {
$background-str: if($background-type == list, nth($background, 1), $background);
$url-str: str-slice($background-str, 1, 3);
$gradient-type: str-slice($background-str, 1, 6);
@if $url-str == "url" {
$webkit-background: $background;
$spec-background: $background;
}
@else if $gradient-type == "linear" {
$gradients: _linear-gradient-parser("#{$background}");
$webkit-background: map-get($gradients, webkit-image);
$spec-background: map-get($gradients, spec-image);
}
@else if $gradient-type == "radial" {
$gradients: _radial-gradient-parser("#{$background}");
$webkit-background: map-get($gradients, webkit-image);
$spec-background: map-get($gradients, spec-image);
}
@else {
$webkit-background: $background;
$spec-background: $background;
}
}
@else {
$webkit-background: $background;
$spec-background: $background;
}
$webkit-backgrounds: append($webkit-backgrounds, $webkit-background, comma);
$spec-backgrounds: append($spec-backgrounds, $spec-background, comma);
}
background: $webkit-backgrounds;
background: $spec-backgrounds;
}
@@ -0,0 +1,59 @@
@mixin border-image($borders...) {
$webkit-borders: ();
$spec-borders: ();
@each $border in $borders {
$webkit-border: ();
$spec-border: ();
$border-type: type-of($border);
@if $border-type == string or list {
$border-str: if($border-type == list, nth($border, 1), $border);
$url-str: str-slice($border-str, 1, 3);
$gradient-type: str-slice($border-str, 1, 6);
@if $url-str == "url" {
$webkit-border: $border;
$spec-border: $border;
}
@else if $gradient-type == "linear" {
$gradients: _linear-gradient-parser("#{$border}");
$webkit-border: map-get($gradients, webkit-image);
$spec-border: map-get($gradients, spec-image);
}
@else if $gradient-type == "radial" {
$gradients: _radial-gradient-parser("#{$border}");
$webkit-border: map-get($gradients, webkit-image);
$spec-border: map-get($gradients, spec-image);
}
@else {
$webkit-border: $border;
$spec-border: $border;
}
}
@else {
$webkit-border: $border;
$spec-border: $border;
}
$webkit-borders: append($webkit-borders, $webkit-border, comma);
$spec-borders: append($spec-borders, $spec-border, comma);
}
-webkit-border-image: $webkit-borders;
border-image: $spec-borders;
border-style: solid;
}
//Examples:
// @include border-image(url("image.png"));
// @include border-image(url("image.png") 20 stretch);
// @include border-image(linear-gradient(45deg, orange, yellow));
// @include border-image(linear-gradient(45deg, orange, yellow) stretch);
// @include border-image(linear-gradient(45deg, orange, yellow) 20 30 40 50 stretch round);
// @include border-image(radial-gradient(top, cover, orange, yellow, orange));
+4
View File
@@ -0,0 +1,4 @@
@mixin calc($property, $value) {
#{$property}: -webkit-calc(#{$value});
#{$property}: calc(#{$value});
}
+47
View File
@@ -0,0 +1,47 @@
@mixin columns($arg: auto) {
// <column-count> || <column-width>
@include prefixer(columns, $arg, webkit moz spec);
}
@mixin column-count($int: auto) {
// auto || integer
@include prefixer(column-count, $int, webkit moz spec);
}
@mixin column-gap($length: normal) {
// normal || length
@include prefixer(column-gap, $length, webkit moz spec);
}
@mixin column-fill($arg: auto) {
// auto || length
@include prefixer(column-fill, $arg, webkit moz spec);
}
@mixin column-rule($arg) {
// <border-width> || <border-style> || <color>
@include prefixer(column-rule, $arg, webkit moz spec);
}
@mixin column-rule-color($color) {
@include prefixer(column-rule-color, $color, webkit moz spec);
}
@mixin column-rule-style($style: none) {
// none | hidden | dashed | dotted | double | groove | inset | inset | outset | ridge | solid
@include prefixer(column-rule-style, $style, webkit moz spec);
}
@mixin column-rule-width ($width: none) {
@include prefixer(column-rule-width, $width, webkit moz spec);
}
@mixin column-span($arg: none) {
// none || all
@include prefixer(column-span, $arg, webkit moz spec);
}
@mixin column-width($length: auto) {
// auto || length
@include prefixer(column-width, $length, webkit moz spec);
}
@@ -0,0 +1,4 @@
@mixin filter($function: none) {
// <filter-function> [<filter-function]* | none
@include prefixer(filter, $function, webkit spec);
}
+287
View File
@@ -0,0 +1,287 @@
// CSS3 Flexible Box Model and property defaults
// Custom shorthand notation for flexbox
@mixin box($orient: inline-axis, $pack: start, $align: stretch) {
@include display-box;
@include box-orient($orient);
@include box-pack($pack);
@include box-align($align);
}
@mixin display-box {
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox; // IE 10
display: box;
}
@mixin box-orient($orient: inline-axis) {
// horizontal|vertical|inline-axis|block-axis|inherit
@include prefixer(box-orient, $orient, webkit moz spec);
}
@mixin box-pack($pack: start) {
// start|end|center|justify
@include prefixer(box-pack, $pack, webkit moz spec);
-ms-flex-pack: $pack; // IE 10
}
@mixin box-align($align: stretch) {
// start|end|center|baseline|stretch
@include prefixer(box-align, $align, webkit moz spec);
-ms-flex-align: $align; // IE 10
}
@mixin box-direction($direction: normal) {
// normal|reverse|inherit
@include prefixer(box-direction, $direction, webkit moz spec);
-ms-flex-direction: $direction; // IE 10
}
@mixin box-lines($lines: single) {
// single|multiple
@include prefixer(box-lines, $lines, webkit moz spec);
}
@mixin box-ordinal-group($int: 1) {
@include prefixer(box-ordinal-group, $int, webkit moz spec);
-ms-flex-order: $int; // IE 10
}
@mixin box-flex($value: 0) {
@include prefixer(box-flex, $value, webkit moz spec);
-ms-flex: $value; // IE 10
}
@mixin box-flex-group($int: 1) {
@include prefixer(box-flex-group, $int, webkit moz spec);
}
// CSS3 Flexible Box Model and property defaults
// Unified attributes for 2009, 2011, and 2012 flavours.
// 2009 - display (box | inline-box)
// 2011 - display (flexbox | inline-flexbox)
// 2012 - display (flex | inline-flex)
@mixin display($value) {
// flex | inline-flex
@if $value == "flex" {
// 2009
display: -webkit-box;
display: -moz-box;
display: box;
// 2012
display: -webkit-flex;
display: -moz-flex;
display: -ms-flexbox; // 2011 (IE 10)
display: flex;
} @else if $value == "inline-flex" {
display: -webkit-inline-box;
display: -moz-inline-box;
display: inline-box;
display: -webkit-inline-flex;
display: -moz-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
} @else {
display: $value;
}
}
// 2009 - box-flex (integer)
// 2011 - flex (decimal | width decimal)
// 2012 - flex (integer integer width)
@mixin flex($value) {
// Grab flex-grow for older browsers.
$flex-grow: nth($value, 1);
// 2009
@include prefixer(box-flex, $flex-grow, webkit moz spec);
// 2011 (IE 10), 2012
@include prefixer(flex, $value, webkit moz ms spec);
}
// 2009 - box-orient ( horizontal | vertical | inline-axis | block-axis)
// - box-direction (normal | reverse)
// 2011 - flex-direction (row | row-reverse | column | column-reverse)
// 2012 - flex-direction (row | row-reverse | column | column-reverse)
@mixin flex-direction($value: row) {
// Alt values.
$value-2009: $value;
$value-2011: $value;
$direction: normal;
@if $value == row {
$value-2009: horizontal;
} @else if $value == "row-reverse" {
$value-2009: horizontal;
$direction: reverse;
} @else if $value == column {
$value-2009: vertical;
} @else if $value == "column-reverse" {
$value-2009: vertical;
$direction: reverse;
}
// 2009
@include prefixer(box-orient, $value-2009, webkit moz spec);
@include prefixer(box-direction, $direction, webkit moz spec);
// 2012
@include prefixer(flex-direction, $value, webkit moz spec);
// 2011 (IE 10)
-ms-flex-direction: $value;
}
// 2009 - box-lines (single | multiple)
// 2011 - flex-wrap (nowrap | wrap | wrap-reverse)
// 2012 - flex-wrap (nowrap | wrap | wrap-reverse)
@mixin flex-wrap($value: nowrap) {
// Alt values
$alt-value: $value;
@if $value == nowrap {
$alt-value: single;
} @else if $value == wrap {
$alt-value: multiple;
} @else if $value == "wrap-reverse" {
$alt-value: multiple;
}
@include prefixer(box-lines, $alt-value, webkit moz spec);
@include prefixer(flex-wrap, $value, webkit moz ms spec);
}
// 2009 - TODO: parse values into flex-direction/flex-wrap
// 2011 - TODO: parse values into flex-direction/flex-wrap
// 2012 - flex-flow (flex-direction || flex-wrap)
@mixin flex-flow($value) {
@include prefixer(flex-flow, $value, webkit moz spec);
}
// 2009 - box-ordinal-group (integer)
// 2011 - flex-order (integer)
// 2012 - order (integer)
@mixin order($int: 0) {
// 2009
@include prefixer(box-ordinal-group, $int, webkit moz spec);
// 2012
@include prefixer(order, $int, webkit moz spec);
// 2011 (IE 10)
-ms-flex-order: $int;
}
// 2012 - flex-grow (number)
@mixin flex-grow($number: 0) {
@include prefixer(flex-grow, $number, webkit moz spec);
-ms-flex-positive: $number;
}
// 2012 - flex-shrink (number)
@mixin flex-shrink($number: 1) {
@include prefixer(flex-shrink, $number, webkit moz spec);
-ms-flex-negative: $number;
}
// 2012 - flex-basis (number)
@mixin flex-basis($width: auto) {
@include prefixer(flex-basis, $width, webkit moz spec);
-ms-flex-preferred-size: $width;
}
// 2009 - box-pack (start | end | center | justify)
// 2011 - flex-pack (start | end | center | justify)
// 2012 - justify-content (flex-start | flex-end | center | space-between | space-around)
@mixin justify-content($value: flex-start) {
// Alt values.
$alt-value: $value;
@if $value == "flex-start" {
$alt-value: start;
} @else if $value == "flex-end" {
$alt-value: end;
} @else if $value == "space-between" {
$alt-value: justify;
} @else if $value == "space-around" {
$alt-value: distribute;
}
// 2009
@include prefixer(box-pack, $alt-value, webkit moz spec);
// 2012
@include prefixer(justify-content, $value, webkit moz ms o spec);
// 2011 (IE 10)
-ms-flex-pack: $alt-value;
}
// 2009 - box-align (start | end | center | baseline | stretch)
// 2011 - flex-align (start | end | center | baseline | stretch)
// 2012 - align-items (flex-start | flex-end | center | baseline | stretch)
@mixin align-items($value: stretch) {
$alt-value: $value;
@if $value == "flex-start" {
$alt-value: start;
} @else if $value == "flex-end" {
$alt-value: end;
}
// 2009
@include prefixer(box-align, $alt-value, webkit moz spec);
// 2012
@include prefixer(align-items, $value, webkit moz ms o spec);
// 2011 (IE 10)
-ms-flex-align: $alt-value;
}
// 2011 - flex-item-align (auto | start | end | center | baseline | stretch)
// 2012 - align-self (auto | flex-start | flex-end | center | baseline | stretch)
@mixin align-self($value: auto) {
$value-2011: $value;
@if $value == "flex-start" {
$value-2011: start;
} @else if $value == "flex-end" {
$value-2011: end;
}
// 2012
@include prefixer(align-self, $value, webkit moz spec);
// 2011 (IE 10)
-ms-flex-item-align: $value-2011;
}
// 2011 - flex-line-pack (start | end | center | justify | distribute | stretch)
// 2012 - align-content (flex-start | flex-end | center | space-between | space-around | stretch)
@mixin align-content($value: stretch) {
$value-2011: $value;
@if $value == "flex-start" {
$value-2011: start;
} @else if $value == "flex-end" {
$value-2011: end;
} @else if $value == "space-between" {
$value-2011: justify;
} @else if $value == "space-around" {
$value-2011: distribute;
}
// 2012
@include prefixer(align-content, $value, webkit moz spec);
// 2011 (IE 10)
-ms-flex-line-pack: $value-2011;
}
@@ -0,0 +1,24 @@
@mixin font-face(
$font-family,
$file-path,
$weight: normal,
$style: normal,
$asset-pipeline: $asset-pipeline,
$file-formats: eot woff2 woff ttf svg) {
$font-url-prefix: font-url-prefixer($asset-pipeline);
@font-face {
font-family: $font-family;
font-style: $style;
font-weight: $weight;
src: font-source-declaration(
$font-family,
$file-path,
$asset-pipeline,
$file-formats,
$font-url-prefix
);
}
}
@@ -0,0 +1,4 @@
@mixin font-feature-settings($settings...) {
@if length($settings) == 0 { $settings: none; }
@include prefixer(font-feature-settings, $settings, webkit moz ms spec);
}
@@ -0,0 +1,10 @@
// HiDPI mixin. Default value set to 1.3 to target Google Nexus 7 (http://bjango.com/articles/min-device-pixel-ratio/)
@mixin hidpi($ratio: 1.3) {
@media only screen and (-webkit-min-device-pixel-ratio: $ratio),
only screen and (min--moz-device-pixel-ratio: $ratio),
only screen and (-o-min-device-pixel-ratio: #{$ratio}/1),
only screen and (min-resolution: round($ratio * 96dpi)),
only screen and (min-resolution: $ratio * 1dppx) {
@content;
}
}
@@ -0,0 +1,4 @@
@mixin hyphens($hyphenation: none) {
// none | manual | auto
@include prefixer(hyphens, $hyphenation, webkit moz ms spec);
}
@@ -0,0 +1,14 @@
@mixin image-rendering ($mode:auto) {
@if ($mode == crisp-edges) {
-ms-interpolation-mode: nearest-neighbor; // IE8+
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
@else {
image-rendering: $mode;
}
}
@@ -0,0 +1,36 @@
// Adds keyframes blocks for supported prefixes, removing redundant prefixes in the block's content
@mixin keyframes($name) {
$original-prefix-for-webkit: $prefix-for-webkit;
$original-prefix-for-mozilla: $prefix-for-mozilla;
$original-prefix-for-microsoft: $prefix-for-microsoft;
$original-prefix-for-opera: $prefix-for-opera;
$original-prefix-for-spec: $prefix-for-spec;
@if $original-prefix-for-webkit {
@include disable-prefix-for-all();
$prefix-for-webkit: true !global;
@-webkit-keyframes #{$name} {
@content;
}
}
@if $original-prefix-for-mozilla {
@include disable-prefix-for-all();
$prefix-for-mozilla: true !global;
@-moz-keyframes #{$name} {
@content;
}
}
$prefix-for-webkit: $original-prefix-for-webkit !global;
$prefix-for-mozilla: $original-prefix-for-mozilla !global;
$prefix-for-microsoft: $original-prefix-for-microsoft !global;
$prefix-for-opera: $original-prefix-for-opera !global;
$prefix-for-spec: $original-prefix-for-spec !global;
@if $original-prefix-for-spec {
@keyframes #{$name} {
@content;
}
}
}
@@ -0,0 +1,38 @@
@mixin linear-gradient($pos, $g1, $g2: null,
$g3: null, $g4: null,
$g5: null, $g6: null,
$g7: null, $g8: null,
$g9: null, $g10: null,
$fallback: null) {
// Detect what type of value exists in $pos
$pos-type: type-of(nth($pos, 1));
$pos-spec: null;
$pos-degree: null;
// If $pos is missing from mixin, reassign vars and add default position
@if ($pos-type == color) or (nth($pos, 1) == "transparent") {
$g10: $g9; $g9: $g8; $g8: $g7; $g7: $g6; $g6: $g5;
$g5: $g4; $g4: $g3; $g3: $g2; $g2: $g1; $g1: $pos;
$pos: null;
}
@if $pos {
$positions: _linear-positions-parser($pos);
$pos-degree: nth($positions, 1);
$pos-spec: nth($positions, 2);
}
$full: $g1, $g2, $g3, $g4, $g5, $g6, $g7, $g8, $g9, $g10;
// Set $g1 as the default fallback color
$fallback-color: nth($g1, 1);
// If $fallback is a color use that color as the fallback color
@if (type-of($fallback) == color) or ($fallback == "transparent") {
$fallback-color: $fallback;
}
background-color: $fallback-color;
background-image: -webkit-linear-gradient($pos-degree $full); // Safari 5.1+, Chrome
background-image: unquote("linear-gradient(#{$pos-spec}#{$full})");
}
@@ -0,0 +1,8 @@
@mixin perspective($depth: none) {
// none | <length>
@include prefixer(perspective, $depth, webkit moz spec);
}
@mixin perspective-origin($value: 50% 50%) {
@include prefixer(perspective-origin, $value, webkit moz spec);
}
@@ -0,0 +1,8 @@
@mixin placeholder {
$placeholders: ":-webkit-input" ":-moz" "-moz" "-ms-input";
@each $placeholder in $placeholders {
&:#{$placeholder}-placeholder {
@content;
}
}
}
@@ -0,0 +1,39 @@
// Requires Sass 3.1+
@mixin radial-gradient($g1, $g2,
$g3: null, $g4: null,
$g5: null, $g6: null,
$g7: null, $g8: null,
$g9: null, $g10: null,
$pos: null,
$shape-size: null,
$fallback: null) {
$data: _radial-arg-parser($g1, $g2, $pos, $shape-size);
$g1: nth($data, 1);
$g2: nth($data, 2);
$pos: nth($data, 3);
$shape-size: nth($data, 4);
$full: $g1, $g2, $g3, $g4, $g5, $g6, $g7, $g8, $g9, $g10;
// Strip deprecated cover/contain for spec
$shape-size-spec: _shape-size-stripper($shape-size);
// Set $g1 as the default fallback color
$first-color: nth($full, 1);
$fallback-color: nth($first-color, 1);
@if (type-of($fallback) == color) or ($fallback == "transparent") {
$fallback-color: $fallback;
}
// Add Commas and spaces
$shape-size: if($shape-size, "#{$shape-size}, ", null);
$pos: if($pos, "#{$pos}, ", null);
$pos-spec: if($pos, "at #{$pos}", null);
$shape-size-spec: if(($shape-size-spec != " ") and ($pos == null), "#{$shape-size-spec}, ", "#{$shape-size-spec} ");
background-color: $fallback-color;
background-image: -webkit-radial-gradient(#{$pos}#{$shape-size}#{$full});
background-image: radial-gradient(#{$shape-size-spec}#{$pos-spec}#{$full});
}
@@ -0,0 +1,42 @@
@charset "UTF-8";
/// Outputs the spec and prefixed versions of the `::selection` pseudo-element.
///
/// @param {Bool} $current-selector [false]
/// If set to `true`, it takes the current element into consideration.
///
/// @example scss - Usage
/// .element {
/// @include selection(true) {
/// background-color: #ffbb52;
/// }
/// }
///
/// @example css - CSS Output
/// .element::-moz-selection {
/// background-color: #ffbb52;
/// }
///
/// .element::selection {
/// background-color: #ffbb52;
/// }
@mixin selection($current-selector: false) {
@if $current-selector {
&::-moz-selection {
@content;
}
&::selection {
@content;
}
} @else {
::-moz-selection {
@content;
}
::selection {
@content;
}
}
}
@@ -0,0 +1,19 @@
@mixin text-decoration($value) {
// <text-decoration-line> || <text-decoration-style> || <text-decoration-color>
@include prefixer(text-decoration, $value, moz);
}
@mixin text-decoration-line($line: none) {
// none || underline || overline || line-through
@include prefixer(text-decoration-line, $line, moz);
}
@mixin text-decoration-style($style: solid) {
// solid || double || dotted || dashed || wavy
@include prefixer(text-decoration-style, $style, moz webkit);
}
@mixin text-decoration-color($color: currentColor) {
// currentColor || <color>
@include prefixer(text-decoration-color, $color, moz);
}
@@ -0,0 +1,15 @@
@mixin transform($property: none) {
// none | <transform-function>
@include prefixer(transform, $property, webkit moz ms o spec);
}
@mixin transform-origin($axes: 50%) {
// x-axis - left | center | right | length | %
// y-axis - top | center | bottom | length | %
// z-axis - length
@include prefixer(transform-origin, $axes, webkit moz ms o spec);
}
@mixin transform-style($style: flat) {
@include prefixer(transform-style, $style, webkit moz ms o spec);
}
@@ -0,0 +1,71 @@
// Shorthand mixin. Supports multiple parentheses-deliminated values for each variable.
// Example: @include transition (all 2s ease-in-out);
// @include transition (opacity 1s ease-in 2s, width 2s ease-out);
// @include transition-property (transform, opacity);
@mixin transition($properties...) {
// Fix for vendor-prefix transform property
$needs-prefixes: false;
$webkit: ();
$moz: ();
$spec: ();
// Create lists for vendor-prefixed transform
@each $list in $properties {
@if nth($list, 1) == "transform" {
$needs-prefixes: true;
$list1: -webkit-transform;
$list2: -moz-transform;
$list3: ();
@each $var in $list {
$list3: join($list3, $var);
@if $var != "transform" {
$list1: join($list1, $var);
$list2: join($list2, $var);
}
}
$webkit: append($webkit, $list1);
$moz: append($moz, $list2);
$spec: append($spec, $list3);
} @else {
$webkit: append($webkit, $list, comma);
$moz: append($moz, $list, comma);
$spec: append($spec, $list, comma);
}
}
@if $needs-prefixes {
-webkit-transition: $webkit;
-moz-transition: $moz;
transition: $spec;
} @else {
@if length($properties) >= 1 {
@include prefixer(transition, $properties, webkit moz spec);
} @else {
$properties: all 0.15s ease-out 0s;
@include prefixer(transition, $properties, webkit moz spec);
}
}
}
@mixin transition-property($properties...) {
-webkit-transition-property: transition-property-names($properties, "webkit");
-moz-transition-property: transition-property-names($properties, "moz");
transition-property: transition-property-names($properties, false);
}
@mixin transition-duration($times...) {
@include prefixer(transition-duration, $times, webkit moz spec);
}
@mixin transition-timing-function($motions...) {
// ease | linear | ease-in | ease-out | ease-in-out | cubic-bezier()
@include prefixer(transition-timing-function, $motions, webkit moz spec);
}
@mixin transition-delay($times...) {
@include prefixer(transition-delay, $times, webkit moz spec);
}
@@ -0,0 +1,3 @@
@mixin user-select($value: none) {
@include prefixer(user-select, $value, webkit moz ms spec);
}
@@ -0,0 +1,11 @@
@function assign-inputs($inputs, $pseudo: null) {
$list: ();
@each $input in $inputs {
$input: unquote($input);
$input: if($pseudo, $input + ":" + $pseudo, $input);
$list: append($list, $input, comma);
}
@return $list;
}
@@ -0,0 +1,20 @@
@charset "UTF-8";
/// Checks if a list does not contains a value.
///
/// @access private
///
/// @param {List} $list
/// The list to check against.
///
/// @return {Bool}
@function contains-falsy($list) {
@each $item in $list {
@if not $item {
@return true;
}
}
@return false;
}
@@ -0,0 +1,26 @@
@charset "UTF-8";
/// Checks if a list contains a value(s).
///
/// @access private
///
/// @param {List} $list
/// The list to check against.
///
/// @param {List} $values
/// A single value or list of values to check for.
///
/// @example scss - Usage
/// contains($list, $value)
///
/// @return {Bool}
@function contains($list, $values...) {
@each $value in $values {
@if type-of(index($list, $value)) != "number" {
@return false;
}
}
@return true;
}
@@ -0,0 +1,11 @@
@charset "UTF-8";
/// Checks for a valid CSS length.
///
/// @param {String} $value
@function is-length($value) {
@return type-of($value) != "null" and (str-slice($value + "", 1, 4) == "calc"
or index(auto inherit initial 0, $value)
or (type-of($value) == "number" and not(unitless($value))));
}
@@ -0,0 +1,21 @@
@charset "UTF-8";
/// Programatically determines whether a color is light or dark.
///
/// @link http://robots.thoughtbot.com/closer-look-color-lightness
///
/// @param {Color (Hex)} $color
///
/// @example scss - Usage
/// is-light($color)
///
/// @return {Bool}
@function is-light($hex-color) {
$-local-red: red(rgba($hex-color, 1));
$-local-green: green(rgba($hex-color, 1));
$-local-blue: blue(rgba($hex-color, 1));
$-local-lightness: ($-local-red * 0.2126 + $-local-green * 0.7152 + $-local-blue * 0.0722) / 255;
@return $-local-lightness > 0.6;
}
@@ -0,0 +1,11 @@
@charset "UTF-8";
/// Checks for a valid number.
///
/// @param {Number} $value
///
/// @require {function} contains
@function is-number($value) {
@return contains("0" "1" "2" "3" "4" "5" "6" "7" "8" "9" 0 1 2 3 4 5 6 7 8 9, $value);
}
@@ -0,0 +1,13 @@
@charset "UTF-8";
/// Checks for a valid CSS size.
///
/// @param {String} $value
///
/// @require {function} contains
/// @require {function} is-length
@function is-size($value) {
@return is-length($value)
or contains("fill" "fit-content" "min-content" "max-content", $value);
}
@@ -0,0 +1,69 @@
// Scaling Variables
$golden: 1.618;
$minor-second: 1.067;
$major-second: 1.125;
$minor-third: 1.2;
$major-third: 1.25;
$perfect-fourth: 1.333;
$augmented-fourth: 1.414;
$perfect-fifth: 1.5;
$minor-sixth: 1.6;
$major-sixth: 1.667;
$minor-seventh: 1.778;
$major-seventh: 1.875;
$octave: 2;
$major-tenth: 2.5;
$major-eleventh: 2.667;
$major-twelfth: 3;
$double-octave: 4;
$modular-scale-ratio: $perfect-fourth !default;
$modular-scale-base: em($em-base) !default;
@function modular-scale($increment, $value: $modular-scale-base, $ratio: $modular-scale-ratio) {
$v1: nth($value, 1);
$v2: nth($value, length($value));
$value: $v1;
// scale $v2 to just above $v1
@while $v2 > $v1 {
$v2: ($v2 / $ratio); // will be off-by-1
}
@while $v2 < $v1 {
$v2: ($v2 * $ratio); // will fix off-by-1
}
// check AFTER scaling $v2 to prevent double-counting corner-case
$double-stranded: $v2 > $v1;
@if $increment > 0 {
@for $i from 1 through $increment {
@if $double-stranded and ($v1 * $ratio) > $v2 {
$value: $v2;
$v2: ($v2 * $ratio);
} @else {
$v1: ($v1 * $ratio);
$value: $v1;
}
}
}
@if $increment < 0 {
// adjust $v2 to just below $v1
@if $double-stranded {
$v2: ($v2 / $ratio);
}
@for $i from $increment through -1 {
@if $double-stranded and ($v1 / $ratio) < $v2 {
$value: $v2;
$v2: ($v2 / $ratio);
} @else {
$v1: ($v1 / $ratio);
$value: $v1;
}
}
}
@return $value;
}
@@ -0,0 +1,13 @@
// Convert pixels to ems
// eg. for a relational value of 12px write em(12) when the parent is 16px
// if the parent is another value say 24px write em(12, 24)
@function em($pxval, $base: $em-base) {
@if not unitless($pxval) {
$pxval: strip-units($pxval);
}
@if not unitless($base) {
$base: strip-units($base);
}
@return ($pxval / $base) * 1em;
}
@@ -0,0 +1,15 @@
// Convert pixels to rems
// eg. for a relational value of 12px write rem(12)
// Assumes $em-base is the font-size of <html>
@function rem($pxval) {
@if not unitless($pxval) {
$pxval: strip-units($pxval);
}
$base: $em-base;
@if not unitless($base) {
$base: strip-units($base);
}
@return ($pxval / $base) * 1rem;
}
@@ -0,0 +1,24 @@
@charset "UTF-8";
/// Mixes a color with black.
///
/// @param {Color} $color
///
/// @param {Number (Percentage)} $percent
/// The amount of black to be mixed in.
///
/// @example scss - Usage
/// .element {
/// background-color: shade(#ffbb52, 60%);
/// }
///
/// @example css - CSS Output
/// .element {
/// background-color: #664a20;
/// }
///
/// @return {Color}
@function shade($color, $percent) {
@return mix(#000, $color, $percent);
}
@@ -0,0 +1,17 @@
@charset "UTF-8";
/// Strips the unit from a number.
///
/// @param {Number (With Unit)} $value
///
/// @example scss - Usage
/// $dimension: strip-units(10em);
///
/// @example css - CSS Output
/// $dimension: 10;
///
/// @return {Number (Unitless)}
@function strip-units($value) {
@return ($value / ($value * 0 + 1));
}
@@ -0,0 +1,24 @@
@charset "UTF-8";
/// Mixes a color with white.
///
/// @param {Color} $color
///
/// @param {Number (Percentage)} $percent
/// The amount of white to be mixed in.
///
/// @example scss - Usage
/// .element {
/// background-color: tint(#6ecaa6, 40%);
/// }
///
/// @example css - CSS Output
/// .element {
/// background-color: #a8dfc9;
/// }
///
/// @return {Color}
@function tint($color, $percent) {
@return mix(#fff, $color, $percent);
}
@@ -0,0 +1,22 @@
// Return vendor-prefixed property names if appropriate
// Example: transition-property-names((transform, color, background), moz) -> -moz-transform, color, background
//************************************************************************//
@function transition-property-names($props, $vendor: false) {
$new-props: ();
@each $prop in $props {
$new-props: append($new-props, transition-property-name($prop, $vendor), comma);
}
@return $new-props;
}
@function transition-property-name($prop, $vendor: false) {
// put other properties that need to be prefixed here aswell
@if $vendor and $prop == transform {
@return unquote('-'+$vendor+'-'+$prop);
}
@else {
@return $prop;
}
}

Some files were not shown because too many files have changed in this diff Show More