add-cms
This commit is contained in:
390
source/admin/packages/decap-cms-lib-util/CHANGELOG.md
Normal file
390
source/admin/packages/decap-cms-lib-util/CHANGELOG.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.3.1](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.3.0...decap-cms-lib-util@3.3.1) (2025-07-31)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [3.3.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.2.0...decap-cms-lib-util@3.3.0) (2025-06-26)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [3.2.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.1.0...decap-cms-lib-util@3.2.0) (2025-01-29)
|
||||
|
||||
### Features
|
||||
|
||||
- visual editing (click-to-edit) ([#7374](https://github.com/decaporg/decap-cms/issues/7374)) ([989c2dd](https://github.com/decaporg/decap-cms/commit/989c2dd6ed80f69b572b8b73c4e37b5106ae04fb))
|
||||
|
||||
# [3.1.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.0.4...decap-cms-lib-util@3.1.0) (2024-08-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **backend:** allow a custom API root for backend ([#7214](https://github.com/decaporg/decap-cms/issues/7214)) ([fae3e05](https://github.com/decaporg/decap-cms/commit/fae3e057f898f60fdbe80091acc833d6ac92696e)), closes [#7168](https://github.com/decaporg/decap-cms/issues/7168)
|
||||
|
||||
## [3.0.4](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.0.3...decap-cms-lib-util@3.0.4) (2024-04-03)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [3.0.3](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.0.2...decap-cms-lib-util@3.0.3) (2024-03-21)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [3.0.2](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.0.2-beta.0...decap-cms-lib-util@3.0.2) (2024-02-01)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [3.0.2-beta.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.0.1...decap-cms-lib-util@3.0.2-beta.0) (2024-01-31)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [3.0.1](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@3.0.0...decap-cms-lib-util@3.0.1) (2023-09-06)
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- filter by path when loading collection from github backend ([#6898](https://github.com/decaporg/decap-cms/issues/6898)) ([18ef773](https://github.com/decaporg/decap-cms/commit/18ef773f35db1b7ef3ab5a0f25527d87745b9c73))
|
||||
|
||||
# [3.0.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@2.16.0...decap-cms-lib-util@3.0.0) (2023-08-18)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.16.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@2.16.0-beta.0...decap-cms-lib-util@2.16.0) (2023-08-18)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# 2.16.0-beta.0 (2023-08-18)
|
||||
|
||||
### Features
|
||||
|
||||
- rename packages ([#6863](https://github.com/decaporg/decap-cms/issues/6863)) ([d515e7b](https://github.com/decaporg/decap-cms/commit/d515e7bd33216a775d96887b08c4f7b1962941bb))
|
||||
|
||||
## [2.15.2-beta.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@2.15.1...decap-cms-lib-util@2.15.2-beta.0) (2023-07-27)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.15.1](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@2.15.0...decap-cms-lib-util@2.15.1) (2022-04-13)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.15.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@2.14.0...decap-cms-lib-util@2.15.0) (2021-12-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **backend-gitlab:** initial GraphQL support ([#6059](https://github.com/decaporg/decap-cms/issues/6059)) ([1523a41](https://github.com/decaporg/decap-cms/commit/1523a4140a3d2f4cc01a1548514ae17bc1ad504e))
|
||||
|
||||
# [2.14.0](https://github.com/decaporg/decap-cms/compare/decap-cms-lib-util@2.13.3...decap-cms-lib-util@2.14.0) (2021-10-18)
|
||||
|
||||
### Features
|
||||
|
||||
- display author of changes in workflow tab ([#5780](https://github.com/decaporg/decap-cms/issues/5780)) ([3f607e4](https://github.com/decaporg/decap-cms/commit/3f607e41d9c4d8fe5329a9ab6841cada7742825e))
|
||||
|
||||
## [2.13.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.13.2...decap-cms-lib-util@2.13.3) (2021-06-01)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.13.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.13.1...decap-cms-lib-util@2.13.2) (2021-05-31)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.13.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.13.0...decap-cms-lib-util@2.13.1) (2021-05-19)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.13.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.12.3...decap-cms-lib-util@2.13.0) (2021-04-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **open-authoring:** add alwaysFork option ([#5204](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/5204)) ([7b19e30](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/7b19e30dd2a310dbc20ccb6fcca45d5cbde1014b))
|
||||
|
||||
## [2.12.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.12.2...decap-cms-lib-util@2.12.3) (2021-02-10)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.12.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.12.1...decap-cms-lib-util@2.12.2) (2021-02-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **backend:** allow calling 'json' again on 403 failure ([#4880](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/4880)) ([1034086](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/1034086ff603e28e3a37c8157a74cbc625f658f9))
|
||||
|
||||
## [2.12.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.12.0...decap-cms-lib-util@2.12.1) (2020-12-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **lib-util:** update js-sha256 import ([#4699](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/4699)) ([60f7e0b](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/60f7e0bba67c6555acba3e04039c49f10cf51f6b))
|
||||
|
||||
# [2.12.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.11.5...decap-cms-lib-util@2.12.0) (2020-11-26)
|
||||
|
||||
### Features
|
||||
|
||||
- add azure devops backend ([#4427](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/4427)) ([4e6dc88](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/4e6dc88efb1dae4cf6137730c3b4fb6d0f75a8cc))
|
||||
|
||||
## [2.11.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.11.4...decap-cms-lib-util@2.11.5) (2020-09-20)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.11.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.11.3...decap-cms-lib-util@2.11.4) (2020-09-15)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## 2.11.3 (2020-09-08)
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "chore(release): publish" ([828bb16](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/828bb16415b8c22a34caa19c50c38b24ffe9ceae))
|
||||
|
||||
## 2.11.2 (2020-08-20)
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "chore(release): publish" ([8262487](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/82624879ccbcb16610090041db28f00714d924c8))
|
||||
|
||||
## 2.11.1 (2020-07-27)
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "chore(release): publish" ([118d50a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/118d50a7a70295f25073e564b5161aa2b9883056))
|
||||
|
||||
# [2.11.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.10.0...decap-cms-lib-util@2.11.0) (2020-06-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- handle token expiry ([#3847](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3847)) ([285c940](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/285c940562548d7bc88de244123ba87ff66fba65))
|
||||
|
||||
### Features
|
||||
|
||||
- add backend status down indicator ([#3889](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3889)) ([a50edc7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/a50edc70553ad6afa1acee6a51996ad226443f8c))
|
||||
|
||||
# [2.10.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.9.4...decap-cms-lib-util@2.10.0) (2020-06-01)
|
||||
|
||||
### Features
|
||||
|
||||
- add pre save/ post save hooks ([#3812](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3812)) ([812716e](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/812716e18b09a716547f128b783c8e6f3d54cc5b))
|
||||
|
||||
## [2.9.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.9.3...decap-cms-lib-util@2.9.4) (2020-05-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- clear fetch timeout on successful response ([#3768](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3768)) ([088b1a8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/088b1a8ab626498382d6305fa46d174d4f5ba755))
|
||||
|
||||
## [2.9.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.9.2...decap-cms-lib-util@2.9.3) (2020-04-21)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.9.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.9.1...decap-cms-lib-util@2.9.2) (2020-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- move common api functions to a separate file ([#3511](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3511)) ([49098de](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/49098de27f053e51aa3d936d09adae3a7186c6ae))
|
||||
|
||||
## [2.9.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.9.0...decap-cms-lib-util@2.9.1) (2020-04-01)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.9.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.8.0...decap-cms-lib-util@2.9.0) (2020-03-12)
|
||||
|
||||
### Features
|
||||
|
||||
- add media lib virtualization ([#3381](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3381)) ([92e7601](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/92e76011e7a9e8b5370088b0a2c065df66b5f7fb))
|
||||
- **backend-github:** add pagination ([#3379](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3379)) ([39f1307](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/39f1307e3a36447da8c9b3ca79b1d7db52ea1a19))
|
||||
|
||||
# [2.8.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.7.1...decap-cms-lib-util@2.8.0) (2020-02-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **core:** align GitHub metadata handling with other backends ([#3316](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3316)) ([7e0a8ad](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/7e0a8ad532012576dc5e40bd4e9d54522e307123)), closes [#3292](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3292)
|
||||
|
||||
## [2.7.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.7.0...decap-cms-lib-util@2.7.1) (2020-02-14)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.7.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.6.2...decap-cms-lib-util@2.7.0) (2020-02-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- filter paginated results ([#3216](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3216)) ([0a482b1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/0a482b10049bcfa022b81165cabf4512d77e0b88))
|
||||
|
||||
### Features
|
||||
|
||||
- field based media/public folders ([#3208](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3208)) ([97bc0c8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/97bc0c8dc489e736f89d748ba832d78400fe4332))
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "chore(release): publish" ([a015d1d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/a015d1d92a4b1c0130c44fcef1c9ecdb157a0f07))
|
||||
|
||||
## [2.6.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.6.1...decap-cms-lib-util@2.6.2) (2020-01-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **backend-git-gateway:** re-write GitHub pagination links ([#3135](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3135)) ([834f6b9](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/834f6b9e457f3738ce0f240ddd4cc160aff9e2f5))
|
||||
|
||||
## [2.6.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.6.0...decap-cms-lib-util@2.6.1) (2020-01-22)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.6.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.5.2...decap-cms-lib-util@2.6.0) (2020-01-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **backend-bitbucket:** Add Git-LFS support ([#3118](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3118)) ([a48c02d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/a48c02d852ca5e11055da3a14cefae8d17a68498))
|
||||
|
||||
## [2.5.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.5.1...decap-cms-lib-util@2.5.2) (2020-01-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- use string endsWith to filter by extension ([#3097](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/3097)) ([6a13a85](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/6a13a85e269c8f299f71b3f5ee45fc5d34f75822))
|
||||
|
||||
## [2.5.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.5.0...decap-cms-lib-util@2.5.1) (2020-01-14)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.5.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.5.0-beta.0...decap-cms-lib-util@2.5.0) (2020-01-07)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.5.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0...decap-cms-lib-util@2.5.0-beta.0) (2019-12-18)
|
||||
|
||||
### Features
|
||||
|
||||
- bundle assets with content ([#2958](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2958)) ([2b41d8a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/2b41d8a838a9c8a6b21cde2ddd16b9288334e298))
|
||||
|
||||
# [2.4.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0-beta.5...decap-cms-lib-util@2.4.0) (2019-12-18)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.4.0-beta.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0-beta.4...decap-cms-lib-util@2.4.0-beta.5) (2019-11-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **backend-github:** editorial workflow commits ([#2867](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2867)) ([86adca3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/86adca3a18f25ab74d1c6702bafab250f005ceec))
|
||||
- **backend-github:** prepend collection name ([#2878](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2878)) ([465f463](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/465f4639597f258d5aa2c1b65e9d2c16023ee7ae))
|
||||
|
||||
# [2.4.0-beta.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0-beta.3...decap-cms-lib-util@2.4.0-beta.4) (2019-09-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **github-backend:** handle race condition in editorial workflow ([#2658](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2658)) ([97f1f84](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/97f1f84))
|
||||
|
||||
# [2.4.0-beta.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0-beta.2...decap-cms-lib-util@2.4.0-beta.3) (2019-09-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **backend-github:** GitHub GraphQL API support ([#2456](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2456)) ([ece136c](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/ece136c))
|
||||
|
||||
# [2.4.0-beta.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0-beta.1...decap-cms-lib-util@2.4.0-beta.2) (2019-08-24)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.4.0-beta.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.4.0-beta.0...decap-cms-lib-util@2.4.0-beta.1) (2019-08-24)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.4.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.3...decap-cms-lib-util@2.4.0-beta.0) (2019-07-24)
|
||||
|
||||
### Features
|
||||
|
||||
- **backend-github:** Open Authoring ([#2430](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2430)) ([edf0a3a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/edf0a3a))
|
||||
|
||||
## [2.3.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.2...decap-cms-lib-util@2.3.3) (2019-07-24)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.3.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.2-beta.0...decap-cms-lib-util@2.3.2) (2019-04-10)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.3.2-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.1...decap-cms-lib-util@2.3.2-beta.0) (2019-04-05)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.3.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.1-beta.1...decap-cms-lib-util@2.3.1) (2019-03-29)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.3.1-beta.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.1-beta.0...decap-cms-lib-util@2.3.1-beta.1) (2019-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- export on decap-cms and maps on esm ([#2244](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2244)) ([6ffd13b](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/6ffd13b))
|
||||
|
||||
## [2.3.1-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.3.0...decap-cms-lib-util@2.3.1-beta.0) (2019-03-25)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.3.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.2.0...decap-cms-lib-util@2.3.0) (2019-03-22)
|
||||
|
||||
### Features
|
||||
|
||||
- add ES module builds ([#2215](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2215)) ([d142b32](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/d142b32))
|
||||
|
||||
# [2.2.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.2.0-beta.0...decap-cms-lib-util@2.2.0) (2019-03-22)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
# [2.2.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.1.3-beta.0...decap-cms-lib-util@2.2.0-beta.0) (2019-03-21)
|
||||
|
||||
### Features
|
||||
|
||||
- provide usable UMD builds for all packages ([#2141](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2141)) ([82cc794](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/82cc794))
|
||||
|
||||
## [2.1.3-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.1.2...decap-cms-lib-util@2.1.3-beta.0) (2019-03-15)
|
||||
|
||||
### Features
|
||||
|
||||
- upgrade to Emotion 10 ([#2166](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/2166)) ([ccef446](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/ccef446))
|
||||
|
||||
## [2.1.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.1.1...decap-cms-lib-util@2.1.2) (2019-02-26)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
## [2.1.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.1.0...decap-cms-lib-util@2.1.1) (2018-11-29)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
<a name="2.1.0"></a>
|
||||
|
||||
# [2.1.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.0.5...decap-cms-lib-util@2.1.0) (2018-09-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- remove alert modal from localForage error case ([#1676](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/1676)) ([4f3116d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/4f3116d))
|
||||
|
||||
### Features
|
||||
|
||||
- **media:** add external media library support, Uploadcare integration ([#1602](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/issues/1602)) ([0596904](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/commit/0596904))
|
||||
|
||||
<a name="2.0.5"></a>
|
||||
|
||||
## [2.0.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.0.4...decap-cms-lib-util@2.0.5) (2018-08-24)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
<a name="2.0.4"></a>
|
||||
|
||||
## [2.0.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.0.3...decap-cms-lib-util@2.0.4) (2018-08-07)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
<a name="2.0.3"></a>
|
||||
|
||||
## [2.0.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.0.2...decap-cms-lib-util@2.0.3) (2018-08-01)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
<a name="2.0.2"></a>
|
||||
|
||||
## [2.0.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util/compare/decap-cms-lib-util@2.0.1...decap-cms-lib-util@2.0.2) (2018-07-28)
|
||||
|
||||
**Note:** Version bump only for package decap-cms-lib-util
|
||||
|
||||
<a name="2.0.1"></a>
|
||||
|
||||
## 2.0.1 (2018-07-26)
|
||||
|
||||
<a name="2.0.0"></a>
|
||||
|
||||
# 2.0.0 (2018-07-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bitbucket:** fix rebasing mistakes in bitbucket backend and deps ([#1522](https://github.com/decaporg/decap-cms/issues/1522)) ([bdfd944](https://github.com/decaporg/decap-cms/commit/bdfd944))
|
||||
22
source/admin/packages/decap-cms-lib-util/README.md
Normal file
22
source/admin/packages/decap-cms-lib-util/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Lib Util
|
||||
|
||||
Shared utilities to handle various `decap-cms-backend-*` backends operations.
|
||||
|
||||
## Code structure
|
||||
|
||||
This structure should be the same for backends.
|
||||
|
||||
At first, look at `Implementation`. This is File Management System API and has factory method for `AuthComponent`.
|
||||
|
||||
### File Management System API
|
||||
|
||||
An abstraction layer between the CMS and Git-repository manager API.
|
||||
|
||||
Used as backend in [cms-core](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-core/README.md).
|
||||
|
||||
### Low-level abstractions for Git-repository manager API:
|
||||
|
||||
- `API` - used for Entry files
|
||||
- `git-lfs` - used for Media files
|
||||
- and more helpful utilities
|
||||
|
||||
31
source/admin/packages/decap-cms-lib-util/package.json
Normal file
31
source/admin/packages/decap-cms-lib-util/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "decap-cms-lib-util",
|
||||
"description": "Shared utilities for Decap CMS.",
|
||||
"version": "3.3.1",
|
||||
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util",
|
||||
"bugs": "https://github.com/decaporg/decap-cms/issues",
|
||||
"module": "dist/esm/index.js",
|
||||
"main": "dist/decap-cms-lib-util.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"decap-cms"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"develop": "npm run build:esm -- --watch",
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
||||
},
|
||||
"dependencies": {
|
||||
"js-sha256": "^0.9.0",
|
||||
"localforage": "^1.7.3",
|
||||
"semaphore": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"immutable": "^3.7.6",
|
||||
"lodash": "^4.17.11"
|
||||
}
|
||||
}
|
||||
376
source/admin/packages/decap-cms-lib-util/src/API.ts
Normal file
376
source/admin/packages/decap-cms-lib-util/src/API.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { asyncLock } from './asyncLock';
|
||||
import unsentRequest from './unsentRequest';
|
||||
import APIError from './APIError';
|
||||
|
||||
import type { AsyncLock } from './asyncLock';
|
||||
|
||||
export interface FetchError extends Error {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface API {
|
||||
rateLimiter?: AsyncLock;
|
||||
buildRequest: (req: ApiRequest) => ApiRequest | Promise<ApiRequest>;
|
||||
requestFunction?: (req: ApiRequest) => Promise<Response>;
|
||||
}
|
||||
|
||||
export type ApiRequestObject = {
|
||||
url: string;
|
||||
params?: Record<string, string | boolean | number>;
|
||||
method?: 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'PATCH';
|
||||
headers?: Record<string, string>;
|
||||
body?: string | FormData;
|
||||
cache?: 'no-store';
|
||||
};
|
||||
|
||||
export type ApiRequest = ApiRequestObject | string;
|
||||
|
||||
class RateLimitError extends Error {
|
||||
resetSeconds: number;
|
||||
|
||||
constructor(message: string, resetSeconds: number) {
|
||||
super(message);
|
||||
if (resetSeconds < 0) {
|
||||
this.resetSeconds = 1;
|
||||
} else if (resetSeconds > 60 * 60) {
|
||||
this.resetSeconds = 60 * 60;
|
||||
} else {
|
||||
this.resetSeconds = resetSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export function parseResponse(response: Response) {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return parseJsonResponse(response);
|
||||
}
|
||||
const textPromise = response.text().then(text => {
|
||||
if (!response.ok) return Promise.reject(text);
|
||||
return text;
|
||||
});
|
||||
return textPromise;
|
||||
}
|
||||
|
||||
export async function requestWithBackoff(
|
||||
api: API,
|
||||
req: ApiRequest,
|
||||
attempt = 1,
|
||||
): Promise<Response> {
|
||||
if (api.rateLimiter) {
|
||||
await api.rateLimiter.acquire();
|
||||
}
|
||||
|
||||
try {
|
||||
const builtRequest = await api.buildRequest(req);
|
||||
const requestFunction = api.requestFunction || unsentRequest.performRequest;
|
||||
const response: Response = await requestFunction(builtRequest);
|
||||
if (response.status === 429) {
|
||||
// GitLab/Bitbucket too many requests
|
||||
const text = await response.text().catch(() => 'Too many requests');
|
||||
throw new Error(text);
|
||||
} else if (response.status === 403) {
|
||||
// GitHub too many requests
|
||||
const json = await response.json().catch(() => ({ message: '' }));
|
||||
if (json.message.match('API rate limit exceeded')) {
|
||||
const now = new Date();
|
||||
const nextWindowInSeconds = response.headers.has('X-RateLimit-Reset')
|
||||
? parseInt(response.headers.get('X-RateLimit-Reset')!)
|
||||
: now.getTime() / 1000 + 60;
|
||||
|
||||
throw new RateLimitError(json.message, nextWindowInSeconds);
|
||||
}
|
||||
response.json = () => Promise.resolve(json);
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (attempt > 5 || err.message === "Can't refresh access token when using implicit auth") {
|
||||
throw err;
|
||||
} else {
|
||||
if (!api.rateLimiter) {
|
||||
const timeout = err.resetSeconds || attempt * attempt;
|
||||
console.log(
|
||||
`Pausing requests for ${timeout} ${
|
||||
attempt === 1 ? 'second' : 'seconds'
|
||||
} due to fetch failures:`,
|
||||
err.message,
|
||||
);
|
||||
api.rateLimiter = asyncLock();
|
||||
api.rateLimiter.acquire();
|
||||
setTimeout(() => {
|
||||
api.rateLimiter?.release();
|
||||
api.rateLimiter = undefined;
|
||||
console.log(`Done pausing requests`);
|
||||
}, 1000 * timeout);
|
||||
}
|
||||
return requestWithBackoff(api, req, attempt + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Options is an object which contains all the standard network request properties
|
||||
// for modifying HTTP requests and may contains `params` property
|
||||
|
||||
type Param = string | number;
|
||||
|
||||
type ParamObject = Record<string, Param>;
|
||||
|
||||
type HeaderObj = Record<string, string>;
|
||||
|
||||
type HeaderConfig = {
|
||||
headers?: HeaderObj;
|
||||
token?: string | undefined;
|
||||
};
|
||||
|
||||
type Backend = 'github' | 'gitlab' | 'bitbucket';
|
||||
|
||||
// RequestConfig contains all the standard properties of a Request object and
|
||||
// several custom properties:
|
||||
// - "headers" property is an object whose properties and values are string types
|
||||
// - `token` property to allow passing tokens for users using a private repo.
|
||||
// - `params` property for customizing response
|
||||
// - `backend`(compulsory) to specify which backend to be used: Github, Gitlab etc.
|
||||
|
||||
type RequestConfig = Omit<RequestInit, 'headers'> &
|
||||
HeaderConfig & {
|
||||
backend: Backend;
|
||||
apiRoot?: string;
|
||||
params?: ParamObject;
|
||||
};
|
||||
|
||||
export const apiRoots = {
|
||||
github: 'https://api.github.com',
|
||||
gitlab: 'https://gitlab.com/api/v4',
|
||||
bitbucket: 'https://api.bitbucket.org/2.0',
|
||||
};
|
||||
|
||||
export const endpointConstants = {
|
||||
singleRepo: {
|
||||
bitbucket: '/repositories',
|
||||
github: '/repos',
|
||||
gitlab: '/projects',
|
||||
},
|
||||
};
|
||||
|
||||
const api = {
|
||||
buildRequest(req: ApiRequest) {
|
||||
return req;
|
||||
},
|
||||
};
|
||||
|
||||
function constructUrlWithParams(url: string, params?: ParamObject) {
|
||||
if (params) {
|
||||
const paramList = [];
|
||||
for (const key in params) {
|
||||
paramList.push(`${key}=${encodeURIComponent(params[key])}`);
|
||||
}
|
||||
if (paramList.length) {
|
||||
url += `?${paramList.join('&')}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function constructRequestHeaders(headerConfig: HeaderConfig) {
|
||||
const { token, headers } = headerConfig;
|
||||
const baseHeaders: HeaderObj = { 'Content-Type': 'application/json; charset=utf-8', ...headers };
|
||||
if (token) {
|
||||
baseHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return Promise.resolve(baseHeaders);
|
||||
}
|
||||
|
||||
function handleRequestError(error: FetchError, responseStatus: number, backend: Backend) {
|
||||
throw new APIError(error.message, responseStatus, backend);
|
||||
}
|
||||
|
||||
export async function apiRequest(
|
||||
path: string,
|
||||
config: RequestConfig,
|
||||
parser = (response: Response) => parseResponse(response),
|
||||
) {
|
||||
const { token, backend, ...props } = config;
|
||||
const options = { cache: 'no-cache', ...props };
|
||||
const headers = await constructRequestHeaders({ headers: options.headers || {}, token });
|
||||
const baseUrl = config.apiRoot ?? apiRoots[backend];
|
||||
const url = constructUrlWithParams(`${baseUrl}${path}`, options.params);
|
||||
let responseStatus = 500;
|
||||
try {
|
||||
const req = unsentRequest.fromFetchArguments(url, {
|
||||
...options,
|
||||
headers,
|
||||
}) as unknown as ApiRequest;
|
||||
const response = await requestWithBackoff(api, req);
|
||||
responseStatus = response.status;
|
||||
const parsedResponse = await parser(response);
|
||||
return parsedResponse;
|
||||
} catch (error) {
|
||||
return handleRequestError(error, responseStatus, backend);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDefaultBranchName(configs: {
|
||||
backend: Backend;
|
||||
repo: string;
|
||||
token?: string;
|
||||
apiRoot?: string;
|
||||
}) {
|
||||
let apiPath;
|
||||
const { token, backend, repo, apiRoot } = configs;
|
||||
switch (backend) {
|
||||
case 'gitlab': {
|
||||
apiPath = `/projects/${encodeURIComponent(repo)}`;
|
||||
break;
|
||||
}
|
||||
case 'bitbucket': {
|
||||
apiPath = `/repositories/${repo}`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
apiPath = `/repos/${repo}`;
|
||||
}
|
||||
}
|
||||
const repoInfo = await apiRequest(apiPath, { token, backend, apiRoot });
|
||||
let defaultBranchName;
|
||||
if (backend === 'bitbucket') {
|
||||
const {
|
||||
mainbranch: { name },
|
||||
} = repoInfo;
|
||||
defaultBranchName = name;
|
||||
} else {
|
||||
const { default_branch } = repoInfo;
|
||||
defaultBranchName = default_branch;
|
||||
}
|
||||
return defaultBranchName;
|
||||
}
|
||||
|
||||
export async function readFile(
|
||||
id: string | null | undefined,
|
||||
fetchContent: () => Promise<string | Blob>,
|
||||
localForage: LocalForage,
|
||||
isText: boolean,
|
||||
) {
|
||||
const key = id ? (isText ? `gh.${id}` : `gh.${id}.blob`) : null;
|
||||
const cached = key ? await localForage.getItem<string | Blob>(key) : null;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const content = await fetchContent();
|
||||
if (key) {
|
||||
await localForage.setItem(key, content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export type FileMetadata = {
|
||||
author: string;
|
||||
updatedOn: string;
|
||||
};
|
||||
|
||||
function getFileMetadataKey(id: string) {
|
||||
return `gh.${id}.meta`;
|
||||
}
|
||||
|
||||
export async function readFileMetadata(
|
||||
id: string | null | undefined,
|
||||
fetchMetadata: () => Promise<FileMetadata>,
|
||||
localForage: LocalForage,
|
||||
) {
|
||||
const key = id ? getFileMetadataKey(id) : null;
|
||||
const cached = key && (await localForage.getItem<FileMetadata>(key));
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const metadata = await fetchMetadata();
|
||||
if (key) {
|
||||
await localForage.setItem<FileMetadata>(key, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords for inferring a status that will provide a deploy preview URL.
|
||||
*/
|
||||
const PREVIEW_CONTEXT_KEYWORDS = ['deploy'];
|
||||
|
||||
/**
|
||||
* Check a given status context string to determine if it provides a link to a
|
||||
* deploy preview. Checks for an exact match against `previewContext` if given,
|
||||
* otherwise checks for inclusion of a value from `PREVIEW_CONTEXT_KEYWORDS`.
|
||||
*/
|
||||
export function isPreviewContext(context: string, previewContext: string) {
|
||||
if (previewContext) {
|
||||
return context === previewContext;
|
||||
}
|
||||
return PREVIEW_CONTEXT_KEYWORDS.some(keyword => context.includes(keyword));
|
||||
}
|
||||
|
||||
export enum PreviewState {
|
||||
Other = 'other',
|
||||
Success = 'success',
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a deploy preview URL from an array of statuses. By default, a
|
||||
* matching status is inferred via `isPreviewContext`.
|
||||
*/
|
||||
export function getPreviewStatus(
|
||||
statuses: {
|
||||
context: string;
|
||||
target_url: string;
|
||||
state: PreviewState;
|
||||
}[],
|
||||
previewContext: string,
|
||||
) {
|
||||
return statuses.find(({ context }) => {
|
||||
return isPreviewContext(context, previewContext);
|
||||
});
|
||||
}
|
||||
|
||||
function getConflictingBranches(branchName: string) {
|
||||
// for cms/posts/post-1, conflicting branches are cms/posts, cms
|
||||
const parts = branchName.split('/');
|
||||
parts.pop();
|
||||
|
||||
const conflictingBranches = parts.reduce((acc, _, index) => {
|
||||
acc = [...acc, parts.slice(0, index + 1).join('/')];
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
|
||||
return conflictingBranches;
|
||||
}
|
||||
|
||||
export async function throwOnConflictingBranches(
|
||||
branchName: string,
|
||||
getBranch: (name: string) => Promise<{ name: string }>,
|
||||
apiName: string,
|
||||
) {
|
||||
const possibleConflictingBranches = getConflictingBranches(branchName);
|
||||
|
||||
const conflictingBranches = await Promise.all(
|
||||
possibleConflictingBranches.map(b =>
|
||||
getBranch(b)
|
||||
.then(b => b.name)
|
||||
.catch(() => ''),
|
||||
),
|
||||
);
|
||||
|
||||
const conflictingBranch = conflictingBranches.filter(Boolean)[0];
|
||||
if (conflictingBranch) {
|
||||
throw new APIError(
|
||||
`Failed creating branch '${branchName}' since there is already a branch named '${conflictingBranch}'. Please delete the '${conflictingBranch}' branch and try again`,
|
||||
500,
|
||||
apiName,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
source/admin/packages/decap-cms-lib-util/src/APIError.ts
Normal file
17
source/admin/packages/decap-cms-lib-util/src/APIError.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const API_ERROR = 'API_ERROR';
|
||||
|
||||
export default class APIError extends Error {
|
||||
message: string;
|
||||
status: null | number;
|
||||
api: string;
|
||||
meta: {};
|
||||
|
||||
constructor(message: string, status: null | number, api: string, meta = {}) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.status = status;
|
||||
this.api = api;
|
||||
this.name = API_ERROR;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
38
source/admin/packages/decap-cms-lib-util/src/APIUtils.ts
Normal file
38
source/admin/packages/decap-cms-lib-util/src/APIUtils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const CMS_BRANCH_PREFIX = 'cms';
|
||||
export const DEFAULT_PR_BODY = 'Automatically generated by Decap CMS';
|
||||
export const MERGE_COMMIT_MESSAGE = 'Automatically generated. Merged on Decap CMS.';
|
||||
|
||||
const DEFAULT_DECAP_CMS_LABEL_PREFIX = 'decap-cms/';
|
||||
|
||||
function getLabelPrefix(labelPrefix: string) {
|
||||
return labelPrefix || DEFAULT_DECAP_CMS_LABEL_PREFIX;
|
||||
}
|
||||
|
||||
export function isCMSLabel(label: string, labelPrefix: string) {
|
||||
return label.startsWith(getLabelPrefix(labelPrefix));
|
||||
}
|
||||
|
||||
export function labelToStatus(label: string, labelPrefix: string) {
|
||||
return label.slice(getLabelPrefix(labelPrefix).length);
|
||||
}
|
||||
|
||||
export function statusToLabel(status: string, labelPrefix: string) {
|
||||
return `${getLabelPrefix(labelPrefix)}${status}`;
|
||||
}
|
||||
|
||||
export function generateContentKey(collectionName: string, slug: string) {
|
||||
return `${collectionName}/${slug}`;
|
||||
}
|
||||
|
||||
export function parseContentKey(contentKey: string) {
|
||||
const index = contentKey.indexOf('/');
|
||||
return { collection: contentKey.slice(0, index), slug: contentKey.slice(index + 1) };
|
||||
}
|
||||
|
||||
export function contentKeyFromBranch(branch: string) {
|
||||
return branch.slice(`${CMS_BRANCH_PREFIX}/`.length);
|
||||
}
|
||||
|
||||
export function branchFromContentKey(contentKey: string) {
|
||||
return `${CMS_BRANCH_PREFIX}/${contentKey}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const ACCESS_TOKEN_ERROR = 'ACCESS_TOKEN_ERROR';
|
||||
|
||||
export default class AccessTokenError extends Error {
|
||||
message: string;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.name = ACCESS_TOKEN_ERROR;
|
||||
}
|
||||
}
|
||||
178
source/admin/packages/decap-cms-lib-util/src/Cursor.ts
Normal file
178
source/admin/packages/decap-cms-lib-util/src/Cursor.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { fromJS, Map, Set } from 'immutable';
|
||||
|
||||
type CursorStoreObject = {
|
||||
actions: Set<string>;
|
||||
data: Map<string, unknown>;
|
||||
meta: Map<string, unknown>;
|
||||
};
|
||||
|
||||
export type CursorStore = {
|
||||
get<K extends keyof CursorStoreObject>(
|
||||
key: K,
|
||||
defaultValue?: CursorStoreObject[K],
|
||||
): CursorStoreObject[K];
|
||||
getIn<V>(path: string[]): V;
|
||||
set<K extends keyof CursorStoreObject, V extends CursorStoreObject[K]>(
|
||||
key: K,
|
||||
value: V,
|
||||
): CursorStoreObject[K];
|
||||
setIn(path: string[], value: unknown): CursorStore;
|
||||
hasIn(path: string[]): boolean;
|
||||
mergeIn(path: string[], value: unknown): CursorStore;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
update: (...args: any[]) => CursorStore;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateIn: (...args: any[]) => CursorStore;
|
||||
};
|
||||
|
||||
type ActionHandler = (action: string) => unknown;
|
||||
|
||||
function jsToMap(obj: {}) {
|
||||
if (obj === undefined) {
|
||||
return Map();
|
||||
}
|
||||
const immutableObj = fromJS(obj);
|
||||
if (!Map.isMap(immutableObj)) {
|
||||
throw new Error('Object must be equivalent to a Map.');
|
||||
}
|
||||
return immutableObj;
|
||||
}
|
||||
|
||||
const knownMetaKeys = Set([
|
||||
'index',
|
||||
'page',
|
||||
'count',
|
||||
'pageSize',
|
||||
'pageCount',
|
||||
'usingOldPaginationAPI',
|
||||
'extension',
|
||||
'folder',
|
||||
'depth',
|
||||
]);
|
||||
|
||||
function filterUnknownMetaKeys(meta: Map<string, string>) {
|
||||
return meta.filter((_v, k) => knownMetaKeys.has(k as string));
|
||||
}
|
||||
|
||||
/*
|
||||
createCursorMap takes one of three signatures:
|
||||
- () -> cursor with empty actions, data, and meta
|
||||
- (cursorMap: <object/Map with optional actions, data, and meta keys>) -> cursor
|
||||
- (actions: <array/List>, data: <object/Map>, meta: <optional object/Map>) -> cursor
|
||||
*/
|
||||
function createCursorStore(...args: {}[]) {
|
||||
const { actions, data, meta } =
|
||||
args.length === 1
|
||||
? jsToMap(args[0]).toObject()
|
||||
: { actions: args[0], data: args[1], meta: args[2] };
|
||||
return Map({
|
||||
// actions are a Set, rather than a List, to ensure an efficient .has
|
||||
actions: Set(actions),
|
||||
|
||||
// data and meta are Maps
|
||||
data: jsToMap(data),
|
||||
meta: jsToMap(meta).update(filterUnknownMetaKeys),
|
||||
}) as CursorStore;
|
||||
}
|
||||
|
||||
function hasAction(store: CursorStore, action: string) {
|
||||
return store.hasIn(['actions', action]);
|
||||
}
|
||||
|
||||
function getActionHandlers(store: CursorStore, handler: ActionHandler) {
|
||||
return store
|
||||
.get('actions', Set<string>())
|
||||
.toMap()
|
||||
.map(action => handler(action as string));
|
||||
}
|
||||
|
||||
// The cursor logic is entirely functional, so this class simply
|
||||
// provides a chainable interface
|
||||
export default class Cursor {
|
||||
store?: CursorStore;
|
||||
actions?: Set<string>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?: Map<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
meta?: Map<string, any>;
|
||||
|
||||
static create(...args: {}[]) {
|
||||
return new Cursor(...args);
|
||||
}
|
||||
|
||||
constructor(...args: {}[]) {
|
||||
if (args[0] instanceof Cursor) {
|
||||
return args[0] as Cursor;
|
||||
}
|
||||
|
||||
this.store = createCursorStore(...args);
|
||||
this.actions = this.store.get('actions');
|
||||
this.data = this.store.get('data');
|
||||
this.meta = this.store.get('meta');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateStore(...args: any[]) {
|
||||
return new Cursor(this.store!.update(...args));
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateInStore(...args: any[]) {
|
||||
return new Cursor(this.store!.updateIn(...args));
|
||||
}
|
||||
|
||||
hasAction(action: string) {
|
||||
return hasAction(this.store!, action);
|
||||
}
|
||||
addAction(action: string) {
|
||||
return this.updateStore('actions', (actions: Set<string>) => actions.add(action));
|
||||
}
|
||||
removeAction(action: string) {
|
||||
return this.updateStore('actions', (actions: Set<string>) => actions.delete(action));
|
||||
}
|
||||
setActions(actions: Iterable<string>) {
|
||||
return this.updateStore((store: CursorStore) => store.set('actions', Set<string>(actions)));
|
||||
}
|
||||
mergeActions(actions: Set<string>) {
|
||||
return this.updateStore('actions', (oldActions: Set<string>) => oldActions.union(actions));
|
||||
}
|
||||
getActionHandlers(handler: ActionHandler) {
|
||||
return getActionHandlers(this.store!, handler);
|
||||
}
|
||||
|
||||
setData(data: {}) {
|
||||
return new Cursor(this.store!.set('data', jsToMap(data)));
|
||||
}
|
||||
mergeData(data: {}) {
|
||||
return new Cursor(this.store!.mergeIn(['data'], jsToMap(data)));
|
||||
}
|
||||
wrapData(data: {}) {
|
||||
return this.updateStore('data', (oldData: Map<string, unknown>) =>
|
||||
jsToMap(data).set('wrapped_cursor_data', oldData),
|
||||
);
|
||||
}
|
||||
unwrapData() {
|
||||
return [
|
||||
this.store!.get('data').delete('wrapped_cursor_data'),
|
||||
this.updateStore('data', (data: Map<string, unknown>) => data.get('wrapped_cursor_data')),
|
||||
] as [Map<string, unknown>, Cursor];
|
||||
}
|
||||
clearData() {
|
||||
return this.updateStore('data', () => Map());
|
||||
}
|
||||
|
||||
setMeta(meta: {}) {
|
||||
return this.updateStore((store: CursorStore) => store.set('meta', jsToMap(meta)));
|
||||
}
|
||||
mergeMeta(meta: {}) {
|
||||
return this.updateStore((store: CursorStore) =>
|
||||
store.update('meta', (oldMeta: Map<string, unknown>) => oldMeta.merge(jsToMap(meta))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This is a temporary hack to allow cursors to be added to the
|
||||
// interface between backend.js and backends without modifying old
|
||||
// backends at all. This should be removed in favor of wrapping old
|
||||
// backends with a compatibility layer, as part of the backend API
|
||||
// refactor.
|
||||
export const CURSOR_COMPATIBILITY_SYMBOL = Symbol('cursor key for compatibility with old backends');
|
||||
@@ -0,0 +1,12 @@
|
||||
export const EDITORIAL_WORKFLOW_ERROR = 'EDITORIAL_WORKFLOW_ERROR';
|
||||
|
||||
export default class EditorialWorkflowError extends Error {
|
||||
message: string;
|
||||
notUnderEditorialWorkflow: boolean;
|
||||
constructor(message: string, notUnderEditorialWorkflow: boolean) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.notUnderEditorialWorkflow = notUnderEditorialWorkflow;
|
||||
this.name = EDITORIAL_WORKFLOW_ERROR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as api from '../API';
|
||||
|
||||
describe('Api', () => {
|
||||
describe('getPreviewStatus', () => {
|
||||
it('should return preview status on matching context', () => {
|
||||
expect(api.getPreviewStatus([{ context: 'deploy' }])).toEqual({ context: 'deploy' });
|
||||
});
|
||||
|
||||
it('should return undefined on matching context', () => {
|
||||
expect(api.getPreviewStatus([{ context: 'other' }])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as apiUtils from '../APIUtils';
|
||||
describe('APIUtils', () => {
|
||||
describe('generateContentKey', () => {
|
||||
it('should generate content key', () => {
|
||||
expect(apiUtils.generateContentKey('posts', 'dir1/dir2/post-title')).toBe(
|
||||
'posts/dir1/dir2/post-title',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseContentKey', () => {
|
||||
it('should parse content key', () => {
|
||||
expect(apiUtils.parseContentKey('posts/dir1/dir2/post-title')).toEqual({
|
||||
collection: 'posts',
|
||||
slug: 'dir1/dir2/post-title',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCMSLabel', () => {
|
||||
it('should return true for CMS label', () => {
|
||||
expect(apiUtils.isCMSLabel('decap-cms/draft', 'decap-cms/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non CMS label', () => {
|
||||
expect(apiUtils.isCMSLabel('other/label', 'decap-cms/')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the prefix not provided for CMS label', () => {
|
||||
expect(apiUtils.isCMSLabel('decap-cms/draft', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if a different prefix provided for CMS label', () => {
|
||||
expect(apiUtils.isCMSLabel('decap-cms/draft', 'other/')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for CMS label when undefined prefix is passed', () => {
|
||||
expect(apiUtils.isCMSLabel('decap-cms/draft', undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labelToStatus', () => {
|
||||
it('should get status from label when default prefix is passed', () => {
|
||||
expect(apiUtils.labelToStatus('decap-cms/draft', 'decap-cms/')).toBe('draft');
|
||||
});
|
||||
|
||||
it('should get status from label when custom prefix is passed', () => {
|
||||
expect(apiUtils.labelToStatus('other/draft', 'other/')).toBe('draft');
|
||||
});
|
||||
|
||||
it('should get status from label when empty prefix is passed', () => {
|
||||
expect(apiUtils.labelToStatus('decap-cms/draft', '')).toBe('draft');
|
||||
});
|
||||
|
||||
it('should get status from label when undefined prefix is passed', () => {
|
||||
expect(apiUtils.labelToStatus('decap-cms/draft', undefined)).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusToLabel', () => {
|
||||
it('should generate label from status when default prefix is passed', () => {
|
||||
expect(apiUtils.statusToLabel('draft', 'decap-cms/')).toBe('decap-cms/draft');
|
||||
});
|
||||
it('should generate label from status when custom prefix is passed', () => {
|
||||
expect(apiUtils.statusToLabel('draft', 'other/')).toBe('other/draft');
|
||||
});
|
||||
it('should generate label from status when empty prefix is passed', () => {
|
||||
expect(apiUtils.statusToLabel('draft', '')).toBe('decap-cms/draft');
|
||||
});
|
||||
it('should generate label from status when undefined prefix is passed', () => {
|
||||
expect(apiUtils.statusToLabel('draft', undefined)).toBe('decap-cms/draft');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { asyncLock } from '../asyncLock';
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
describe('asyncLock', () => {
|
||||
it('should be able to acquire a new lock', async () => {
|
||||
const lock = asyncLock();
|
||||
|
||||
const acquired = await lock.acquire();
|
||||
|
||||
expect(acquired).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be able to acquire an acquired lock', async () => {
|
||||
const lock = asyncLock();
|
||||
await lock.acquire();
|
||||
|
||||
const promise = lock.acquire();
|
||||
|
||||
// advance by default lock timeout
|
||||
jest.advanceTimersByTime(15000);
|
||||
|
||||
const acquired = await promise;
|
||||
|
||||
expect(acquired).toBe(false);
|
||||
});
|
||||
|
||||
it('should be able to acquire an acquired lock that was released', async () => {
|
||||
const lock = asyncLock();
|
||||
await lock.acquire();
|
||||
|
||||
const promise = lock.acquire();
|
||||
|
||||
// release the lock in the "future"
|
||||
setTimeout(() => lock.release(), 100);
|
||||
|
||||
// advance to the time where the lock will be released
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
const acquired = await promise;
|
||||
|
||||
expect(acquired).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept a timeout for acquire', async () => {
|
||||
const lock = asyncLock();
|
||||
await lock.acquire();
|
||||
|
||||
const promise = lock.acquire(50);
|
||||
|
||||
/// advance by lock timeout
|
||||
jest.advanceTimersByTime(50);
|
||||
|
||||
const acquired = await promise;
|
||||
|
||||
expect(acquired).toBe(false);
|
||||
});
|
||||
|
||||
it('should be able to re-acquire a lock after a timeout', async () => {
|
||||
const lock = asyncLock();
|
||||
await lock.acquire();
|
||||
|
||||
const promise = lock.acquire();
|
||||
|
||||
// advance by default lock timeout
|
||||
jest.advanceTimersByTime(15000);
|
||||
|
||||
let acquired = await promise;
|
||||
|
||||
expect(acquired).toBe(false);
|
||||
|
||||
acquired = await lock.acquire();
|
||||
expect(acquired).toBe(true);
|
||||
});
|
||||
|
||||
it('should suppress "leave called too many times" error', async () => {
|
||||
const lock = asyncLock();
|
||||
|
||||
await expect(() => lock.release()).not.toThrow();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith('leave called too many times.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { oneLine } from 'common-tags';
|
||||
import nock from 'nock';
|
||||
|
||||
import { parseLinkHeader, getAllResponses, getPathDepth, filterByExtension } from '../backendUtil';
|
||||
|
||||
describe('parseLinkHeader', () => {
|
||||
it('should return the right rel urls', () => {
|
||||
const url = 'https://api.github.com/resource';
|
||||
const link = oneLine`
|
||||
<${url}?page=1>; rel="first",
|
||||
<${url}?page=2>; rel="prev",
|
||||
<${url}?page=4>; rel="next",
|
||||
<${url}?page=5>; rel="last"
|
||||
`;
|
||||
const linkHeader = parseLinkHeader(link);
|
||||
|
||||
expect(linkHeader.next).toBe(`${url}?page=4`);
|
||||
expect(linkHeader.last).toBe(`${url}?page=5`);
|
||||
expect(linkHeader.first).toBe(`${url}?page=1`);
|
||||
expect(linkHeader.prev).toBe(`${url}?page=2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllResponses', () => {
|
||||
function generatePulls(length) {
|
||||
return Array.from({ length }, (_, id) => {
|
||||
return { id: id + 1, number: `134${id}`, state: 'open' };
|
||||
});
|
||||
}
|
||||
|
||||
function createLinkHeaders({ page, pageCount }) {
|
||||
const pageNum = parseInt(page, 10);
|
||||
const pageCountNum = parseInt(pageCount, 10);
|
||||
const url = 'https://api.github.com/pulls';
|
||||
|
||||
function link(linkPage) {
|
||||
return `<${url}?page=${linkPage}>`;
|
||||
}
|
||||
|
||||
const linkHeader = oneLine`
|
||||
${pageNum === 1 ? '' : `${link(1)}; rel="first",`}
|
||||
${pageNum === pageCountNum ? '' : `${link(pageCount)}; rel="last",`}
|
||||
${pageNum === 1 ? '' : `${link(pageNum - 1)}; rel="prev",`}
|
||||
${pageNum === pageCountNum ? '' : `${link(pageNum + 1)}; rel="next",`}
|
||||
`.slice(0, -1);
|
||||
|
||||
return { Link: linkHeader };
|
||||
}
|
||||
|
||||
function interceptCall({ perPage = 30, repeat = 1, data = [] } = {}) {
|
||||
nock('https://api.github.com')
|
||||
.get('/pulls')
|
||||
.query(true)
|
||||
.times(repeat)
|
||||
.reply(uri => {
|
||||
const searchParams = new URLSearchParams(uri.split('?')[1]);
|
||||
const page = searchParams.get('page') || 1;
|
||||
const pageCount = data.length <= perPage ? 1 : Math.ceil(data.length / perPage);
|
||||
const pageLastIndex = page * perPage;
|
||||
const pageFirstIndex = pageLastIndex - perPage;
|
||||
const resp = data.slice(pageFirstIndex, pageLastIndex);
|
||||
return [200, resp, createLinkHeaders({ page, pageCount })];
|
||||
});
|
||||
}
|
||||
|
||||
it('should return all paged response', async () => {
|
||||
interceptCall({ repeat: 3, data: generatePulls(70) });
|
||||
const res = await getAllResponses('https://api.github.com/pulls', {}, 'next', url => url);
|
||||
const pages = await Promise.all(res.map(res => res.json()));
|
||||
|
||||
expect(pages[0]).toHaveLength(30);
|
||||
expect(pages[1]).toHaveLength(30);
|
||||
expect(pages[2]).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPathDepth', () => {
|
||||
it('should return 1 for empty string', () => {
|
||||
expect(getPathDepth('')).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 2 for path of one nested folder', () => {
|
||||
expect(getPathDepth('{{year}}/{{slug}}')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByExtension', () => {
|
||||
it('should return true when extension matches', () => {
|
||||
expect(filterByExtension({ path: 'file.html.md' }, '.html.md')).toBe(true);
|
||||
expect(filterByExtension({ path: 'file.html.md' }, 'html.md')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when extension doesn't match", () => {
|
||||
expect(filterByExtension({ path: 'file.json' }, '.html.md')).toBe(false);
|
||||
expect(filterByExtension({ path: 'file.json' }, 'html.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getMediaAsBlob, getMediaDisplayURL } from '../implementation';
|
||||
|
||||
describe('implementation', () => {
|
||||
describe('getMediaAsBlob', () => {
|
||||
it('should return response blob on non svg file', async () => {
|
||||
const blob = {};
|
||||
const readFile = jest.fn().mockResolvedValue(blob);
|
||||
|
||||
await expect(getMediaAsBlob('static/media/image.png', 'sha', readFile)).resolves.toBe(blob);
|
||||
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(readFile).toHaveBeenCalledWith('static/media/image.png', 'sha', {
|
||||
parseText: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return text blob on svg file', async () => {
|
||||
const text = 'svg';
|
||||
const readFile = jest.fn().mockResolvedValue(text);
|
||||
|
||||
await expect(getMediaAsBlob('static/media/logo.svg', 'sha', readFile)).resolves.toEqual(
|
||||
new Blob([text], { type: 'image/svg+xml' }),
|
||||
);
|
||||
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(readFile).toHaveBeenCalledWith('static/media/logo.svg', 'sha', {
|
||||
parseText: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaDisplayURL', () => {
|
||||
it('should return createObjectURL result', async () => {
|
||||
const blob = {};
|
||||
const readFile = jest.fn().mockResolvedValue(blob);
|
||||
const semaphore = { take: jest.fn(callback => callback()), leave: jest.fn() };
|
||||
|
||||
global.URL.createObjectURL = jest
|
||||
.fn()
|
||||
.mockResolvedValue('blob:http://localhost:8080/blob-id');
|
||||
|
||||
await expect(
|
||||
getMediaDisplayURL({ path: 'static/media/image.png', id: 'sha' }, readFile, semaphore),
|
||||
).resolves.toBe('blob:http://localhost:8080/blob-id');
|
||||
|
||||
expect(semaphore.take).toHaveBeenCalledTimes(1);
|
||||
expect(semaphore.leave).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(readFile).toHaveBeenCalledWith('static/media/image.png', 'sha', {
|
||||
parseText: false,
|
||||
});
|
||||
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalledWith(blob);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { fileExtensionWithSeparator, fileExtension } from '../path';
|
||||
|
||||
describe('fileExtensionWithSeparator', () => {
|
||||
it('should return the extension of a file', () => {
|
||||
expect(fileExtensionWithSeparator('index.html')).toEqual('.html');
|
||||
});
|
||||
|
||||
it('should return the extension of a file path', () => {
|
||||
expect(fileExtensionWithSeparator('/src/main/index.html')).toEqual('.html');
|
||||
});
|
||||
|
||||
it('should return the extension of a file path with trailing slash', () => {
|
||||
expect(fileExtensionWithSeparator('/src/main/index.html/')).toEqual('.html');
|
||||
});
|
||||
|
||||
it('should return the extension for an extension with two ..', () => {
|
||||
expect(fileExtensionWithSeparator('/src/main/index..html')).toEqual('.html');
|
||||
});
|
||||
|
||||
it('should return an empty string for the parent path ..', () => {
|
||||
expect(fileExtensionWithSeparator('..')).toEqual('');
|
||||
});
|
||||
|
||||
it('should return an empty string if the file has no extension', () => {
|
||||
expect(fileExtensionWithSeparator('/src/main/index')).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileExtension', () => {
|
||||
it('should return the extension of a file', () => {
|
||||
expect(fileExtension('index.html')).toEqual('html');
|
||||
});
|
||||
|
||||
it('should return the extension of a file path', () => {
|
||||
expect(fileExtension('/src/main/index.html')).toEqual('html');
|
||||
});
|
||||
|
||||
it('should return the extension of a file path with trailing slash', () => {
|
||||
expect(fileExtension('/src/main/index.html/')).toEqual('html');
|
||||
});
|
||||
|
||||
it('should return the extension for an extension with two ..', () => {
|
||||
expect(fileExtension('/src/main/index..html')).toEqual('html');
|
||||
});
|
||||
|
||||
it('should return an empty string for the parent path ..', () => {
|
||||
expect(fileExtension('..')).toEqual('');
|
||||
});
|
||||
|
||||
it('should return an empty string if the file has no extension', () => {
|
||||
expect(fileExtension('/src/main/index')).toEqual('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import unsentRequest from '../unsentRequest';
|
||||
|
||||
describe('unsentRequest', () => {
|
||||
describe('withHeaders', () => {
|
||||
it('should create new request with headers', () => {
|
||||
expect(unsentRequest.withHeaders({ Authorization: 'token' })('path').toJS()).toEqual({
|
||||
url: 'path',
|
||||
headers: { Authorization: 'token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should add headers to existing request', () => {
|
||||
expect(unsentRequest.withHeaders({ Authorization: 'token' }, 'path').toJS()).toEqual({
|
||||
url: 'path',
|
||||
headers: { Authorization: 'token' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
43
source/admin/packages/decap-cms-lib-util/src/asyncLock.ts
Normal file
43
source/admin/packages/decap-cms-lib-util/src/asyncLock.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
export type AsyncLock = { release: () => void; acquire: () => Promise<boolean> };
|
||||
|
||||
export function asyncLock(): AsyncLock {
|
||||
let lock = semaphore(1);
|
||||
|
||||
function acquire(timeout = 15000) {
|
||||
const promise = new Promise<boolean>(resolve => {
|
||||
// this makes sure a caller doesn't gets stuck forever awaiting on the lock
|
||||
const timeoutId = setTimeout(() => {
|
||||
// we reset the lock in that case to allow future consumers to use it without being blocked
|
||||
lock = semaphore(1);
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
lock.take(() => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function release() {
|
||||
try {
|
||||
// suppress too many calls to leave error
|
||||
lock.leave();
|
||||
} catch (e) {
|
||||
// calling 'leave' too many times might not be good behavior
|
||||
// but there is no reason to completely fail on it
|
||||
if (e.message !== 'leave called too many times.') {
|
||||
throw e;
|
||||
} else {
|
||||
console.warn('leave called too many times.');
|
||||
lock = semaphore(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { acquire, release };
|
||||
}
|
||||
121
source/admin/packages/decap-cms-lib-util/src/backendUtil.ts
Normal file
121
source/admin/packages/decap-cms-lib-util/src/backendUtil.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import flow from 'lodash/flow';
|
||||
import fromPairs from 'lodash/fromPairs';
|
||||
import { map } from 'lodash/fp';
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
import unsentRequest from './unsentRequest';
|
||||
import APIError from './APIError';
|
||||
|
||||
type Formatter = (res: Response) => Promise<string | Blob | unknown>;
|
||||
|
||||
export function filterByExtension(file: { path: string }, extension: string) {
|
||||
const path = file?.path || '';
|
||||
return path.endsWith(extension.startsWith('.') ? extension : `.${extension}`);
|
||||
}
|
||||
|
||||
function catchFormatErrors(format: string, formatter: Formatter) {
|
||||
return (res: Response) => {
|
||||
try {
|
||||
return formatter(res);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Response cannot be parsed into the expected format (${format}): ${err.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const responseFormatters = fromJS({
|
||||
json: async (res: Response) => {
|
||||
const contentType = res.headers.get('Content-Type') || '';
|
||||
if (!contentType.startsWith('application/json') && !contentType.startsWith('text/json')) {
|
||||
throw new Error(`${contentType} is not a valid JSON Content-Type`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
text: async (res: Response) => res.text(),
|
||||
blob: async (res: Response) => res.blob(),
|
||||
}).mapEntries(([format, formatter]: [string, Formatter]) => [
|
||||
format,
|
||||
catchFormatErrors(format, formatter),
|
||||
]);
|
||||
|
||||
export async function parseResponse(
|
||||
res: Response,
|
||||
{ expectingOk = true, format = 'text', apiName = '' },
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
const formatter = responseFormatters.get(format, false);
|
||||
if (!formatter) {
|
||||
throw new Error(`${format} is not a supported response format.`);
|
||||
}
|
||||
body = await formatter(res);
|
||||
} catch (err) {
|
||||
throw new APIError(err.message, res.status, apiName);
|
||||
}
|
||||
if (expectingOk && !res.ok) {
|
||||
const isJSON = format === 'json';
|
||||
const message = isJSON ? body.message || body.msg || body.error?.message : body;
|
||||
throw new APIError(isJSON && message ? message : body, res.status, apiName);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function responseParser(options: {
|
||||
expectingOk?: boolean;
|
||||
format: string;
|
||||
apiName: string;
|
||||
}) {
|
||||
return (res: Response) => parseResponse(res, options);
|
||||
}
|
||||
|
||||
export function parseLinkHeader(header: string | null) {
|
||||
if (!header) {
|
||||
return {};
|
||||
}
|
||||
return flow([
|
||||
linksString => linksString.split(','),
|
||||
map((str: string) => str.trim().split(';')),
|
||||
map(([linkStr, keyStr]) => [
|
||||
keyStr.match(/rel="(.*?)"/)[1],
|
||||
linkStr
|
||||
.trim()
|
||||
.match(/<(.*?)>/)[1]
|
||||
.replace(/\+/g, '%20'),
|
||||
]),
|
||||
fromPairs,
|
||||
])(header);
|
||||
}
|
||||
|
||||
export async function getAllResponses(
|
||||
url: string,
|
||||
options: { headers?: {} } = {},
|
||||
linkHeaderRelName: string,
|
||||
nextUrlProcessor: (url: string) => string,
|
||||
) {
|
||||
const maxResponses = 30;
|
||||
let responseCount = 1;
|
||||
|
||||
let req = unsentRequest.fromFetchArguments(url, options);
|
||||
|
||||
const pageResponses = [];
|
||||
|
||||
while (req && responseCount < maxResponses) {
|
||||
const pageResponse = await unsentRequest.performRequest(req);
|
||||
const linkHeader = pageResponse.headers.get('Link');
|
||||
const nextURL = linkHeader && parseLinkHeader(linkHeader)[linkHeaderRelName];
|
||||
|
||||
const { headers = {} } = options;
|
||||
req = nextURL && unsentRequest.fromFetchArguments(nextUrlProcessor(nextURL), { headers });
|
||||
pageResponses.push(pageResponse);
|
||||
responseCount++;
|
||||
}
|
||||
|
||||
return pageResponses;
|
||||
}
|
||||
|
||||
export function getPathDepth(path: string) {
|
||||
const depth = path.split('/').length;
|
||||
return depth;
|
||||
}
|
||||
12
source/admin/packages/decap-cms-lib-util/src/getBlobSHA.ts
Normal file
12
source/admin/packages/decap-cms-lib-util/src/getBlobSHA.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { sha256 } from 'js-sha256';
|
||||
|
||||
export default (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = ({ target }) => resolve(sha256(target?.result || ''));
|
||||
fr.onerror = err => {
|
||||
fr.abort();
|
||||
reject(err);
|
||||
};
|
||||
fr.readAsArrayBuffer(blob);
|
||||
});
|
||||
133
source/admin/packages/decap-cms-lib-util/src/git-lfs.ts
Normal file
133
source/admin/packages/decap-cms-lib-util/src/git-lfs.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// Pointer file parsing
|
||||
|
||||
import { filter, flow, fromPairs, map } from 'lodash/fp';
|
||||
|
||||
import getBlobSHA from './getBlobSHA';
|
||||
|
||||
import type { AssetProxy } from './implementation';
|
||||
|
||||
export interface PointerFile {
|
||||
size: number;
|
||||
sha: string;
|
||||
}
|
||||
|
||||
function splitIntoLines(str: string) {
|
||||
return str.split('\n');
|
||||
}
|
||||
|
||||
function splitIntoWords(str: string) {
|
||||
return str.split(/\s+/g);
|
||||
}
|
||||
|
||||
function isNonEmptyString(str: string) {
|
||||
return str !== '';
|
||||
}
|
||||
|
||||
const withoutEmptyLines = flow([map((str: string) => str.trim()), filter(isNonEmptyString)]);
|
||||
export const parsePointerFile: (data: string) => PointerFile = flow([
|
||||
splitIntoLines,
|
||||
withoutEmptyLines,
|
||||
map(splitIntoWords),
|
||||
fromPairs,
|
||||
({ size, oid, ...rest }) => ({
|
||||
size: parseInt(size),
|
||||
sha: oid?.split(':')[1],
|
||||
...rest,
|
||||
}),
|
||||
]);
|
||||
|
||||
//
|
||||
// .gitattributes file parsing
|
||||
|
||||
function removeGitAttributesCommentsFromLine(line: string) {
|
||||
return line.split('#')[0];
|
||||
}
|
||||
|
||||
function parseGitPatternAttribute(attributeString: string) {
|
||||
// There are three kinds of attribute settings:
|
||||
// - a key=val pair sets an attribute to a specific value
|
||||
// - a key without a value and a leading hyphen sets an attribute to false
|
||||
// - a key without a value and no leading hyphen sets an attribute
|
||||
// to true
|
||||
if (attributeString.includes('=')) {
|
||||
return attributeString.split('=');
|
||||
}
|
||||
if (attributeString.startsWith('-')) {
|
||||
return [attributeString.slice(1), false];
|
||||
}
|
||||
return [attributeString, true];
|
||||
}
|
||||
|
||||
const parseGitPatternAttributes = flow([map(parseGitPatternAttribute), fromPairs]);
|
||||
|
||||
const parseGitAttributesPatternLine = flow([
|
||||
splitIntoWords,
|
||||
([pattern, ...attributes]) => [pattern, parseGitPatternAttributes(attributes)],
|
||||
]);
|
||||
|
||||
const parseGitAttributesFileToPatternAttributePairs = flow([
|
||||
splitIntoLines,
|
||||
map(removeGitAttributesCommentsFromLine),
|
||||
withoutEmptyLines,
|
||||
map(parseGitAttributesPatternLine),
|
||||
]);
|
||||
|
||||
export const getLargeMediaPatternsFromGitAttributesFile = flow([
|
||||
parseGitAttributesFileToPatternAttributePairs,
|
||||
filter(
|
||||
([, attributes]) =>
|
||||
attributes.filter === 'lfs' && attributes.diff === 'lfs' && attributes.merge === 'lfs',
|
||||
),
|
||||
map(([pattern]) => pattern),
|
||||
]);
|
||||
|
||||
export function createPointerFile({ size, sha }: PointerFile) {
|
||||
return `\
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:${sha}
|
||||
size ${size}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getPointerFileForMediaFileObj(
|
||||
client: { uploadResource: (pointer: PointerFile, resource: Blob) => Promise<string> },
|
||||
fileObj: File,
|
||||
path: string,
|
||||
) {
|
||||
const { name, size } = fileObj;
|
||||
const sha = await getBlobSHA(fileObj);
|
||||
await client.uploadResource({ sha, size }, fileObj);
|
||||
const pointerFileString = createPointerFile({ sha, size });
|
||||
const pointerFileBlob = new Blob([pointerFileString]);
|
||||
const pointerFile = new File([pointerFileBlob], name, { type: 'text/plain' });
|
||||
const pointerFileSHA = await getBlobSHA(pointerFile);
|
||||
return {
|
||||
fileObj: pointerFile,
|
||||
size: pointerFileBlob.size,
|
||||
sha: pointerFileSHA,
|
||||
raw: pointerFileString,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLargeMediaFilteredMediaFiles(
|
||||
client: {
|
||||
uploadResource: (pointer: PointerFile, resource: Blob) => Promise<string>;
|
||||
matchPath: (path: string) => boolean;
|
||||
},
|
||||
mediaFiles: AssetProxy[],
|
||||
) {
|
||||
return await Promise.all(
|
||||
mediaFiles.map(async mediaFile => {
|
||||
const { fileObj, path } = mediaFile;
|
||||
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
if (!client.matchPath(fixedPath)) {
|
||||
return mediaFile;
|
||||
}
|
||||
|
||||
const pointerFileDetails = await getPointerFileForMediaFileObj(client, fileObj as File, path);
|
||||
return { ...mediaFile, ...pointerFileDetails };
|
||||
}),
|
||||
);
|
||||
}
|
||||
590
source/admin/packages/decap-cms-lib-util/src/implementation.ts
Normal file
590
source/admin/packages/decap-cms-lib-util/src/implementation.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import semaphore from 'semaphore';
|
||||
import unionBy from 'lodash/unionBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
|
||||
import { basename } from './path';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type Cursor from './Cursor';
|
||||
import type { AsyncLock } from './asyncLock';
|
||||
import type { FileMetadata } from './API';
|
||||
|
||||
export type DisplayURLObject = { id: string; path: string };
|
||||
|
||||
export type DisplayURL = DisplayURLObject | string;
|
||||
|
||||
export interface ImplementationMediaFile {
|
||||
name: string;
|
||||
id: string;
|
||||
size?: number;
|
||||
displayURL?: DisplayURL;
|
||||
path: string;
|
||||
draft?: boolean;
|
||||
url?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export interface UnpublishedEntryMediaFile {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ImplementationEntry {
|
||||
data: string;
|
||||
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
|
||||
}
|
||||
|
||||
export interface UnpublishedEntryDiff {
|
||||
id: string;
|
||||
path: string;
|
||||
newFile: boolean;
|
||||
}
|
||||
|
||||
export interface UnpublishedEntry {
|
||||
pullRequestAuthor?: string;
|
||||
slug: string;
|
||||
collection: string;
|
||||
status: string;
|
||||
diffs: UnpublishedEntryDiff[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Map {
|
||||
get: <T>(key: string, defaultValue?: T) => T;
|
||||
getIn: <T>(key: string[], defaultValue?: T) => T;
|
||||
setIn: <T>(key: string[], value: T) => Map;
|
||||
set: <T>(key: string, value: T) => Map;
|
||||
}
|
||||
|
||||
export type DataFile = {
|
||||
path: string;
|
||||
slug: string;
|
||||
raw: string;
|
||||
newPath?: string;
|
||||
};
|
||||
|
||||
export type AssetProxy = {
|
||||
path: string;
|
||||
fileObj?: File;
|
||||
toBase64?: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
dataFiles: DataFile[];
|
||||
assets: AssetProxy[];
|
||||
};
|
||||
|
||||
export type PersistOptions = {
|
||||
newEntry?: boolean;
|
||||
commitMessage: string;
|
||||
collectionName?: string;
|
||||
useWorkflow?: boolean;
|
||||
unpublished?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type DeleteOptions = {};
|
||||
|
||||
export type Credentials = { token: string | {}; refresh_token?: string };
|
||||
|
||||
export type User = Credentials & {
|
||||
backendName?: string;
|
||||
login?: string;
|
||||
name: string;
|
||||
useOpenAuthoring?: boolean;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
backend: {
|
||||
repo?: string | null;
|
||||
open_authoring?: boolean;
|
||||
always_fork?: boolean;
|
||||
branch?: string;
|
||||
api_root?: string;
|
||||
squash_merges?: boolean;
|
||||
use_graphql?: boolean;
|
||||
graphql_api_root?: string;
|
||||
preview_context?: string;
|
||||
identity_url?: string;
|
||||
gateway_url?: string;
|
||||
large_media_url?: string;
|
||||
use_large_media_transforms_in_media_library?: boolean;
|
||||
proxy_url?: string;
|
||||
auth_type?: string;
|
||||
app_id?: string;
|
||||
base_url?: string;
|
||||
cms_label_prefix?: string;
|
||||
api_version?: string;
|
||||
status_endpoint?: string;
|
||||
};
|
||||
auth: {
|
||||
use_oidc?: boolean;
|
||||
base_url?: string;
|
||||
auth_endpoint?: string;
|
||||
auth_token_endpoint?: string;
|
||||
app_id?: string;
|
||||
auth_token_endpoint_content_type?: string;
|
||||
email_claim?: string;
|
||||
full_name_claim?: string;
|
||||
first_name_claim?: string;
|
||||
last_name_claim?: string;
|
||||
avatar_url_claim?: string;
|
||||
};
|
||||
media_folder: string;
|
||||
base_url?: string;
|
||||
site_id?: string;
|
||||
};
|
||||
|
||||
export interface Implementation {
|
||||
authComponent: () => void;
|
||||
restoreUser: (user: User) => Promise<User>;
|
||||
|
||||
authenticate: (credentials: Credentials) => Promise<User>;
|
||||
logout: () => Promise<void> | void | null;
|
||||
getToken: () => Promise<string | null>;
|
||||
|
||||
getEntry: (path: string) => Promise<ImplementationEntry>;
|
||||
entriesByFolder: (
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
) => Promise<ImplementationEntry[]>;
|
||||
entriesByFiles: (files: ImplementationFile[]) => Promise<ImplementationEntry[]>;
|
||||
|
||||
getMediaDisplayURL?: (displayURL: DisplayURL) => Promise<string>;
|
||||
getMedia: (folder?: string) => Promise<ImplementationMediaFile[]>;
|
||||
getMediaFile: (path: string) => Promise<ImplementationMediaFile>;
|
||||
|
||||
persistEntry: (entry: Entry, opts: PersistOptions) => Promise<void>;
|
||||
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
|
||||
deleteFiles: (paths: string[], commitMessage: string) => Promise<void>;
|
||||
|
||||
unpublishedEntries: () => Promise<string[]>;
|
||||
unpublishedEntry: (args: {
|
||||
id?: string;
|
||||
collection?: string;
|
||||
slug?: string;
|
||||
}) => Promise<UnpublishedEntry>;
|
||||
unpublishedEntryDataFile: (
|
||||
collection: string,
|
||||
slug: string,
|
||||
path: string,
|
||||
id: string,
|
||||
) => Promise<string>;
|
||||
unpublishedEntryMediaFile: (
|
||||
collection: string,
|
||||
slug: string,
|
||||
path: string,
|
||||
id: string,
|
||||
) => Promise<ImplementationMediaFile>;
|
||||
updateUnpublishedEntryStatus: (
|
||||
collection: string,
|
||||
slug: string,
|
||||
newStatus: string,
|
||||
) => Promise<void>;
|
||||
publishUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
|
||||
deleteUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
|
||||
getDeployPreview: (
|
||||
collectionName: string,
|
||||
slug: string,
|
||||
) => Promise<{ url: string; status: string } | null>;
|
||||
|
||||
allEntriesByFolder?: (
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
pathRegex?: RegExp,
|
||||
) => Promise<ImplementationEntry[]>;
|
||||
traverseCursor?: (
|
||||
cursor: Cursor,
|
||||
action: string,
|
||||
) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>;
|
||||
|
||||
isGitBackend?: () => boolean;
|
||||
status: () => Promise<{
|
||||
auth: { status: boolean };
|
||||
api: { status: boolean; statusPage: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export type ImplementationFile = {
|
||||
id?: string | null | undefined;
|
||||
label?: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ReadFile = (
|
||||
path: string,
|
||||
id: string | null | undefined,
|
||||
options: { parseText: boolean },
|
||||
) => Promise<string | Blob>;
|
||||
|
||||
type ReadFileMetadata = (path: string, id: string | null | undefined) => Promise<FileMetadata>;
|
||||
|
||||
type CustomFetchFunc = (files: ImplementationFile[]) => Promise<ImplementationEntry[]>;
|
||||
|
||||
async function fetchFiles(
|
||||
files: ImplementationFile[],
|
||||
readFile: ReadFile,
|
||||
readFileMetadata: ReadFileMetadata,
|
||||
apiName: string,
|
||||
) {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [] as Promise<ImplementationEntry | { error: boolean }>[];
|
||||
files.forEach(file => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(async () => {
|
||||
try {
|
||||
const [data, fileMetadata] = await Promise.all([
|
||||
readFile(file.path, file.id, { parseText: true }),
|
||||
readFileMetadata(file.path, file.id),
|
||||
]);
|
||||
resolve({ file: { ...file, ...fileMetadata }, data: data as string });
|
||||
sem.leave();
|
||||
} catch (error) {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from ${apiName}: ${file.path}`);
|
||||
resolve({ error: true });
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !(loadedEntry as { error: boolean }).error),
|
||||
) as Promise<ImplementationEntry[]>;
|
||||
}
|
||||
|
||||
export async function entriesByFolder(
|
||||
listFiles: () => Promise<ImplementationFile[]>,
|
||||
readFile: ReadFile,
|
||||
readFileMetadata: ReadFileMetadata,
|
||||
apiName: string,
|
||||
) {
|
||||
const files = await listFiles();
|
||||
return fetchFiles(files, readFile, readFileMetadata, apiName);
|
||||
}
|
||||
|
||||
export async function entriesByFiles(
|
||||
files: ImplementationFile[],
|
||||
readFile: ReadFile,
|
||||
readFileMetadata: ReadFileMetadata,
|
||||
apiName: string,
|
||||
) {
|
||||
return fetchFiles(files, readFile, readFileMetadata, apiName);
|
||||
}
|
||||
|
||||
export async function unpublishedEntries(listEntriesKeys: () => Promise<string[]>) {
|
||||
try {
|
||||
const keys = await listEntriesKeys();
|
||||
return keys;
|
||||
} catch (error) {
|
||||
if (error.message === 'Not Found') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function blobToFileObj(name: string, blob: Blob) {
|
||||
const options = name.match(/.svg$/) ? { type: 'image/svg+xml' } : {};
|
||||
return new File([blob], name, options);
|
||||
}
|
||||
|
||||
export async function getMediaAsBlob(path: string, id: string | null, readFile: ReadFile) {
|
||||
let blob: Blob;
|
||||
if (path.match(/.svg$/)) {
|
||||
const text = (await readFile(path, id, { parseText: true })) as string;
|
||||
blob = new Blob([text], { type: 'image/svg+xml' });
|
||||
} else {
|
||||
blob = (await readFile(path, id, { parseText: false })) as Blob;
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function getMediaDisplayURL(
|
||||
displayURL: DisplayURL,
|
||||
readFile: ReadFile,
|
||||
semaphore: Semaphore,
|
||||
) {
|
||||
const { path, id } = displayURL as DisplayURLObject;
|
||||
return new Promise<string>((resolve, reject) =>
|
||||
semaphore.take(() =>
|
||||
getMediaAsBlob(path, id, readFile)
|
||||
.then(blob => URL.createObjectURL(blob))
|
||||
.then(resolve, reject)
|
||||
.finally(() => semaphore.leave()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWithLock(lock: AsyncLock, func: Function, message: string) {
|
||||
try {
|
||||
const acquired = await lock.acquire();
|
||||
if (!acquired) {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
const result = await func();
|
||||
return result;
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_KEY = 'git.local';
|
||||
|
||||
type LocalTree = {
|
||||
head: string;
|
||||
files: { id: string; name: string; path: string }[];
|
||||
};
|
||||
|
||||
type GetKeyArgs = {
|
||||
branch: string;
|
||||
folder: string;
|
||||
extension: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function getLocalKey({ branch, folder, extension, depth }: GetKeyArgs) {
|
||||
return `${LOCAL_KEY}.${branch}.${folder}.${extension}.${depth}`;
|
||||
}
|
||||
|
||||
type PersistLocalTreeArgs = GetKeyArgs & {
|
||||
localForage: LocalForage;
|
||||
localTree: LocalTree;
|
||||
};
|
||||
|
||||
type GetLocalTreeArgs = GetKeyArgs & {
|
||||
localForage: LocalForage;
|
||||
};
|
||||
|
||||
export async function persistLocalTree({
|
||||
localForage,
|
||||
localTree,
|
||||
branch,
|
||||
folder,
|
||||
extension,
|
||||
depth,
|
||||
}: PersistLocalTreeArgs) {
|
||||
await localForage.setItem<LocalTree>(
|
||||
getLocalKey({ branch, folder, extension, depth }),
|
||||
localTree,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLocalTree({
|
||||
localForage,
|
||||
branch,
|
||||
folder,
|
||||
extension,
|
||||
depth,
|
||||
}: GetLocalTreeArgs) {
|
||||
const localTree = await localForage.getItem<LocalTree>(
|
||||
getLocalKey({ branch, folder, extension, depth }),
|
||||
);
|
||||
return localTree;
|
||||
}
|
||||
|
||||
type GetDiffFromLocalTreeMethods = {
|
||||
getDifferences: (
|
||||
to: string,
|
||||
from: string,
|
||||
) => Promise<
|
||||
{
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
status: string;
|
||||
}[]
|
||||
>;
|
||||
filterFile: (file: { path: string; name: string }) => boolean;
|
||||
getFileId: (path: string) => Promise<string>;
|
||||
};
|
||||
|
||||
type GetDiffFromLocalTreeArgs = GetDiffFromLocalTreeMethods & {
|
||||
branch: { name: string; sha: string };
|
||||
localTree: LocalTree;
|
||||
folder: string;
|
||||
extension: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
async function getDiffFromLocalTree({
|
||||
branch,
|
||||
localTree,
|
||||
folder,
|
||||
getDifferences,
|
||||
filterFile,
|
||||
getFileId,
|
||||
}: GetDiffFromLocalTreeArgs) {
|
||||
const diff = await getDifferences(branch.sha, localTree.head);
|
||||
const diffFiles = diff
|
||||
.filter(d => d.oldPath?.startsWith(folder) || d.newPath?.startsWith(folder))
|
||||
.reduce((acc, d) => {
|
||||
if (d.status === 'renamed') {
|
||||
acc.push({
|
||||
path: d.oldPath,
|
||||
name: basename(d.oldPath),
|
||||
deleted: true,
|
||||
});
|
||||
acc.push({
|
||||
path: d.newPath,
|
||||
name: basename(d.newPath),
|
||||
deleted: false,
|
||||
});
|
||||
} else if (d.status === 'deleted') {
|
||||
acc.push({
|
||||
path: d.oldPath,
|
||||
name: basename(d.oldPath),
|
||||
deleted: true,
|
||||
});
|
||||
} else {
|
||||
acc.push({
|
||||
path: d.newPath || d.oldPath,
|
||||
name: basename(d.newPath || d.oldPath),
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as { path: string; name: string; deleted: boolean }[])
|
||||
|
||||
.filter(filterFile);
|
||||
|
||||
const diffFilesWithIds = await Promise.all(
|
||||
diffFiles.map(async file => {
|
||||
if (!file.deleted) {
|
||||
const id = await getFileId(file.path);
|
||||
return { ...file, id };
|
||||
} else {
|
||||
return { ...file, id: '' };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return diffFilesWithIds;
|
||||
}
|
||||
|
||||
type AllEntriesByFolderArgs = GetKeyArgs &
|
||||
GetDiffFromLocalTreeMethods & {
|
||||
listAllFiles: (
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
) => Promise<ImplementationFile[]>;
|
||||
readFile: ReadFile;
|
||||
readFileMetadata: ReadFileMetadata;
|
||||
getDefaultBranch: () => Promise<{ name: string; sha: string }>;
|
||||
isShaExistsInBranch: (branch: string, sha: string) => Promise<boolean>;
|
||||
apiName: string;
|
||||
localForage: LocalForage;
|
||||
customFetch?: CustomFetchFunc;
|
||||
};
|
||||
|
||||
export async function allEntriesByFolder({
|
||||
listAllFiles,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
apiName,
|
||||
branch,
|
||||
localForage,
|
||||
folder,
|
||||
extension,
|
||||
depth,
|
||||
getDefaultBranch,
|
||||
isShaExistsInBranch,
|
||||
getDifferences,
|
||||
getFileId,
|
||||
filterFile,
|
||||
customFetch,
|
||||
}: AllEntriesByFolderArgs) {
|
||||
async function listAllFilesAndPersist() {
|
||||
const files = await listAllFiles(folder, extension, depth);
|
||||
const branch = await getDefaultBranch();
|
||||
await persistLocalTree({
|
||||
localForage,
|
||||
localTree: {
|
||||
head: branch.sha,
|
||||
files: files.map(f => ({ id: f.id!, path: f.path, name: basename(f.path) })),
|
||||
},
|
||||
branch: branch.name,
|
||||
depth,
|
||||
extension,
|
||||
folder,
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
async function listFiles() {
|
||||
const localTree = await getLocalTree({ localForage, branch, folder, extension, depth });
|
||||
if (localTree) {
|
||||
const branch = await getDefaultBranch();
|
||||
// if the branch was forced pushed the local tree sha can be removed from the remote tree
|
||||
const localTreeInBranch = await isShaExistsInBranch(branch.name, localTree.head);
|
||||
if (!localTreeInBranch) {
|
||||
console.log(
|
||||
`Can't find local tree head '${localTree.head}' in branch '${branch.name}', rebuilding local tree`,
|
||||
);
|
||||
return listAllFilesAndPersist();
|
||||
}
|
||||
const diff = await getDiffFromLocalTree({
|
||||
branch,
|
||||
localTree,
|
||||
folder,
|
||||
extension,
|
||||
depth,
|
||||
getDifferences,
|
||||
getFileId,
|
||||
filterFile,
|
||||
}).catch(e => {
|
||||
console.log('Failed getting diff from local tree:', e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!diff) {
|
||||
console.log(`Diff is null, rebuilding local tree`);
|
||||
return listAllFilesAndPersist();
|
||||
}
|
||||
|
||||
if (diff.length === 0) {
|
||||
// return local copy
|
||||
return localTree.files;
|
||||
} else {
|
||||
const deleted = diff.reduce((acc, d) => {
|
||||
acc[d.path] = d.deleted;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
const newCopy = sortBy(
|
||||
unionBy(
|
||||
diff.filter(d => !deleted[d.path]),
|
||||
localTree.files.filter(f => !deleted[f.path]),
|
||||
file => file.path,
|
||||
),
|
||||
file => file.path,
|
||||
);
|
||||
|
||||
await persistLocalTree({
|
||||
localForage,
|
||||
localTree: { head: branch.sha, files: newCopy },
|
||||
branch: branch.name,
|
||||
depth,
|
||||
extension,
|
||||
folder,
|
||||
});
|
||||
|
||||
return newCopy;
|
||||
}
|
||||
} else {
|
||||
return listAllFilesAndPersist();
|
||||
}
|
||||
}
|
||||
|
||||
const files = await listFiles();
|
||||
if (customFetch) {
|
||||
return await customFetch(files);
|
||||
}
|
||||
return await fetchFiles(files, readFile, readFileMetadata, apiName);
|
||||
}
|
||||
213
source/admin/packages/decap-cms-lib-util/src/index.ts
Normal file
213
source/admin/packages/decap-cms-lib-util/src/index.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import APIError from './APIError';
|
||||
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor';
|
||||
import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError';
|
||||
import AccessTokenError from './AccessTokenError';
|
||||
import localForage from './localForage';
|
||||
import { isAbsolutePath, basename, fileExtensionWithSeparator, fileExtension } from './path';
|
||||
import { onlySuccessfulPromises, flowAsync, then } from './promise';
|
||||
import unsentRequest from './unsentRequest';
|
||||
import {
|
||||
filterByExtension,
|
||||
getAllResponses,
|
||||
parseLinkHeader,
|
||||
parseResponse,
|
||||
responseParser,
|
||||
getPathDepth,
|
||||
} from './backendUtil';
|
||||
import loadScript from './loadScript';
|
||||
import getBlobSHA from './getBlobSHA';
|
||||
import { asyncLock } from './asyncLock';
|
||||
import {
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
unpublishedEntries,
|
||||
getMediaDisplayURL,
|
||||
getMediaAsBlob,
|
||||
runWithLock,
|
||||
blobToFileObj,
|
||||
allEntriesByFolder,
|
||||
} from './implementation';
|
||||
import {
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
isPreviewContext,
|
||||
getPreviewStatus,
|
||||
PreviewState,
|
||||
requestWithBackoff,
|
||||
getDefaultBranchName,
|
||||
throwOnConflictingBranches,
|
||||
} from './API';
|
||||
import {
|
||||
CMS_BRANCH_PREFIX,
|
||||
generateContentKey,
|
||||
isCMSLabel,
|
||||
labelToStatus,
|
||||
statusToLabel,
|
||||
DEFAULT_PR_BODY,
|
||||
MERGE_COMMIT_MESSAGE,
|
||||
parseContentKey,
|
||||
branchFromContentKey,
|
||||
contentKeyFromBranch,
|
||||
} from './APIUtils';
|
||||
import {
|
||||
createPointerFile,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
parsePointerFile,
|
||||
getPointerFileForMediaFileObj,
|
||||
} from './git-lfs';
|
||||
|
||||
import type { PointerFile as PF } from './git-lfs';
|
||||
import type { FetchError as FE, ApiRequest as AR } from './API';
|
||||
import type {
|
||||
Implementation as I,
|
||||
ImplementationEntry as IE,
|
||||
UnpublishedEntryDiff as UED,
|
||||
UnpublishedEntry as UE,
|
||||
ImplementationMediaFile as IMF,
|
||||
ImplementationFile as IF,
|
||||
DisplayURLObject as DUO,
|
||||
DisplayURL as DU,
|
||||
Credentials as Cred,
|
||||
User as U,
|
||||
Entry as E,
|
||||
PersistOptions as PO,
|
||||
AssetProxy as AP,
|
||||
Config as C,
|
||||
UnpublishedEntryMediaFile as UEMF,
|
||||
DataFile as DF,
|
||||
} from './implementation';
|
||||
import type { AsyncLock as AL } from './asyncLock';
|
||||
|
||||
export type AsyncLock = AL;
|
||||
export type Implementation = I;
|
||||
export type ImplementationEntry = IE;
|
||||
export type UnpublishedEntryDiff = UED;
|
||||
export type UnpublishedEntry = UE;
|
||||
export type ImplementationMediaFile = IMF;
|
||||
export type ImplementationFile = IF;
|
||||
export type DisplayURL = DU;
|
||||
export type DisplayURLObject = DUO;
|
||||
export type Credentials = Cred;
|
||||
export type User = U;
|
||||
export type Entry = E;
|
||||
export type UnpublishedEntryMediaFile = UEMF;
|
||||
export type PersistOptions = PO;
|
||||
export type AssetProxy = AP;
|
||||
export type ApiRequest = AR;
|
||||
export type Config = C;
|
||||
export type FetchError = FE;
|
||||
export type PointerFile = PF;
|
||||
export type DataFile = DF;
|
||||
|
||||
export const DecapCmsLibUtil = {
|
||||
APIError,
|
||||
Cursor,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
EditorialWorkflowError,
|
||||
EDITORIAL_WORKFLOW_ERROR,
|
||||
localForage,
|
||||
basename,
|
||||
fileExtensionWithSeparator,
|
||||
fileExtension,
|
||||
onlySuccessfulPromises,
|
||||
flowAsync,
|
||||
then,
|
||||
unsentRequest,
|
||||
filterByExtension,
|
||||
parseLinkHeader,
|
||||
parseResponse,
|
||||
responseParser,
|
||||
loadScript,
|
||||
getBlobSHA,
|
||||
getPathDepth,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
unpublishedEntries,
|
||||
getMediaDisplayURL,
|
||||
getMediaAsBlob,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
CMS_BRANCH_PREFIX,
|
||||
generateContentKey,
|
||||
isCMSLabel,
|
||||
labelToStatus,
|
||||
statusToLabel,
|
||||
DEFAULT_PR_BODY,
|
||||
MERGE_COMMIT_MESSAGE,
|
||||
isPreviewContext,
|
||||
getPreviewStatus,
|
||||
runWithLock,
|
||||
PreviewState,
|
||||
parseContentKey,
|
||||
createPointerFile,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
parsePointerFile,
|
||||
getPointerFileForMediaFileObj,
|
||||
branchFromContentKey,
|
||||
contentKeyFromBranch,
|
||||
blobToFileObj,
|
||||
requestWithBackoff,
|
||||
getDefaultBranchName,
|
||||
allEntriesByFolder,
|
||||
AccessTokenError,
|
||||
throwOnConflictingBranches,
|
||||
};
|
||||
export {
|
||||
APIError,
|
||||
Cursor,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
EditorialWorkflowError,
|
||||
EDITORIAL_WORKFLOW_ERROR,
|
||||
localForage,
|
||||
basename,
|
||||
fileExtensionWithSeparator,
|
||||
fileExtension,
|
||||
onlySuccessfulPromises,
|
||||
flowAsync,
|
||||
then,
|
||||
unsentRequest,
|
||||
filterByExtension,
|
||||
parseLinkHeader,
|
||||
getAllResponses,
|
||||
parseResponse,
|
||||
responseParser,
|
||||
loadScript,
|
||||
getBlobSHA,
|
||||
asyncLock,
|
||||
isAbsolutePath,
|
||||
getPathDepth,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
unpublishedEntries,
|
||||
getMediaDisplayURL,
|
||||
getMediaAsBlob,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
CMS_BRANCH_PREFIX,
|
||||
generateContentKey,
|
||||
isCMSLabel,
|
||||
labelToStatus,
|
||||
statusToLabel,
|
||||
DEFAULT_PR_BODY,
|
||||
MERGE_COMMIT_MESSAGE,
|
||||
isPreviewContext,
|
||||
getPreviewStatus,
|
||||
runWithLock,
|
||||
PreviewState,
|
||||
parseContentKey,
|
||||
createPointerFile,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
parsePointerFile,
|
||||
getPointerFileForMediaFileObj,
|
||||
branchFromContentKey,
|
||||
contentKeyFromBranch,
|
||||
blobToFileObj,
|
||||
requestWithBackoff,
|
||||
getDefaultBranchName,
|
||||
allEntriesByFolder,
|
||||
AccessTokenError,
|
||||
throwOnConflictingBranches,
|
||||
};
|
||||
24
source/admin/packages/decap-cms-lib-util/src/loadScript.js
Normal file
24
source/admin/packages/decap-cms-lib-util/src/loadScript.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Simple script loader that returns a promise.
|
||||
*/
|
||||
export default function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
const head = document.getElementsByTagName('head')[0];
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.onload = script.onreadystatechange = function () {
|
||||
if (
|
||||
!done &&
|
||||
(!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete')
|
||||
) {
|
||||
done = true;
|
||||
resolve();
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
};
|
||||
script.onerror = error => reject(error);
|
||||
head.appendChild(script);
|
||||
});
|
||||
}
|
||||
21
source/admin/packages/decap-cms-lib-util/src/localForage.ts
Normal file
21
source/admin/packages/decap-cms-lib-util/src/localForage.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import localForage from 'localforage';
|
||||
|
||||
function localForageTest() {
|
||||
const testKey = 'localForageTest';
|
||||
localForage
|
||||
.setItem(testKey, { expires: Date.now() + 300000 })
|
||||
.then(() => {
|
||||
localForage.removeItem(testKey);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.code === 22) {
|
||||
const message = 'Unable to set localStorage key. Quota exceeded! Full disk?';
|
||||
console.warn(message);
|
||||
}
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
localForageTest();
|
||||
|
||||
export default localForage;
|
||||
86
source/admin/packages/decap-cms-lib-util/src/path.ts
Normal file
86
source/admin/packages/decap-cms-lib-util/src/path.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i');
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.replace(/[\\/]+/g, '/');
|
||||
}
|
||||
|
||||
export function isAbsolutePath(path: string) {
|
||||
return absolutePath.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last portion of a path. Similar to the Unix basename command.
|
||||
* @example Usage example
|
||||
* path.basename('/foo/bar/baz/asdf/quux.html')
|
||||
* // returns
|
||||
* 'quux.html'
|
||||
*
|
||||
* path.basename('/foo/bar/baz/asdf/quux.html', '.html')
|
||||
* // returns
|
||||
* 'quux'
|
||||
*/
|
||||
export function basename(p: string, ext = '') {
|
||||
// Special case: Normalize will modify this to '.'
|
||||
if (p === '') {
|
||||
return p;
|
||||
}
|
||||
// Normalize the string first to remove any weirdness.
|
||||
p = normalizePath(p);
|
||||
// Get the last part of the string.
|
||||
const sections = p.split('/');
|
||||
const lastPart = sections[sections.length - 1];
|
||||
// Special case: If it's empty, then we have a string like so: foo/
|
||||
// Meaning, 'foo' is guaranteed to be a directory.
|
||||
if (lastPart === '' && sections.length > 1) {
|
||||
return sections[sections.length - 2];
|
||||
}
|
||||
// Remove the extension, if need be.
|
||||
if (ext.length > 0) {
|
||||
const lastPartExt = lastPart.slice(-ext.length);
|
||||
if (lastPartExt === ext) {
|
||||
return lastPart.slice(0, -ext.length);
|
||||
}
|
||||
}
|
||||
return lastPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the extension of the path, from the last '.' to end of string in the
|
||||
* last portion of the path. If there is no '.' in the last portion of the path
|
||||
* or the first character of it is '.', then it returns an empty string.
|
||||
* @example Usage example
|
||||
* path.fileExtensionWithSeparator('index.html')
|
||||
* // returns
|
||||
* '.html'
|
||||
*/
|
||||
export function fileExtensionWithSeparator(p: string) {
|
||||
p = normalizePath(p);
|
||||
const sections = p.split('/');
|
||||
p = sections.pop() as string;
|
||||
// Special case: foo/file.ext/ should return '.ext'
|
||||
if (p === '' && sections.length > 0) {
|
||||
p = sections.pop() as string;
|
||||
}
|
||||
if (p === '..') {
|
||||
return '';
|
||||
}
|
||||
const i = p.lastIndexOf('.');
|
||||
if (i === -1 || i === 0) {
|
||||
return '';
|
||||
}
|
||||
return p.slice(i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the extension of the path, from after the last '.' to end of string in the
|
||||
* last portion of the path. If there is no '.' in the last portion of the path
|
||||
* or the first character of it is '.', then it returns an empty string.
|
||||
* @example Usage example
|
||||
* path.fileExtension('index.html')
|
||||
* // returns
|
||||
* 'html'
|
||||
*/
|
||||
export function fileExtension(p: string) {
|
||||
const ext = fileExtensionWithSeparator(p);
|
||||
return ext === '' ? ext : ext.slice(1);
|
||||
}
|
||||
21
source/admin/packages/decap-cms-lib-util/src/promise.ts
Normal file
21
source/admin/packages/decap-cms-lib-util/src/promise.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import flow from 'lodash/flow';
|
||||
|
||||
export function then<T, V>(fn: (r: T) => V) {
|
||||
return (p: Promise<T>) => Promise.resolve(p).then(fn);
|
||||
}
|
||||
|
||||
const filterPromiseSymbol = Symbol('filterPromiseSymbol');
|
||||
|
||||
export function onlySuccessfulPromises(promises: Promise<unknown>[]) {
|
||||
return Promise.all(promises.map(p => p.catch(() => filterPromiseSymbol))).then(results =>
|
||||
results.filter(result => result !== filterPromiseSymbol),
|
||||
);
|
||||
}
|
||||
|
||||
function wrapFlowAsync(fn: Function) {
|
||||
return async (arg: unknown) => fn(await arg);
|
||||
}
|
||||
|
||||
export function flowAsync(fns: Function[]) {
|
||||
return flow(fns.map(fn => wrapFlowAsync(fn)));
|
||||
}
|
||||
9
source/admin/packages/decap-cms-lib-util/src/types.ts
Normal file
9
source/admin/packages/decap-cms-lib-util/src/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Map as ImmutableMap, List } from 'immutable';
|
||||
|
||||
export function isImmutableMap(value: unknown): value is ImmutableMap<string, unknown> {
|
||||
return ImmutableMap.isMap(value);
|
||||
}
|
||||
|
||||
export function isImmutableList(value: unknown): value is List<unknown> {
|
||||
return List.isList(value);
|
||||
}
|
||||
5
source/admin/packages/decap-cms-lib-util/src/types/semaphore.d.ts
vendored
Normal file
5
source/admin/packages/decap-cms-lib-util/src/types/semaphore.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module 'semaphore' {
|
||||
export type Semaphore = { take: (f: Function) => void; leave: () => void };
|
||||
const semaphore: (count: number) => Semaphore;
|
||||
export default semaphore;
|
||||
}
|
||||
133
source/admin/packages/decap-cms-lib-util/src/unsentRequest.js
Normal file
133
source/admin/packages/decap-cms-lib-util/src/unsentRequest.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { fromJS, List, Map } from 'immutable';
|
||||
import curry from 'lodash/curry';
|
||||
import flow from 'lodash/flow';
|
||||
import isString from 'lodash/isString';
|
||||
|
||||
function isAbortControllerSupported() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return !!window.AbortController;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeout = 60;
|
||||
|
||||
function fetchWithTimeout(input, init) {
|
||||
if ((init && init.signal) || !isAbortControllerSupported()) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
|
||||
return fetch(input, { ...init, signal: controller.signal })
|
||||
.then(res => {
|
||||
clearTimeout(timeoutId);
|
||||
return res;
|
||||
})
|
||||
.catch(e => {
|
||||
if (e.name === 'AbortError' || e.name === 'DOMException') {
|
||||
throw new Error(`Request timed out after ${timeout} seconds`);
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
function decodeParams(paramsString) {
|
||||
return List(paramsString.split('&'))
|
||||
.map(s => List(s.split('=')).map(decodeURIComponent))
|
||||
.update(Map);
|
||||
}
|
||||
|
||||
function fromURL(wholeURL) {
|
||||
const [url, allParamsString] = wholeURL.split('?');
|
||||
return Map({ url, ...(allParamsString ? { params: decodeParams(allParamsString) } : {}) });
|
||||
}
|
||||
|
||||
function fromFetchArguments(wholeURL, options) {
|
||||
return fromURL(wholeURL).merge(
|
||||
(options ? fromJS(options) : Map()).remove('url').remove('params'),
|
||||
);
|
||||
}
|
||||
|
||||
function encodeParams(params) {
|
||||
return params
|
||||
.entrySeq()
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function toURL(req) {
|
||||
return `${req.get('url')}${req.get('params') ? `?${encodeParams(req.get('params'))}` : ''}`;
|
||||
}
|
||||
|
||||
function toFetchArguments(req) {
|
||||
return [toURL(req), req.remove('url').remove('params').toJS()];
|
||||
}
|
||||
|
||||
function maybeRequestArg(req) {
|
||||
if (isString(req)) {
|
||||
return fromURL(req);
|
||||
}
|
||||
if (req) {
|
||||
return fromJS(req);
|
||||
}
|
||||
return Map();
|
||||
}
|
||||
|
||||
function ensureRequestArg(func) {
|
||||
return req => func(maybeRequestArg(req));
|
||||
}
|
||||
|
||||
function ensureRequestArg2(func) {
|
||||
return (arg, req) => func(arg, maybeRequestArg(req));
|
||||
}
|
||||
|
||||
// This actually performs the built request object
|
||||
const performRequest = ensureRequestArg(req => {
|
||||
const args = toFetchArguments(req);
|
||||
return fetchWithTimeout(...args);
|
||||
});
|
||||
|
||||
// Each of the following functions takes options and returns another
|
||||
// function that performs the requested action on a request.
|
||||
const getCurriedRequestProcessor = flow([ensureRequestArg2, curry]);
|
||||
|
||||
function getPropSetFunction(path) {
|
||||
return getCurriedRequestProcessor((val, req) => req.setIn(path, val));
|
||||
}
|
||||
|
||||
function getPropMergeFunction(path) {
|
||||
return getCurriedRequestProcessor((obj, req) => req.updateIn(path, (p = Map()) => p.merge(obj)));
|
||||
}
|
||||
|
||||
const withMethod = getPropSetFunction(['method']);
|
||||
const withBody = getPropSetFunction(['body']);
|
||||
const withNoCache = getPropSetFunction(['cache'])('no-cache');
|
||||
const withParams = getPropMergeFunction(['params']);
|
||||
const withHeaders = getPropMergeFunction(['headers']);
|
||||
|
||||
// withRoot sets a root URL, unless the URL is already absolute
|
||||
const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i');
|
||||
const withRoot = getCurriedRequestProcessor((root, req) =>
|
||||
req.update('url', p => {
|
||||
if (absolutePath.test(p)) {
|
||||
return p;
|
||||
}
|
||||
return root && p && p[0] !== '/' && root[root.length - 1] !== '/'
|
||||
? `${root}/${p}`
|
||||
: `${root}${p}`;
|
||||
}),
|
||||
);
|
||||
|
||||
export default {
|
||||
toURL,
|
||||
fromURL,
|
||||
fromFetchArguments,
|
||||
performRequest,
|
||||
withMethod,
|
||||
withBody,
|
||||
withHeaders,
|
||||
withParams,
|
||||
withRoot,
|
||||
withNoCache,
|
||||
fetchWithTimeout,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
const { getConfig } = require('../../scripts/webpack.js');
|
||||
|
||||
module.exports = getConfig();
|
||||
Reference in New Issue
Block a user