This commit is contained in:
2025-08-25 20:24:23 +08:00
parent 30106e0129
commit 0ae8d7a709
1044 changed files with 321581 additions and 0 deletions

View 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))

View 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

View 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"
}
}

View 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,
);
}
}

View 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;
}
}

View 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}`;
}

View File

@@ -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;
}
}

View 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');

View File

@@ -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;
}
}

View File

@@ -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();
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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.');
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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('');
});
});

View File

@@ -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' },
});
});
});
});

View 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 };
}

View 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;
}

View 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);
});

View 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 };
}),
);
}

View 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);
}

View 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,
};

View 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);
});
}

View 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;

View 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);
}

View 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)));
}

View 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);
}

View 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;
}

View 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,
};

View File

@@ -0,0 +1,3 @@
const { getConfig } = require('../../scripts/webpack.js');
module.exports = getConfig();