diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..eefa92d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **Add tests!** — Your patch won't be accepted if it doesn't have tests. + +- **100% code coverage** — Run `composer coverage` locally. The minimum threshold is 100% for `src/`. + +- **Run the quality suite** — Run `composer quality` before submitting (tests, PHPStan, Pint). + +- **Document any change in behaviour** — Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** — We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** — If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** — Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..d65e6df --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please email security@justbetter.nl instead of using the issue tracker. diff --git a/.github/workflows/analyse.yml b/.github/workflows/analyse.yml new file mode 100644 index 0000000..dfae29f --- /dev/null +++ b/.github/workflows/analyse.yml @@ -0,0 +1,34 @@ +name: analyse + +on: ['push', 'pull_request'] + +jobs: + analyse: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.4, 8.5] + laravel: ['12.40.*', '13.*'] + + name: P${{ matrix.php }} - L${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer install + + - name: Analyse + run: composer analyse diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..42ad94b --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,28 @@ +name: coverage + +on: ['push', 'pull_request'] + +jobs: + coverage: + runs-on: ubuntu-latest + + name: Coverage + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, xdebug + coverage: xdebug + + - name: Install dependencies + run: | + composer require "laravel/framework:13.*" --no-interaction --no-update + composer install + + - name: Coverage + run: composer coverage diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..e8d6066 --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,28 @@ +name: style + +on: ['push', 'pull_request'] + +jobs: + style: + runs-on: ubuntu-latest + + name: Style + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:13.*" --no-interaction --no-update + composer install + + - name: Style + run: composer style diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9cfad6e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: tests + +on: ['push', 'pull_request'] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.4, 8.5] + laravel: ['12.40.*', '13.*'] + + name: P${{ matrix.php }} - L${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer install + + - name: Tests + run: composer test diff --git a/.gitignore b/.gitignore index d5673e3..b1540bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/vendor/ +/vendor node_modules/ npm-debug.log yarn-error.log @@ -28,3 +28,8 @@ Homestead.json .env.production .phpactor.json auth.json + +resources/dist/hot +/composer.lock +package-lock.json +yarn.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0a5c39 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Statamic Base + +Foundation addon for JustBetter Statamic packages. Provides a Control Panel overview of installed `justbetter/*` and `just-better/*` Composer packages. + +## Features + +- **JustBetter** CP navigation section with a **Packages** overview +- Lists production and development JustBetter packages separately +- Shows installed version, latest stable Packagist version, and semver-aware update badges +- JustBetter nav and overview header use `icon_url` (and optional `icon_dark_url`) as SVG `` markup (browser loads assets; no server-side fetch). Dark variant toggles with Tailwind `dark:` like the rest of the CP. +- Custom permission: `view justbetter packages` + +## Requirements + +- PHP ^8.4 +- Laravel ^12.40 or ^13.0 +- Statamic ^6.0 + +## Installation + +```bash +composer require justbetter/statamic-base +``` + +Publish the config (optional): + +```bash +php artisan vendor:publish --tag=justbetter-statamic-base +``` + +Build CP assets (required for the Inertia overview page): + +```bash +cd vendor/justbetter/statamic-base +npm install +npm run build +``` + +During development: + +```bash +npm run dev +``` + +## Configuration + +Config file: `config/justbetter/statamic-base.php` + +| Key | Default | Description | +|-----|---------|-------------| +| `packagist_cache_ttl` | `3600` | Seconds to cache Packagist responses | +| `icon_url` | `https://opensource.justbetter.nl/statamic/justbetter-logo-small-black.svg` | Light / default theme: URL for the `` in the nav/header SVG | +| `icon_dark_url` | `null` | Optional. When set to a valid URL, dark mode uses this asset (same SVG, second `` behind `hidden dark:block`) | + +Environment variables: `STATAMIC_BASE_PACKAGIST_CACHE_TTL`, `STATAMIC_BASE_ICON_URL`, `STATAMIC_BASE_ICON_DARK_URL` + +## Permissions + +Assign the **View JustBetter packages** permission to roles that should access the overview. Super users always have access. + +## Quality + +```bash +composer quality +composer coverage +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0042838 --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "justbetter/statamic-base", + "description": "Foundation addon for JustBetter Statamic packages.", + "license": "MIT", + "require": { + "php": "^8.4", + "composer/semver": "^3.4", + "guzzlehttp/guzzle": "^7.0", + "laravel/framework": "^12.40|^13.0", + "statamic/cms": "^6.0" + }, + "require-dev": { + "larastan/larastan": "^3.4", + "laravel/pint": "^1.22", + "orchestra/testbench": "^10.8|^11.0", + "pestphp/pest": "^3.7", + "phpstan/phpstan-mockery": "^2.0", + "phpunit/phpunit": "^11.5" + }, + "autoload": { + "psr-4": { + "JustBetter\\StatamicBase\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "JustBetter\\StatamicBase\\Tests\\": "tests/" + } + }, + "extra": { + "statamic": { + "name": "Statamic Base", + "description": "Foundation addon for JustBetter Statamic packages." + }, + "laravel": { + "providers": [ + "JustBetter\\StatamicBase\\ServiceProvider" + ] + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan --memory-limit=1G", + "style": "pint --test", + "style:fix": "pint", + "coverage": "XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=100", + "quality": [ + "@test", + "@analyse", + "@style" + ] + }, + "config": { + "allow-plugins": { + "pixelfear/composer-dist-plugin": true, + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/statamic-base.php b/config/statamic-base.php new file mode 100644 index 0000000..cbc6a86 --- /dev/null +++ b/config/statamic-base.php @@ -0,0 +1,14 @@ + [ + 'view' => 'view justbetter packages', + ], + + 'packagist_cache_ttl' => (int) env('STATAMIC_BASE_PACKAGIST_CACHE_TTL', 3600), + + 'icon_url' => 'https://opensource.justbetter.nl/statamic/justbetter-logo-small-black.svg', + + 'icon_dark_url' => 'https://opensource.justbetter.nl/statamic/justbetter-logo-small-white.svg', +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..11d7da5 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@statamic/cms": "file:./vendor/statamic/cms/resources/dist-package", + "@tailwindcss/vite": "^4.1.18", + "laravel-vite-plugin": "^1.2.0", + "tailwindcss": "^4.1.18", + "vue": "^3.5.31" + }, + "devDependencies": { + "vite": "^6.3.4" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..adecec8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - ./vendor/larastan/larastan/extension.neon + - ./vendor/phpstan/phpstan-mockery/extension.neon + +parameters: + paths: + - src + - tests + level: 9 diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..db8afb3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + ./tests/* + + + + + + ./src + + + diff --git a/resources/css/justbetter-statamic-base.css b/resources/css/justbetter-statamic-base.css new file mode 100644 index 0000000..1249799 --- /dev/null +++ b/resources/css/justbetter-statamic-base.css @@ -0,0 +1 @@ +@import "@statamic/cms/tailwind.css"; diff --git a/resources/dist/build/assets/justbetter-statamic-base-CZtGSfRx.js b/resources/dist/build/assets/justbetter-statamic-base-CZtGSfRx.js new file mode 100644 index 0000000..c8c13e1 --- /dev/null +++ b/resources/dist/build/assets/justbetter-statamic-base-CZtGSfRx.js @@ -0,0 +1 @@ +const T=window.Vue,{BaseTransition:V,BaseTransitionPropsValidators:N,Comment:z,DeprecationTypes:U,EffectScope:G,ErrorCodes:O,ErrorTypeStrings:j,Fragment:b,KeepAlive:q,ReactiveEffect:$,Static:K,Suspense:W,Teleport:J,Text:Q,TrackOpTypes:X,Transition:Y,TransitionGroup:Z,TriggerOpTypes:ee,VueElement:te,__esModule:oe,assertNumber:ne,callWithAsyncErrorHandling:ae,callWithErrorHandling:re,camelize:ie,capitalize:se,cloneVNode:le,compatUtils:de,compile:ue,computed:ce,createApp:pe,createBlock:C,createCommentVNode:f,createElementBlock:p,createElementVNode:m,createHydrationRenderer:me,createPropsRestProxy:ge,createRenderer:Ce,createSSRApp:fe,createSlots:Se,createStaticVNode:xe,createTextVNode:d,createVNode:t,customRef:he,defineAsyncComponent:Pe,defineComponent:Te,defineCustomElement:be,defineEmits:ve,defineExpose:ye,defineModel:ke,defineOptions:we,defineProps:Re,defineSSRCustomElement:Me,defineSlots:Ie,devtools:Ae,effect:_e,effectScope:Le,getCurrentInstance:De,getCurrentScope:Ee,getCurrentWatcher:He,getTransitionRawChildren:Fe,guardReactiveProps:Be,h:Ve,handleError:Ne,hasInjectionContext:ze,hydrate:Ue,hydrateOnIdle:Ge,hydrateOnInteraction:Oe,hydrateOnMediaQuery:je,hydrateOnVisible:qe,initCustomFormatter:$e,initDirectivesForSSR:Ke,inject:We,isMemoSame:Je,isProxy:Qe,isReactive:Xe,isReadonly:Ye,isRef:Ze,isRuntimeOnly:et,isShallow:tt,isVNode:ot,markRaw:nt,mergeDefaults:at,mergeModels:rt,mergeProps:it,nextTick:st,nodeOps:lt,normalizeClass:dt,normalizeProps:ut,normalizeStyle:ct,onActivated:pt,onBeforeMount:mt,onBeforeUnmount:gt,onBeforeUpdate:Ct,onDeactivated:ft,onErrorCaptured:St,onMounted:xt,onRenderTracked:ht,onRenderTriggered:Pt,onScopeDispose:Tt,onServerPrefetch:bt,onUnmounted:vt,onUpdated:yt,onWatcherCleanup:kt,openBlock:r,patchProp:wt,popScopeId:Rt,provide:Mt,proxyRefs:It,pushScopeId:At,queuePostFlushCb:_t,reactive:Lt,readonly:Dt,ref:Et,registerRuntimeCompiler:Ht,render:Ft,renderList:v,renderSlot:Bt,resolveComponent:Vt,resolveDirective:Nt,resolveDynamicComponent:zt,resolveFilter:Ut,resolveTransitionHooks:Gt,setBlockTracking:Ot,setDevtoolsHook:jt,setTransitionHooks:qt,shallowReactive:$t,shallowReadonly:Kt,shallowRef:Wt,ssrContextKey:Jt,ssrUtils:Qt,stop:Xt,toDisplayString:l,toHandlerKey:Yt,toHandlers:Zt,toRaw:eo,toRef:to,toRefs:oo,toValue:no,transformVNodeArgs:ao,triggerRef:ro,unref:e,useAttrs:io,useCssModule:so,useCssVars:lo,useHost:uo,useId:co,useModel:po,useSSRContext:mo,useShadowRoot:go,useSlots:Co,useTemplateRef:fo,useTransitionState:So,vModelCheckbox:xo,vModelDynamic:ho,vModelRadio:Po,vModelSelect:To,vModelText:bo,vShow:vo,version:yo,warn:ko,watch:wo,watchEffect:Ro,watchPostEffect:Mo,watchSyncEffect:Io,withAsyncContext:Ao,withCtx:n,withDefaults:_o,withDirectives:Lo,withKeys:Do,withMemo:Eo,withModifiers:Ho,withScopeId:Fo}=T,{Form:Bo,Head:y,Link:Vo,router:No,toggleArchitecturalBackground:zo,useArchitecturalBackground:Uo,useForm:Go,usePoll:Oo}=__STATAMIC__.inertia,{Alert:jo,AuthCard:qo,Avatar:$o,Badge:k,Button:Ko,ButtonGroup:Wo,Calendar:Jo,Card:S,CardList:Qo,CardListItem:Xo,CardPanel:Yo,CharacterCounter:Zo,Checkbox:en,CheckboxGroup:tn,CodeEditor:on,Combobox:nn,CommandPaletteItem:an,ConfirmationModal:rn,Context:sn,ContextFooter:ln,ContextHeader:dn,ContextItem:un,ContextLabel:cn,ContextMenu:pn,ContextSeparator:mn,CreateForm:gn,DatePicker:Cn,DateRangePicker:fn,Description:Sn,DocsCallout:xn,DragHandle:hn,Dropdown:Pn,DropdownItem:Tn,DropdownLabel:bn,DropdownMenu:vn,DropdownSeparator:yn,DropdownFooter:kn,DropdownHeader:wn,Editable:Rn,ErrorMessage:Mn,EmptyStateItem:In,EmptyStateMenu:An,Field:_n,Header:w,Heading:Ln,HoverCard:Dn,Icon:En,Input:Hn,InputGroup:Fn,InputGroupAppend:Bn,InputGroupPrepend:Vn,Label:Nn,Listing:zn,ListingCustomizeColumns:Un,ListingFilters:Gn,ListingHeaderCell:On,ListingPagination:jn,ListingPresets:qn,ListingPresetTrigger:$n,ListingRowActions:Kn,ListingSearch:Wn,ListingTable:Jn,ListingTableBody:Qn,ListingTableHead:Xn,ListingToggleAll:Yn,LivePreview:Zn,LivePreviewPopout:ea,MiddleEllipsis:ta,Modal:oa,ModalClose:na,ModalTitle:aa,Pagination:ra,Panel:ia,PanelFooter:sa,PanelHeader:la,Popover:da,PublishComponents:ua,PublishContainer:ca,publishContextKey:pa,injectPublishContext:ma,PublishField:ga,PublishFields:Ca,PublishFieldsProvider:fa,PublishForm:Sa,PublishLocalizations:xa,PublishSections:ha,PublishTabs:Pa,Radio:Ta,RadioGroup:ba,Select:va,Separator:ya,Slider:ka,Skeleton:wa,SplitterGroup:Ra,SplitterPanel:Ma,SplitterResizeHandle:Ia,StatusIndicator:Aa,Subheading:_a,Switch:La,TabContent:Da,Stack:Ea,StackClose:Ha,StackHeader:Fa,StackFooter:Ba,StackContent:Va,Table:R,TableCell:u,TableColumn:c,TableColumns:M,TableRow:I,TableRows:A,TabList:Na,TabProvider:za,Tabs:Ua,TabTrigger:Ga,Text:Oa,Textarea:ja,TimePicker:qa,TimezoneHoverCard:$a,Timezones:Ka,ToggleGroup:Wa,ToggleItem:Ja,Widget:Qa,registerIconSet:Xa,registerIconSetFromStrings:Ya}=__STATAMIC__.ui,_=["textContent"],L=["textContent"],D=["textContent"],h={__name:"PackageSection",props:{title:{type:String,required:!0},packages:{type:Array,required:!0}},setup(i){const x=s=>s==="statamic-addon"?"Statamic addon":s,g=s=>({up_to_date:"success",patch:"info",minor:"warning",major:"danger",unknown:"default"})[s]??"default",P=s=>({up_to_date:"Up to date",patch:"Patch update",minor:"Minor update",major:"Major update",unknown:"Unknown"})[s]??"Unknown";return(s,o)=>(r(),p("section",null,[m("h2",{class:"text-lg font-semibold mb-3",textContent:l(i.title)},null,8,_),i.packages.length===0?(r(),C(e(S),{key:0},{default:n(()=>[...o[0]||(o[0]=[m("p",{class:"text-sm text-gray-600"},"No packages found in this section.",-1)])]),_:1})):(r(),C(e(S),{key:1,class:"overflow-x-auto"},{default:n(()=>[t(e(R),null,{default:n(()=>[t(e(M),null,{default:n(()=>[t(e(c),null,{default:n(()=>[...o[1]||(o[1]=[d("Package",-1)])]),_:1}),t(e(c),null,{default:n(()=>[...o[2]||(o[2]=[d("Addon",-1)])]),_:1}),t(e(c),null,{default:n(()=>[...o[3]||(o[3]=[d("Type",-1)])]),_:1}),t(e(c),null,{default:n(()=>[...o[4]||(o[4]=[d("Installed",-1)])]),_:1}),t(e(c),null,{default:n(()=>[...o[5]||(o[5]=[d("Latest",-1)])]),_:1}),t(e(c),null,{default:n(()=>[...o[6]||(o[6]=[d("Update",-1)])]),_:1})]),_:1}),t(e(A),null,{default:n(()=>[(r(!0),p(b,null,v(i.packages,a=>(r(),C(e(I),{key:a.name},{default:n(()=>[t(e(u),null,{default:n(()=>[m("div",{class:"font-medium",textContent:l(a.name)},null,8,L),a.description?(r(),p("p",{key:0,class:"text-xs text-gray-500 mt-0.5",textContent:l(a.description)},null,8,D)):f("",!0)]),_:2},1024),t(e(u),{textContent:l(a.addonName??"—")},null,8,["textContent"]),t(e(u),{textContent:l(x(a.type))},null,8,["textContent"]),t(e(u),{textContent:l(a.installedVersion)},null,8,["textContent"]),t(e(u),{textContent:l(a.latestVersion??"—")},null,8,["textContent"]),t(e(u),null,{default:n(()=>[t(e(k),{variant:g(a.updateStatus),text:P(a.updateStatus)},null,8,["variant","text"])]),_:2},1024)]),_:2},1024))),128))]),_:1})]),_:1})]),_:1}))]))}},E={class:"max-w-7xl mx-auto","data-max-width-wrapper":""},H={key:0,class:"mb-6"},F={class:"space-y-8"},B={__name:"Index",props:{productionPackages:{type:Array,required:!0},devPackages:{type:Array,required:!0},packagistAvailable:{type:Boolean,required:!0},icon:{type:String,default:null}},setup(i){return(x,g)=>(r(),p("div",E,[t(e(y),{title:"JustBetter Packages"}),t(e(w),{title:"JustBetter Packages",icon:"addons"}),i.packagistAvailable?f("",!0):(r(),p("div",H,[t(e(S),null,{default:n(()=>[...g[0]||(g[0]=[m("p",{class:"text-sm"}," Packagist could not be reached. Installed versions are shown, but update information is unavailable. ",-1)])]),_:1})])),m("div",F,[t(h,{title:"Production packages",packages:i.productionPackages},null,8,["packages"]),i.devPackages.length>0?(r(),C(h,{key:0,title:"Development packages",packages:i.devPackages},null,8,["packages"])):f("",!0)])]))}};Statamic.booting(()=>{Statamic.$inertia.register("statamic-base::Packages/Index",B)}); diff --git a/resources/dist/build/assets/justbetter-statamic-base-Clf4ilY4.css b/resources/dist/build/assets/justbetter-statamic-base-Clf4ilY4.css new file mode 100644 index 0000000..b803a13 --- /dev/null +++ b/resources/dist/build/assets/justbetter-statamic-base-Clf4ilY4.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer addon-theme{:root,:host{--color-gray-300:var(--theme-color-gray-300);--color-gray-700:var(--theme-color-gray-700)}}@layer addon-utilities;:root{--animation-timing-function-fast-out-slow-in:cubic-bezier(.4,0,.2,1);--color-button-active:hsl(from var(--color-gray-300) h s l / .45)}.dark{--color-button-active:hsl(from var(--color-gray-700) h s l / .45)} diff --git a/resources/dist/build/manifest.json b/resources/dist/build/manifest.json new file mode 100644 index 0000000..32eba0a --- /dev/null +++ b/resources/dist/build/manifest.json @@ -0,0 +1,13 @@ +{ + "resources/css/justbetter-statamic-base.css": { + "file": "assets/justbetter-statamic-base-Clf4ilY4.css", + "src": "resources/css/justbetter-statamic-base.css", + "isEntry": true + }, + "resources/js/justbetter-statamic-base.js": { + "file": "assets/justbetter-statamic-base-CZtGSfRx.js", + "name": "justbetter-statamic-base", + "src": "resources/js/justbetter-statamic-base.js", + "isEntry": true + } +} \ No newline at end of file diff --git a/resources/js/justbetter-statamic-base.js b/resources/js/justbetter-statamic-base.js new file mode 100644 index 0000000..4ae3bbd --- /dev/null +++ b/resources/js/justbetter-statamic-base.js @@ -0,0 +1,5 @@ +import PackagesIndex from './pages/Packages/Index.vue'; + +Statamic.booting(() => { + Statamic.$inertia.register('statamic-base::Packages/Index', PackagesIndex); +}); diff --git a/resources/js/pages/Packages/Index.vue b/resources/js/pages/Packages/Index.vue new file mode 100644 index 0000000..66c5451 --- /dev/null +++ b/resources/js/pages/Packages/Index.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/js/pages/Packages/PackageSection.vue b/resources/js/pages/Packages/PackageSection.vue new file mode 100644 index 0000000..1439fb4 --- /dev/null +++ b/resources/js/pages/Packages/PackageSection.vue @@ -0,0 +1,83 @@ + + + diff --git a/routes/cp.php b/routes/cp.php new file mode 100644 index 0000000..35356e9 --- /dev/null +++ b/routes/cp.php @@ -0,0 +1,10 @@ +middleware('justbetter.packages') + ->group(function () { + Route::get('/packages', [PackagesController::class, 'index'])->name('justbetter.packages.index'); + }); diff --git a/src/Client/PackagistClient.php b/src/Client/PackagistClient.php new file mode 100644 index 0000000..1a82fe5 --- /dev/null +++ b/src/Client/PackagistClient.php @@ -0,0 +1,85 @@ +integer('justbetter.statamic-base.packagist_cache_ttl', 3600); + + /** @var string|null $version */ + $version = Cache::get($cacheKey); + + if (is_string($version)) { + return $version; + } + + $version = $this->fetchLatestStableVersion($packageName); + + if ($version !== null) { + Cache::put($cacheKey, $version, $ttl); + } + + return $version; + } + + public function isAvailable(): bool + { + return rescue( + static function (): bool { + Http::timeout(5) + ->get('https://packagist.org/packages/justbetter/statamic-base.json') + ->throw(); + + return true; + }, + false, + ); + } + + protected function fetchLatestStableVersion(string $packageName): ?string + { + return rescue(function () use ($packageName): ?string { + $response = Http::timeout(5) + ->get("https://packagist.org/packages/{$packageName}.json") + ->throw(); + + /** @var array|null $versions */ + $versions = $response->json('package.versions'); + + if (! is_array($versions)) { + return null; + } + + $stableVersions = collect(array_keys($versions)) + ->filter(fn (string $version): bool => $this->isStable($version)) + ->map(fn (string $version): string => ltrim($version, 'v')) + ->values() + ->all(); + + if ($stableVersions === []) { + return null; + } + + $sorted = Semver::rsort($stableVersions); + + return $sorted[0] ?? null; + }, null); + } + + protected function isStable(string $version): bool + { + $normalized = strtolower(ltrim($version, 'v')); + + return ! str_contains($normalized, 'dev') + && ! str_contains($normalized, 'alpha') + && ! str_contains($normalized, 'beta') + && ! str_contains($normalized, 'rc'); + } +} diff --git a/src/Data/DiscoveredPackage.php b/src/Data/DiscoveredPackage.php new file mode 100644 index 0000000..edcbae9 --- /dev/null +++ b/src/Data/DiscoveredPackage.php @@ -0,0 +1,17 @@ + + * + * @property-read string $name + * @property-read string $version + * @property-read string|null $description + * @property-read string $type + * @property-read string|null $addonName + * @property-read bool $isDev + */ +final class DiscoveredPackage extends Fluent {} diff --git a/src/Data/PackageOverview.php b/src/Data/PackageOverview.php new file mode 100644 index 0000000..5d491e5 --- /dev/null +++ b/src/Data/PackageOverview.php @@ -0,0 +1,36 @@ + + * + * @property-read string $name + * @property-read string|null $description + * @property-read string $installedVersion + * @property-read string|null $latestVersion + * @property-read string $updateStatus + * @property-read string|null $addonName + * @property-read string $type + */ +final class PackageOverview extends Fluent +{ + public static function fromDiscovered( + DiscoveredPackage $package, + ?string $latestVersion, + UpdateStatus $updateStatus, + ): self { + return new self([ + 'name' => $package->name, + 'description' => $package->description, + 'installedVersion' => $package->version, + 'latestVersion' => $latestVersion, + 'updateStatus' => $updateStatus->value, + 'addonName' => $package->addonName, + 'type' => $package->type, + ]); + } +} diff --git a/src/Data/PackagesIndexData.php b/src/Data/PackagesIndexData.php new file mode 100644 index 0000000..c3ffa65 --- /dev/null +++ b/src/Data/PackagesIndexData.php @@ -0,0 +1,54 @@ + + * + * @property-read list $productionPackages + * @property-read list $devPackages + * @property-read bool $packagistAvailable + */ +final class PackagesIndexData extends Fluent +{ + /** + * @return array + */ + public function toArray(): array + { + $rawProduction = $this->get('productionPackages', []); + $rawDev = $this->get('devPackages', []); + + $production = []; + if (is_array($rawProduction)) { + foreach ($rawProduction as $package) { + if ($package instanceof PackageOverview) { + $production[] = $package; + } + } + } + + $dev = []; + if (is_array($rawDev)) { + foreach ($rawDev as $package) { + if ($package instanceof PackageOverview) { + $dev[] = $package; + } + } + } + + return [ + 'productionPackages' => array_map( + static fn (PackageOverview $package): array => $package->toArray(), + $production, + ), + 'devPackages' => array_map( + static fn (PackageOverview $package): array => $package->toArray(), + $dev, + ), + 'packagistAvailable' => (bool) $this->get('packagistAvailable'), + ]; + } +} diff --git a/src/Enums/UpdateStatus.php b/src/Enums/UpdateStatus.php new file mode 100644 index 0000000..4c03931 --- /dev/null +++ b/src/Enums/UpdateStatus.php @@ -0,0 +1,12 @@ +overviewBuilder->build()->toArray(), + 'icon' => $this->iconFetcher->fetch(), + ]); + } +} diff --git a/src/Http/Middleware/AuthorizePackages.php b/src/Http/Middleware/AuthorizePackages.php new file mode 100644 index 0000000..4a34b8f --- /dev/null +++ b/src/Http/Middleware/AuthorizePackages.php @@ -0,0 +1,24 @@ +string('justbetter.statamic-base.permissions.view'); + + abort_unless( + $user && ($user->isSuper() || $user->hasPermission($permission)), + 403 + ); + + return $next($request); + } +} diff --git a/src/Navigation/JustBetterNav.php b/src/Navigation/JustBetterNav.php new file mode 100644 index 0000000..6e2dc1d --- /dev/null +++ b/src/Navigation/JustBetterNav.php @@ -0,0 +1,28 @@ +item('Packages'); + $packages->route('justbetter.packages.index'); + $packages->can($permission); + + $justBetter = $nav->create('JustBetter'); + $justBetter->section('Tools'); + $justBetter->route('justbetter.packages.index'); + $justBetter->icon($icon); + $justBetter->can($permission); + $justBetter->children([$packages]); + } + + public static function icon(): string + { + return app(IconFetcher::class)->fetch(); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..9f67820 --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,101 @@ + __DIR__.'/../routes/cp.php', + ]; + + /** @phpstan-ignore-next-line */ + protected $vite = [ + 'input' => [ + 'resources/js/justbetter-statamic-base.js', + 'resources/css/justbetter-statamic-base.css', + ], + 'publicDirectory' => 'resources/dist', + ]; + + public function register(): void + { + parent::register(); + + $this->mergeConfigFrom(__DIR__.'/../config/statamic-base.php', 'justbetter.statamic-base'); + + $this->app->singleton(InstalledPackageDiscovery::class); + $this->app->singleton(PackagistClient::class); + $this->app->singleton(VersionComparator::class); + $this->app->singleton(IconFetcher::class); + $this->app->singleton(PackageOverviewBuilder::class); + + $this->app->booted(function () { + $router = app(Router::class); + $router->aliasMiddleware('justbetter.packages', AuthorizePackages::class); + }); + } + + public function bootAddon(): void + { + $this->bootConfig() + ->bootPermissions() + ->bootNavigation(); + } + + protected function bootConfig(): static + { + $this->publishes([ + __DIR__.'/../config/statamic-base.php' => config_path('justbetter/statamic-base.php'), + ], 'justbetter-statamic-base'); + + return $this; + } + + protected function bootPermissions(): static + { + PermissionFacade::extend(function () { + PermissionFacade::group('justbetter', 'JustBetter', function () { + $permission = config()->string('justbetter.statamic-base.permissions.view'); + + PermissionFacade::register($permission, function (Permission $permission) { + $permission + ->label('View JustBetter packages') + ->description('Gives the user access to the JustBetter packages overview.'); + }); + }); + }); + + return $this; + } + + protected function bootNavigation(): static + { + Nav::extend(function ($nav) { + if (! $nav instanceof Navigation) { + return; + } + + (new JustBetterNav)->register( + $nav, + JustBetterNav::icon(), + config()->string('justbetter.statamic-base.permissions.view'), + ); + }); + + return $this; + } +} diff --git a/src/Services/IconFetcher.php b/src/Services/IconFetcher.php new file mode 100644 index 0000000..b168e7a --- /dev/null +++ b/src/Services/IconFetcher.php @@ -0,0 +1,49 @@ +'; + + public function fetch(): string + { + $url = trim(config()->string('justbetter.statamic-base.icon_url')); + + if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) { + return self::FALLBACK_ICON; + } + + $darkRaw = config('justbetter.statamic-base.icon_dark_url'); + $darkUrl = is_string($darkRaw) ? trim($darkRaw) : ''; + + if ($darkUrl !== '' && filter_var($darkUrl, FILTER_VALIDATE_URL) !== false) { + return $this->svgImagesForLightAndDark($url, $darkUrl); + } + + return $this->svgImageFromUrl($url); + } + + protected function svgImagesForLightAndDark(string $lightUrl, string $darkUrl): string + { + $light = htmlspecialchars($lightUrl, ENT_QUOTES | ENT_XML1, 'UTF-8'); + $dark = htmlspecialchars($darkUrl, ENT_QUOTES | ENT_XML1, 'UTF-8'); + + return ''; + } + + protected function svgImageFromUrl(string $url): string + { + $href = htmlspecialchars($url, ENT_QUOTES | ENT_XML1, 'UTF-8'); + + return ''; + } + + protected function imageElement(string $escapedHref): string + { + return ''; + } +} diff --git a/src/Services/InstalledPackageDiscovery.php b/src/Services/InstalledPackageDiscovery.php new file mode 100644 index 0000000..1c0534b --- /dev/null +++ b/src/Services/InstalledPackageDiscovery.php @@ -0,0 +1,136 @@ + + */ + public function discover(): Collection + { + $lock = $this->readLockFile(); + + $production = collect($lock['packages'] ?? []) + ->filter(fn (array $package): bool => $this->shouldInclude($package)) + ->map(fn (array $package): DiscoveredPackage => $this->mapPackage($package, isDev: false)); + + $development = collect($lock['packages-dev'] ?? []) + ->filter(fn (array $package): bool => $this->shouldInclude($package)) + ->map(fn (array $package): DiscoveredPackage => $this->mapPackage($package, isDev: true)); + + return $production + ->merge($development) + ->sortBy(fn (DiscoveredPackage $package): string => $package->name) + ->values(); + } + + /** + * @return array{packages?: list>, packages-dev?: list>} + */ + protected function readLockFile(): array + { + $path = $this->lockFilePath ?? base_path('composer.lock'); + + if (! File::exists($path)) { + throw new RuntimeException("Composer lock file not found at [{$path}]."); + } + + /** @var array{packages?: list>, packages-dev?: list>}|null $lock */ + $lock = json_decode(File::get($path), true); + + if (! is_array($lock)) { + throw new RuntimeException("Composer lock file at [{$path}] is invalid."); + } + + return $lock; + } + + /** + * @param array $package + */ + protected function shouldInclude(array $package): bool + { + if (! is_string($package['name'] ?? null)) { + throw new RuntimeException('Invalid package entry in composer.lock.'); + } + + return Vendors::matches($package['name']); + } + + /** + * @param array $package + */ + protected function mapPackage(array $package, bool $isDev): DiscoveredPackage + { + if (! is_string($package['name'] ?? null) || ! is_string($package['version'] ?? null)) { + throw new RuntimeException('Invalid package entry in composer.lock.'); + } + + $name = $package['name']; + $composer = $this->readPackageComposer($name); + $description = $package['description'] ?? $composer['description'] ?? null; + + return new DiscoveredPackage([ + 'name' => $name, + 'version' => $this->normalizeVersion($package['version']), + 'description' => is_string($description) ? $description : null, + 'type' => $this->resolveType($composer), + 'addonName' => ($addonName = Arr::get($composer, 'extra.statamic.name')) && is_string($addonName) ? $addonName : null, + 'isDev' => $isDev, + ]); + } + + /** + * @return array + */ + protected function readPackageComposer(string $packageName): array + { + $path = $this->vendorDirectory().'/'.$packageName.'/composer.json'; + + if (! File::exists($path)) { + return []; + } + + /** @var array|null $composer */ + $composer = json_decode(File::get($path), true); + + return is_array($composer) ? $composer : []; + } + + protected function vendorDirectory(): string + { + return $this->vendorPath ?? base_path('vendor'); + } + + protected function normalizeVersion(string $version): string + { + return ltrim($version, 'v'); + } + + /** + * @param array $composer + */ + protected function resolveType(array $composer): string + { + if (($composer['type'] ?? null) === 'statamic-addon' || is_array($composer['extra'] ?? null) && isset($composer['extra']['statamic'])) { + return 'statamic-addon'; + } + + $type = $composer['type'] ?? 'library'; + + return is_string($type) ? $type : 'library'; + } +} diff --git a/src/Services/PackageOverviewBuilder.php b/src/Services/PackageOverviewBuilder.php new file mode 100644 index 0000000..a0af9d0 --- /dev/null +++ b/src/Services/PackageOverviewBuilder.php @@ -0,0 +1,50 @@ +discovery->discover(); + $packagistAvailable = $this->packagist->isAvailable(); + + $production = []; + $dev = []; + + foreach ($packages as $package) { + $overview = $this->buildOverview($package); + + if ($package->isDev) { + $dev[] = $overview; + } else { + $production[] = $overview; + } + } + + return new PackagesIndexData([ + 'productionPackages' => $production, + 'devPackages' => $dev, + 'packagistAvailable' => $packagistAvailable, + ]); + } + + protected function buildOverview(DiscoveredPackage $package): PackageOverview + { + $latest = $this->packagist->latestStableVersion($package->name); + $status = $this->versionComparator->compare($package->version, $latest); + + return PackageOverview::fromDiscovered($package, $latest, $status); + } +} diff --git a/src/Services/VersionComparator.php b/src/Services/VersionComparator.php new file mode 100644 index 0000000..f711d68 --- /dev/null +++ b/src/Services/VersionComparator.php @@ -0,0 +1,55 @@ +=')) { + return UpdateStatus::UpToDate; + } + + $installedParts = $this->majorMinorPatch($installed); + $latestParts = $this->majorMinorPatch($latest); + + if ($latestParts[0] > $installedParts[0]) { + return UpdateStatus::Major; + } + + if ($latestParts[1] > $installedParts[1]) { + return UpdateStatus::Minor; + } + + return UpdateStatus::Patch; + } + + /** + * @return array{0: int, 1: int, 2: int} + */ + protected function majorMinorPatch(string $version): array + { + $normalized = $this->versionParser->normalize($version); + $parts = explode('.', $normalized); + + return [ + (int) $parts[0], + (int) $parts[1], + (int) $parts[2], + ]; + } +} diff --git a/src/Support/Vendors.php b/src/Support/Vendors.php new file mode 100644 index 0000000..1ed3109 --- /dev/null +++ b/src/Support/Vendors.php @@ -0,0 +1,23 @@ + */ + public const NAMES = [ + 'justbetter', + 'just-better', + ]; + + public static function matches(string $packageName): bool + { + foreach (self::NAMES as $vendor) { + if (str_starts_with($packageName, $vendor.'/')) { + return true; + } + } + + return false; + } +} diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..59fa025 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,2 @@ +providers: + - JustBetter\StatamicBase\ServiceProvider diff --git a/tests/Client/PackagistClientTest.php b/tests/Client/PackagistClientTest.php new file mode 100644 index 0000000..44dbf83 --- /dev/null +++ b/tests/Client/PackagistClientTest.php @@ -0,0 +1,139 @@ +client = new PackagistClient; + } + + #[Test] + public function it_returns_the_latest_stable_version(): void + { + Http::fake(fn () => Http::response([ + 'package' => [ + 'versions' => [ + 'dev-main' => [], + '1.0.0' => [], + '1.2.0' => [], + 'v1.1.0' => [], + '2.0.0-beta1' => [], + ], + ], + ])); + + $this->assertSame('1.2.0', $this->client->latestStableVersion('justbetter/statamic-base')); + } + + #[Test] + public function it_returns_cached_versions(): void + { + Cache::put('statamic-base.packagist.justbetter.statamic-base', '9.9.9', 3600); + + Http::fake(); + + $this->assertSame('9.9.9', $this->client->latestStableVersion('justbetter/statamic-base')); + + Http::assertNothingSent(); + } + + #[Test] + public function it_returns_null_when_versions_are_missing(): void + { + Http::fake(fn () => Http::response(['package' => []])); + + $this->assertNull($this->client->latestStableVersion('justbetter/statamic-base')); + } + + #[Test] + public function it_returns_null_when_only_unstable_versions_exist(): void + { + Http::fake(fn () => Http::response([ + 'package' => [ + 'versions' => [ + 'dev-main' => [], + '2.0.0-beta1' => [], + ], + ], + ])); + + $this->assertNull($this->client->latestStableVersion('justbetter/statamic-base')); + } + + #[Test] + public function it_returns_null_when_packagist_throws(): void + { + Http::fake(fn () => throw new ConnectionException('Connection failed')); + + $this->assertNull($this->client->latestStableVersion('justbetter/statamic-base')); + } + + #[Test] + public function it_caches_successful_responses(): void + { + Http::fake(fn () => Http::response([ + 'package' => [ + 'versions' => [ + '1.0.0' => [], + ], + ], + ])); + + $this->client->latestStableVersion('justbetter/statamic-base'); + $this->client->latestStableVersion('justbetter/statamic-base'); + + Http::assertSentCount(1); + } + + #[Test] + public function it_returns_null_when_packagist_fails(): void + { + Http::fake([ + '*' => Http::response([], 500), + ]); + + $this->assertNull($this->client->latestStableVersion('justbetter/statamic-base')); + } + + #[Test] + public function it_can_detect_packagist_availability(): void + { + Http::fake(fn () => Http::response(['package' => ['versions' => ['1.0.0' => []]]])); + + $this->assertTrue($this->client->isAvailable()); + } + + #[Test] + public function it_detects_when_packagist_is_unavailable(): void + { + Http::fake([ + '*' => Http::response([], 500), + ]); + + $this->assertFalse($this->client->isAvailable()); + } + + #[Test] + public function it_detects_when_packagist_throws(): void + { + Http::fake(fn () => throw new ConnectionException('Connection failed')); + + $this->assertFalse($this->client->isAvailable()); + } +} diff --git a/tests/Data/PackageOverviewTest.php b/tests/Data/PackageOverviewTest.php new file mode 100644 index 0000000..f68347e --- /dev/null +++ b/tests/Data/PackageOverviewTest.php @@ -0,0 +1,52 @@ + 'justbetter/statamic-base', + 'version' => '1.0.0', + 'description' => 'Foundation addon', + 'type' => 'statamic-addon', + 'addonName' => 'Statamic Base', + 'isDev' => false, + ]), + latestVersion: '1.1.0', + updateStatus: UpdateStatus::Minor, + ); + + $this->assertSame([ + 'name' => 'justbetter/statamic-base', + 'description' => 'Foundation addon', + 'installedVersion' => '1.0.0', + 'latestVersion' => '1.1.0', + 'updateStatus' => 'minor', + 'addonName' => 'Statamic Base', + 'type' => 'statamic-addon', + ], $overview->toArray()); + + $index = new PackagesIndexData([ + 'productionPackages' => [$overview], + 'devPackages' => [], + 'packagistAvailable' => true, + ]); + + $this->assertSame([ + 'productionPackages' => [$overview->toArray()], + 'devPackages' => [], + 'packagistAvailable' => true, + ], $index->toArray()); + } +} diff --git a/tests/Http/Controllers/PackagesControllerTest.php b/tests/Http/Controllers/PackagesControllerTest.php new file mode 100644 index 0000000..93ab9e1 --- /dev/null +++ b/tests/Http/Controllers/PackagesControllerTest.php @@ -0,0 +1,92 @@ +fixturePath('composer.lock'), + vendorPath: $this->fixturePath('vendor'), + ); + + $app = $this->app; + $this->assertNotNull($app); + + $app->instance(InstalledPackageDiscovery::class, $discovery); + $app->instance(PackageOverviewBuilder::class, new PackageOverviewBuilder( + $discovery, + new PackagistClient, + new VersionComparator, + )); + + Http::preventStrayRequests(); + + Http::fake(function (Request $request) { + return Http::response([ + 'package' => [ + 'versions' => [ + '1.0.0' => [], + '1.1.0' => [], + '2.2.0' => [], + '0.6.0' => [], + ], + ], + ]); + }); + } + + #[Test] + public function it_renders_the_packages_overview(): void + { + /** @var \Statamic\Auth\File\User $user */ + $user = User::make(); + $user->id('super')->email('super@example.com')->makeSuper(); + + $this->actingAs($user); + + $response = $this->get(cp_route('justbetter.packages.index')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('statamic-base::Packages/Index') + ->has('productionPackages', 2) + ->has('devPackages', 1) + ->where('packagistAvailable', true) + ->has('icon')); + } + + #[Test] + public function it_requires_permission(): void + { + $role = Role::make('cp-only')->permissions(['access cp']); + $role->save(); + + /** @var \Statamic\Auth\File\User $user */ + $user = User::make(); + $user + ->id('guest') + ->email('guest@example.com') + ->assignRole($role) + ->save(); + + $this->actingAs($user); + + $this->get(cp_route('justbetter.packages.index'))->assertForbidden(); + } +} diff --git a/tests/Http/Middleware/AuthorizePackagesTest.php b/tests/Http/Middleware/AuthorizePackagesTest.php new file mode 100644 index 0000000..83e416b --- /dev/null +++ b/tests/Http/Middleware/AuthorizePackagesTest.php @@ -0,0 +1,70 @@ +id('super')->email('super@example.com')->makeSuper(); + + $this->actingAs($user); + + $response = (new AuthorizePackages)->handle(Request::create('/cp/justbetter/packages'), fn () => response('ok')); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[Test] + public function it_allows_users_with_permission(): void + { + $role = Role::make('packages-access')->permissions(['access cp', 'view justbetter packages']); + $role->save(); + + /** @var \Statamic\Auth\File\User $user */ + $user = User::make(); + $user + ->id('editor') + ->email('editor@example.com') + ->assignRole($role) + ->save(); + + $this->actingAs($user); + + $response = (new AuthorizePackages)->handle(Request::create('/cp/justbetter/packages'), fn () => response('ok')); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[Test] + public function it_denies_users_without_permission(): void + { + $role = Role::make('cp-only')->permissions(['access cp']); + $role->save(); + + /** @var \Statamic\Auth\File\User $user */ + $user = User::make(); + $user + ->id('guest') + ->email('guest@example.com') + ->assignRole($role) + ->save(); + + $this->actingAs($user); + + $this->expectException(HttpException::class); + + (new AuthorizePackages)->handle(Request::create('/cp/justbetter/packages'), fn () => response('ok')); + } +} diff --git a/tests/Navigation/JustBetterNavTest.php b/tests/Navigation/JustBetterNavTest.php new file mode 100644 index 0000000..604226e --- /dev/null +++ b/tests/Navigation/JustBetterNavTest.php @@ -0,0 +1,31 @@ +register($nav, JustBetterNav::icon(), 'view justbetter packages'); + + $section = $nav->find('Tools', 'JustBetter'); + + $this->assertNotNull($section); + $children = $section->children(); + $this->assertInstanceOf(Collection::class, $children); + $first = $children->first(); + $this->assertInstanceOf(NavItem::class, $first); + $url = $first->url(); + $this->assertIsString($url); + $this->assertStringContainsString('justbetter/packages', $url); + } +} diff --git a/tests/ServiceProvider/ServiceProviderTest.php b/tests/ServiceProvider/ServiceProviderTest.php new file mode 100644 index 0000000..8acf716 --- /dev/null +++ b/tests/ServiceProvider/ServiceProviderTest.php @@ -0,0 +1,71 @@ +assertSame('justbetter/statamic-base', Addon::get('justbetter/statamic-base')->id()); + } + + #[Test] + public function it_registers_navigation_through_the_nav_extension(): void + { + $navigation = new Navigation; + Nav::swap($navigation); + + $app = $this->app; + $this->assertNotNull($app); + + $bootNavigation = new ReflectionMethod(ServiceProvider::class, 'bootNavigation'); + $bootNavigation->invoke($app->getProvider(ServiceProvider::class)); + + $extensions = new ReflectionProperty(Navigation::class, 'extensions'); + $extensions->setAccessible(true); + + /** @var list $callbacks */ + $callbacks = $extensions->getValue($navigation); + + foreach ($callbacks as $callback) { + $callback($navigation); + } + + $this->assertNotNull($navigation->find('Tools', 'JustBetter')); + } + + #[Test] + public function it_skips_navigation_registration_for_invalid_nav_instances(): void + { + $navigation = new Navigation; + Nav::swap($navigation); + + $app = $this->app; + $this->assertNotNull($app); + + $bootNavigation = new ReflectionMethod(ServiceProvider::class, 'bootNavigation'); + $bootNavigation->invoke($app->getProvider(ServiceProvider::class)); + + $extensions = new ReflectionProperty(Navigation::class, 'extensions'); + $extensions->setAccessible(true); + + /** @var list $callbacks */ + $callbacks = $extensions->getValue($navigation); + + foreach ($callbacks as $callback) { + $callback(new \stdClass); + } + + $this->assertNull($navigation->find('Tools', 'JustBetter')); + } +} diff --git a/tests/Services/IconFetcherTest.php b/tests/Services/IconFetcherTest.php new file mode 100644 index 0000000..1552a41 --- /dev/null +++ b/tests/Services/IconFetcherTest.php @@ -0,0 +1,86 @@ +fetcher = new IconFetcher; + } + + #[Test] + public function it_embeds_the_configured_icon_url_in_an_svg_image(): void + { + Config::set('justbetter.statamic-base.icon_url', 'https://example.com/logo.svg'); + + $markup = $this->fetcher->fetch(); + + $this->assertStringStartsWith('assertStringContainsString('assertStringContainsString('href="https://example.com/logo.svg"', $markup); + $this->assertStringContainsString('xlink:href="https://example.com/logo.svg"', $markup); + } + + #[Test] + public function it_escapes_xml_special_characters_in_the_icon_url(): void + { + Config::set('justbetter.statamic-base.icon_url', 'https://example.com/logo.svg?foo=1&bar=2'); + + $markup = $this->fetcher->fetch(); + + $this->assertStringContainsString('foo=1&bar=2', $markup); + $this->assertStringNotContainsString('&bar=2"', $markup); + } + + #[Test] + public function it_falls_back_when_icon_url_is_empty(): void + { + Config::set('justbetter.statamic-base.icon_url', ''); + + $this->assertSame(IconFetcher::FALLBACK_ICON, $this->fetcher->fetch()); + } + + #[Test] + public function it_falls_back_when_icon_url_is_invalid(): void + { + Config::set('justbetter.statamic-base.icon_url', 'not-a-url'); + + $this->assertSame(IconFetcher::FALLBACK_ICON, $this->fetcher->fetch()); + } + + #[Test] + public function it_outputs_light_and_dark_images_when_icon_dark_url_is_set(): void + { + Config::set('justbetter.statamic-base.icon_url', 'https://example.com/light.svg'); + Config::set('justbetter.statamic-base.icon_dark_url', 'https://example.com/dark.svg'); + + $markup = $this->fetcher->fetch(); + + $this->assertStringContainsString('class="dark:hidden"', $markup); + $this->assertStringContainsString('class="hidden dark:block"', $markup); + $this->assertStringContainsString('href="https://example.com/light.svg"', $markup); + $this->assertStringContainsString('href="https://example.com/dark.svg"', $markup); + } + + #[Test] + public function it_ignores_invalid_icon_dark_url_and_uses_light_only(): void + { + Config::set('justbetter.statamic-base.icon_url', 'https://example.com/light.svg'); + Config::set('justbetter.statamic-base.icon_dark_url', 'not-a-url'); + + $markup = $this->fetcher->fetch(); + + $this->assertStringNotContainsString('dark:hidden', $markup); + $this->assertStringContainsString('href="https://example.com/light.svg"', $markup); + } +} diff --git a/tests/Services/InstalledPackageDiscoveryTest.php b/tests/Services/InstalledPackageDiscoveryTest.php new file mode 100644 index 0000000..ff954a7 --- /dev/null +++ b/tests/Services/InstalledPackageDiscoveryTest.php @@ -0,0 +1,154 @@ +discovery = new InstalledPackageDiscovery( + lockFilePath: $this->fixturePath('composer.lock'), + vendorPath: $this->fixturePath('vendor'), + ); + } + + #[Test] + public function it_discovers_production_and_dev_packages(): void + { + $packages = $this->discovery->discover(); + + $this->assertCount(3, $packages); + + $base = $packages->firstWhere('name', 'justbetter/statamic-base'); + $this->assertNotNull($base); + $this->assertSame('1.0.0', $base->version); + $this->assertSame('statamic-addon', $base->type); + $this->assertSame('Statamic Base', $base->addonName); + $this->assertFalse($base->isDev); + + $dev = $packages->firstWhere('name', 'just-better/statamic-dev-tools'); + $this->assertNotNull($dev); + $this->assertTrue($dev->isDev); + $this->assertSame('Dev Tools', $dev->addonName); + } + + #[Test] + public function it_throws_when_lock_file_is_missing(): void + { + $discovery = new InstalledPackageDiscovery( + lockFilePath: $this->fixturePath('missing.lock'), + vendorPath: $this->fixturePath('vendor'), + ); + + $this->expectException(RuntimeException::class); + + $discovery->discover(); + } + + #[Test] + public function it_throws_when_a_package_entry_is_invalid(): void + { + $path = $this->fixturePath('invalid-package.lock'); + file_put_contents($path, json_encode([ + 'packages' => [ + ['name' => 123, 'version' => '1.0.0'], + ], + ])); + + try { + $discovery = new InstalledPackageDiscovery( + lockFilePath: $path, + vendorPath: $this->fixturePath('vendor'), + ); + + $this->expectException(RuntimeException::class); + + $discovery->discover(); + } finally { + unlink($path); + } + } + + #[Test] + public function it_throws_when_a_package_version_is_invalid(): void + { + $path = $this->fixturePath('invalid-version.lock'); + file_put_contents($path, json_encode([ + 'packages' => [ + ['name' => 'justbetter/statamic-base', 'version' => 123], + ], + ])); + + try { + $discovery = new InstalledPackageDiscovery( + lockFilePath: $path, + vendorPath: $this->fixturePath('vendor'), + ); + + $this->expectException(RuntimeException::class); + + $discovery->discover(); + } finally { + unlink($path); + } + } + + #[Test] + public function it_handles_packages_without_vendor_composer_files(): void + { + $path = $this->fixturePath('missing-vendor.lock'); + file_put_contents($path, json_encode([ + 'packages' => [ + [ + 'name' => 'justbetter/statamic-missing', + 'version' => 'v3.0.0', + 'type' => 'library', + ], + ], + ])); + + try { + $discovery = new InstalledPackageDiscovery( + lockFilePath: $path, + vendorPath: $this->fixturePath('vendor'), + ); + + $package = $discovery->discover()->firstWhere('name', 'justbetter/statamic-missing'); + + $this->assertNotNull($package); + $this->assertSame('library', $package->type); + $this->assertNull($package->addonName); + } finally { + unlink($path); + } + } + + #[Test] + public function it_throws_when_lock_file_is_invalid(): void + { + $path = $this->fixturePath('invalid.lock'); + file_put_contents($path, 'not-json'); + + try { + $discovery = new InstalledPackageDiscovery( + lockFilePath: $path, + vendorPath: $this->fixturePath('vendor'), + ); + + $this->expectException(RuntimeException::class); + + $discovery->discover(); + } finally { + unlink($path); + } + } +} diff --git a/tests/Services/PackageOverviewBuilderTest.php b/tests/Services/PackageOverviewBuilderTest.php new file mode 100644 index 0000000..4f36d81 --- /dev/null +++ b/tests/Services/PackageOverviewBuilderTest.php @@ -0,0 +1,76 @@ +url(), 'statamic-base.json') => ['1.0.0' => [], '1.1.0' => []], + str_contains($request->url(), 'statamic-glide.json') => ['2.1.0' => [], '2.2.0' => []], + str_contains($request->url(), 'statamic-dev-tools.json') => ['0.5.0' => [], '0.6.0' => []], + default => ['1.0.0' => []], + }; + + return Http::response(['package' => ['versions' => $versions]]); + }); + + $data = $this->builder()->build(); + + $this->assertTrue($data->packagistAvailable); + $this->assertCount(2, $data->productionPackages); + $this->assertCount(1, $data->devPackages); + + $base = collect($data->productionPackages)->firstWhere('name', 'justbetter/statamic-base'); + $this->assertNotNull($base); + $this->assertSame(UpdateStatus::Minor->value, $base->updateStatus); + + $dev = $data->devPackages[0]; + $this->assertSame('just-better/statamic-dev-tools', $dev->name); + $this->assertSame(UpdateStatus::Minor->value, $dev->updateStatus); + } + + #[Test] + public function it_marks_packagist_as_unavailable_when_unreachable(): void + { + Http::preventStrayRequests(); + + Http::fake([ + '*' => Http::response([], 500), + ]); + + $data = $this->builder()->build(); + + $this->assertFalse($data->packagistAvailable); + $this->assertSame(UpdateStatus::Unknown->value, $data->productionPackages[0]->updateStatus); + } + + protected function builder(): PackageOverviewBuilder + { + $discovery = new InstalledPackageDiscovery( + lockFilePath: $this->fixturePath('composer.lock'), + vendorPath: $this->fixturePath('vendor'), + ); + + return new PackageOverviewBuilder( + $discovery, + new PackagistClient, + new VersionComparator, + ); + } +} diff --git a/tests/Services/VersionComparatorTest.php b/tests/Services/VersionComparatorTest.php new file mode 100644 index 0000000..bb88a70 --- /dev/null +++ b/tests/Services/VersionComparatorTest.php @@ -0,0 +1,42 @@ +comparator = new VersionComparator; + } + + #[Test] + public function it_returns_unknown_when_latest_is_missing(): void + { + $this->assertSame(UpdateStatus::Unknown, $this->comparator->compare('1.0.0', null)); + } + + #[Test] + public function it_returns_up_to_date_when_installed_is_current(): void + { + $this->assertSame(UpdateStatus::UpToDate, $this->comparator->compare('1.2.3', '1.2.3')); + $this->assertSame(UpdateStatus::UpToDate, $this->comparator->compare('v1.2.3', '1.2.3')); + $this->assertSame(UpdateStatus::UpToDate, $this->comparator->compare('2.0.0', '1.9.9')); + } + + #[Test] + public function it_detects_patch_minor_and_major_updates(): void + { + $this->assertSame(UpdateStatus::Patch, $this->comparator->compare('1.2.3', '1.2.4')); + $this->assertSame(UpdateStatus::Minor, $this->comparator->compare('1.2.3', '1.3.0')); + $this->assertSame(UpdateStatus::Major, $this->comparator->compare('1.2.3', '2.0.0')); + } +} diff --git a/tests/Support/VendorsTest.php b/tests/Support/VendorsTest.php new file mode 100644 index 0000000..4aa0f03 --- /dev/null +++ b/tests/Support/VendorsTest.php @@ -0,0 +1,28 @@ +assertTrue(Vendors::matches('justbetter/statamic-base')); + } + + #[Test] + public function it_matches_just_better_packages(): void + { + $this->assertTrue(Vendors::matches('just-better/statamic-dev-tools')); + } + + #[Test] + public function it_does_not_match_other_packages(): void + { + $this->assertFalse(Vendors::matches('acme/example')); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..bde3ff7 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,29 @@ +set('app.key', 'base64:7tG0yY7g3QkFrQ+Vk4EBSbcT8D9C4/5Dph1dNRjh6WU='); + $app['config']->set('cache.default', 'array'); + $app['config']->set('justbetter.statamic-base.packagist_cache_ttl', 3600); + $app['config']->set('justbetter.statamic-base.icon_url', 'https://opensource.justbetter.nl/statamic/justbetter-logo-small-black.svg'); + $app['config']->set('justbetter.statamic-base.icon_dark_url', null); + $app['config']->set('justbetter.statamic-base.permissions.view', 'view justbetter packages'); + } + + protected function fixturePath(string $path = ''): string + { + return __DIR__.'/__fixtures__'.($path !== '' ? '/'.$path : ''); + } +} diff --git a/tests/__fixtures__/composer.lock b/tests/__fixtures__/composer.lock new file mode 100644 index 0000000..4c6d214 --- /dev/null +++ b/tests/__fixtures__/composer.lock @@ -0,0 +1,27 @@ +{ + "packages": [ + { + "name": "justbetter/statamic-base", + "version": "v1.0.0", + "type": "library", + "description": "Foundation addon" + }, + { + "name": "justbetter/statamic-glide", + "version": "v2.1.0", + "type": "library" + }, + { + "name": "acme/unrelated", + "version": "v1.0.0", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "just-better/statamic-dev-tools", + "version": "v0.5.0", + "type": "library" + } + ] +} diff --git a/tests/__fixtures__/dev-null/.gitkeep b/tests/__fixtures__/dev-null/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/vendor/just-better/statamic-dev-tools/composer.json b/tests/__fixtures__/vendor/just-better/statamic-dev-tools/composer.json new file mode 100644 index 0000000..b06d221 --- /dev/null +++ b/tests/__fixtures__/vendor/just-better/statamic-dev-tools/composer.json @@ -0,0 +1,9 @@ +{ + "name": "just-better/statamic-dev-tools", + "type": "statamic-addon", + "extra": { + "statamic": { + "name": "Dev Tools" + } + } +} diff --git a/tests/__fixtures__/vendor/justbetter/statamic-base/composer.json b/tests/__fixtures__/vendor/justbetter/statamic-base/composer.json new file mode 100644 index 0000000..40c0d50 --- /dev/null +++ b/tests/__fixtures__/vendor/justbetter/statamic-base/composer.json @@ -0,0 +1,10 @@ +{ + "name": "justbetter/statamic-base", + "type": "statamic-addon", + "description": "Foundation addon", + "extra": { + "statamic": { + "name": "Statamic Base" + } + } +} diff --git a/tests/__fixtures__/vendor/justbetter/statamic-glide/composer.json b/tests/__fixtures__/vendor/justbetter/statamic-glide/composer.json new file mode 100644 index 0000000..39aeb32 --- /dev/null +++ b/tests/__fixtures__/vendor/justbetter/statamic-glide/composer.json @@ -0,0 +1,5 @@ +{ + "name": "justbetter/statamic-glide", + "type": "library", + "description": "Glide directive addon" +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..15e67e5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import statamic from '@statamic/cms/vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + statamic(), + laravel({ + input: [ + 'resources/js/justbetter-statamic-base.js', + 'resources/css/justbetter-statamic-base.css', + ], + publicDirectory: 'resources/dist', + }), + tailwindcss(), + ], +});