Compare commits

...

96 Commits

Author SHA1 Message Date
gitea-actions[bot] cedf6808d2 chore(version): pre-release bump to 01.04.15-dev [skip ci] 2026-06-28 19:25:11 +00:00
gitea-actions[bot] 36ce686ae1 chore(version): auto-bump patch 01.04.14-dev [skip ci] 2026-06-28 19:24:54 +00:00
jmiller 5ea422d75e docs(api): require Joomla 6.0+ in OpenAPI metadata
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report: Scripts Governance (pull_request) Blocked by required conditions
Generic: Repo Health / Report: Repository Health (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 12s
Generic: Project CI / Lint & Validate (pull_request) Successful in 48s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 59s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
2026-06-28 14:19:29 -05:00
jmiller e6e525080f Merge origin/main into dev — reconcile diverged trunks for v2.0 release
main carried template/governance syncs not in dev; dev carries the v2.0
feature work. Resolved per project convention:
- ours (dev): composer.json (main's was corrupted to 'mokojoomgallery'),
  all 5 manifests (01.04.13), README.md, CHANGELOG.md, all source
- theirs (main): GOVERNANCE.md, SECURITY.md, and workflow files (template syncs)
- kept dev's deliberate removal of deploy-manual.yml

This makes dev a superset of main so PR #83 (dev -> main) merges cleanly.
2026-06-28 14:15:17 -05:00
gitea-actions[bot] 42ffb4b46c chore(version): pre-release bump to 01.04.13-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 5s
2026-06-28 18:56:12 +00:00
gitea-actions[bot] 2907e64641 chore(version): auto-bump patch 01.04.12-dev [skip ci] 2026-06-28 18:56:01 +00:00
jmiller b77054b769 fix: harden input handling and output safety (#79)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 11s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
- canonical_url: sanitize via sanitizeUrl() (scheme allowlist) instead of
  bare trim() — closes stored-XSS via addHeadLink() on the public frontend
- AI endpoint: replace die('Invalid Token') with a clean event result,
  and strip_tags + truncate article_title to 200 chars before use
- SitemapBuilder: whitelist changefreq against the sitemap spec enum,
  intval() noindex IDs, strict in_array comparison
- MokoOG: log a WARNING when sitemap.xml write fails instead of ignoring it
2026-06-28 13:55:14 -05:00
Jonathan Miller 3fb5a87be9 fix: use mysqli driver in component manifest for Joomla 4/5/6
The install/uninstall/update SQL sections used driver="mysql" which
doesn't match the active mysqli driver, causing "SQL File not found"
on fresh installs and silently skipping schema updates on upgrades.
2026-06-28 13:55:12 -05:00
gitea-actions[bot] 5afbc75f23 chore(version): pre-release bump to 01.04.11-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 28s
2026-06-28 08:07:44 +00:00
gitea-actions[bot] 7ef082a8de chore(version): pre-release bump to 01.04.10-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 5s
2026-06-28 08:07:24 +00:00
gitea-actions[bot] c65ef345ef chore(version): pre-release bump to 01.04.09-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 25s
2026-06-28 08:05:36 +00:00
jmiller 31da0a5980 chore: sync GOVERNANCE.md from Template-Joomla
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Authored-by: Moko Consulting
2026-06-28 07:54:35 +00:00
jmiller 9991bb3099 chore: sync version-set.yml from Template-Joomla
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
Authored-by: Moko Consulting
2026-06-28 07:52:37 +00:00
jmiller aa4e254f0b chore: sync pr-metadata-check.yml from Template-Joomla 2026-06-28 07:47:38 +00:00
jmiller 94201082d2 chore: sync ci-issue-reporter.yml from Template-Joomla
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
Authored-by: Moko Consulting
2026-06-28 07:46:41 +00:00
jmiller 9147790214 chore: sync SECURITY.md from Template-Joomla 2026-06-28 07:46:12 +00:00
jmiller 3e51d8c439 chore: sync GOVERNANCE.md from Template-Joomla 2026-06-28 07:42:40 +00:00
jmiller 750f769a13 chore: sync CONTRIBUTING.md from Template-Joomla 2026-06-28 07:40:55 +00:00
jmiller 981464ee4e chore: sync CODE_OF_CONDUCT.md from Template-Joomla 2026-06-28 07:37:50 +00:00
jmiller 7afcc8e6b9 chore: sync composer.json from Template-Joomla 2026-06-28 07:35:50 +00:00
jmiller e47fdf8722 chore: sync phpstan.neon from Template-Joomla 2026-06-28 07:34:31 +00:00
jmiller 872abec8bc chore: sync .editorconfig from Template-Joomla 2026-06-28 07:33:59 +00:00
jmiller d01b39841a chore: add SECURITY.md from Template-Joomla
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Has been cancelled
2026-06-28 07:25:41 +00:00
jmiller 6cc08927e7 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-27 20:44:50 +00:00
jmiller ed715b5db8 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-27 05:32:30 +00:00
jmiller 5d02db24d5 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-27 00:49:26 +00:00
jmiller e6ade9033d chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-25 19:47:01 +00:00
jmiller 76845f78f2 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-25 19:47:00 +00:00
jmiller b68d3f6481 chore: sync ci-issue-reporter.yml from Template-Generic [skip ci] 2026-06-25 19:46:59 +00:00
jmiller 3110d7eb75 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-25 17:11:55 +00:00
jmiller e285b8e770 chore: sync version-set.yml from Template-Generic [skip ci] 2026-06-25 17:11:54 +00:00
jmiller 0997a875d6 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-25 17:11:54 +00:00
jmiller baf67e18e6 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-25 17:11:53 +00:00
jmiller cf6b1286b5 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-25 17:11:53 +00:00
jmiller c1f560704b chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-25 17:11:52 +00:00
jmiller 52edde00c9 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-25 17:11:51 +00:00
jmiller 759af6b237 chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-25 17:11:51 +00:00
jmiller e0112d770a chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-25 17:11:50 +00:00
jmiller 5544878cf2 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-25 17:11:49 +00:00
jmiller bd551bffda chore: sync version-set.yml from Template-Generic [skip ci] 2026-06-24 11:51:16 +00:00
jmiller 48eeb9631f chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-24 11:51:14 +00:00
gitea-actions[bot] 281e742b54 chore(version): pre-release bump to 01.04.08-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 8s
2026-06-23 18:28:10 +00:00
jmiller 2e3331170f chore: remove security-audit.yml -- handled by MokoGitea 2026-06-23 18:27:30 +00:00
gitea-actions[bot] 8de243b181 chore(version): pre-release bump to 01.04.07-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 8s
2026-06-23 18:21:17 +00:00
gitea-actions[bot] 9793bd4031 chore(version): pre-release bump to 01.04.06-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 7s
2026-06-23 18:07:08 +00:00
jmiller a9fc5d2cf1 chore: remove security-audit.yml -- handled by MokoGitea
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 29s
2026-06-23 18:05:38 +00:00
Jonathan Miller f1c6eb8f6e docs: update CHANGELOG and README for v2.0 release
Universal: Auto Version Bump / Version Bump (push) Successful in 25s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 26s
Add all v2.0 features: 11+ JSON-LD types, 6 preview cards, AI
generation, XML sitemap, per-platform images, SEO scoring, PHPUnit
tests, OpenAPI spec, coverage dashboard, custom schema builder.
2026-06-23 13:05:25 -05:00
jmiller a578ac3bb3 chore: remove deploy-manual.yml -- no longer needed
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 28s
2026-06-23 18:00:09 +00:00
gitea-actions[bot] cf783c6b83 chore(version): pre-release bump to 01.04.05-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 37s
2026-06-23 17:54:40 +00:00
gitea-actions[bot] bc6ce4397f chore(version): auto-bump patch 01.04.04-dev [skip ci] 2026-06-23 17:54:31 +00:00
Jonathan Miller 49d644566a feat: add custom schema, AI generation, XML sitemap, platform images
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
- Custom JSON-LD schema builder: per-article textarea for arbitrary
  structured data with JSON validation. Closes #70
- AI-powered meta generation: Generate with AI buttons for OG title
  and description, supports Claude and OpenAI APIs. Closes #71
- XML sitemap: auto-generates sitemap.xml on article save, respects
  noindex directives. Closes #72
- Per-platform image resizing: Twitter 1200x600, Pinterest 1000x1500,
  WhatsApp 400x400 alongside default Facebook 1200x630. Closes #74
- DB migration 01.05.00: adds custom_schema column
2026-06-23 12:54:01 -05:00
Jonathan Miller cbebaecc22 test: add PHPUnit test suite with JsonLdBuilder unit tests
16 unit tests covering FAQ, HowTo, Event, Recipe, LocalBusiness,
and VideoObject schema builders plus toScriptTag XSS escaping.
Closes #75
2026-06-23 12:54:00 -05:00
Jonathan Miller e7b0af1fca docs: add OpenAPI 3.0 spec for REST API
Documents the /api/v1/mokoog/tags endpoints with full request/response
schemas, authentication, and examples. Closes #80
2026-06-23 12:53:59 -05:00
Jonathan Miller 2088b3f13f feat: add OG coverage dashboard to tag manager
Shows coverage percentage, article count with/without OG tags,
and counts of missing title, description, and image fields.
Closes #73
2026-06-23 12:53:59 -05:00
Jonathan Miller f649858fcd feat: add SEO content scoring panel to article editor
JavaScript-based SEO analysis with 7 checks (OG title, description,
image, SEO title, meta description, title length, description
length). Shows pass/fail dots and overall score. Closes #68
2026-06-23 12:53:58 -05:00
jmiller 4699686f26 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-23 17:51:20 +00:00
jmiller a7fe881d84 chore: remove deprecated .mokogitea/workflows/composer-publish.yml [skip ci] 2026-06-23 17:37:50 +00:00
jmiller ab02de34f4 chore: remove deprecated .mokogitea/workflows/update-server.yml [skip ci] 2026-06-23 17:37:47 +00:00
jmiller 64d9a97db1 chore: remove deprecated .mokogitea/workflows/deploy-manual.yml [skip ci] 2026-06-23 17:37:44 +00:00
gitea-actions[bot] 7a38025b5e chore(version): pre-release bump to 01.04.03-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 7s
2026-06-23 17:20:08 +00:00
gitea-actions[bot] e530ca821e chore(version): auto-bump patch 01.04.02-dev [skip ci] 2026-06-23 17:19:49 +00:00
Jonathan Miller 872074cd5b feat: add FAQ, HowTo, Event, and Recipe JSON-LD schema types
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
- FAQ: auto-detects h3/h4 + paragraph patterns, outputs FAQPage (#62)
- HowTo: auto-detects ordered lists, outputs HowTo with steps (#63)
- Event: per-article fields (dates, venue, tickets), event_data JSON
  column, outputs Event schema (#64)
- Recipe: per-article fields (times, ingredients, nutrition),
  recipe_data JSON column, outputs Recipe schema (#66)
- DB migration 01.04.00: adds event_data and recipe_data columns

Closes #62, closes #63, closes #64, closes #66
2026-06-23 12:19:37 -05:00
gitea-actions[bot] c871b7d30d chore(version): pre-release bump to 01.04.01-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 4s
2026-06-23 16:33:35 +00:00
gitea-actions[bot] 641eee753a chore(version): auto-bump patch 01.03.08-dev [skip ci] 2026-06-23 16:33:26 +00:00
Jonathan Miller 44d9daf3bc feat: add LocalBusiness JSON-LD schema type
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
Adds configurable LocalBusiness structured data with address,
contact, geo coordinates, and opening hours. Enabled via plugin
parameters. Closes #65
2026-06-23 11:30:45 -05:00
Jonathan Miller 96eea6060f feat: add Discord, Mastodon, and Slack preview cards to editor
Shows how shared links will appear on Discord (dark theme with
accent bar), Mastodon (rounded card), and Slack (compact unfurl)
alongside the existing Facebook, Twitter, and LinkedIn previews.
Closes #69
2026-06-23 11:28:37 -05:00
Jonathan Miller aeea65423c feat: add VideoObject JSON-LD schema for video content
Outputs VideoObject structured data when an article has a video URL
set, enabling Google video rich results. Closes #67
2026-06-23 11:28:37 -05:00
Jonathan Miller b4d5b73d15 fix: code quality improvements (#76, #77, #78, #79)
- Add exception logging to BatchController batch skip (#76)
- Align form maxlength with DB schema limits (#77)
- applySeoTags() already uses public API — no change needed (#78)
- Add strip_tags() input sanitization on OG text fields (#79)
2026-06-23 11:28:36 -05:00
jmiller 3ba1c3ead4 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 16:05:23 +00:00
jmiller 4c091805ee chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 16:05:22 +00:00
jmiller 0d4e7785a3 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 16:05:21 +00:00
jmiller 6f13a10a34 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 16:05:18 +00:00
gitea-actions[bot] 5f1e44e66b chore: promote changelog [Unreleased] → [01.04.00] 2026-06-23 16:04:28 +00:00
gitea-actions[bot] 646dd23e81 chore(release): build 01.04.00 [skip ci]
Publish to Composer / Publish Package (release) Successful in 27s
2026-06-23 16:04:22 +00:00
gitea-actions[bot] e939e90733 chore(version): pre-release bump to 01.03.07-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 7s
2026-06-23 16:03:42 +00:00
gitea-actions[bot] d4c22ebdbf chore(version): auto-bump patch 01.03.06-dev [skip ci] 2026-06-23 16:03:32 +00:00
jmiller d4229fd450 Merge pull request 'feat: v1.3 — multi-platform social tags, editor UX, video support' (#82) from dev into main 2026-06-23 16:03:30 +00:00
Jonathan Miller 5724a1545e Merge remote-tracking branch 'origin/dev' into dev
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 9s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-23 11:02:45 -05:00
Jonathan Miller a04dbfd732 chore: normalize workflow CRLF to LF 2026-06-23 10:59:35 -05:00
Jonathan Miller bc06710fdd Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	CHANGELOG.md
#	README.md
#	source/packages/com_mokoog/mokoog.xml
#	source/packages/plg_content_mokoog/mokoog.xml
#	source/packages/plg_system_mokoog/mokoog.xml
#	source/packages/plg_webservices_mokoog/mokoog.xml
#	source/pkg_mokoog.xml
2026-06-23 10:55:38 -05:00
gitea-actions[bot] 07b296db61 chore(version): pre-release bump to 01.03.05-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 39s
2026-06-23 15:47:35 +00:00
gitea-actions[bot] 6a0ee812d8 chore(version): auto-bump patch 01.03.04-dev [skip ci] 2026-06-23 15:47:22 +00:00
Jonathan Miller fcfa6838e5 fix: address PR #82 review findings
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 14s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 54s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- Only emit og:video:secure_url for HTTPS URLs (review #1)
- Only emit og:video:width/height for direct files, not embeds (review #2)
- Add server-side http/https scheme validation on og_video save (review #3)
- Consolidate duplicate com_mokoshop product blocks into one (review #4)
- Fix stale com_virtuemart reference in SQL comment (review #5)
- Use COM_MOKOOG_* language keys in tag.xml instead of plugin keys (review #6)
2026-06-23 10:46:58 -05:00
gitea-actions[bot] 908e1d3e1b chore(version): pre-release bump to 01.03.03-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 29s
2026-06-23 15:37:27 +00:00
gitea-actions[bot] 9539bb44c2 chore(version): auto-bump patch 01.03.02-dev [skip ci] 2026-06-23 15:37:17 +00:00
Jonathan Miller 5b29690d34 feat: add og:video support and Pinterest rich pin tags
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
- Add og:video meta tags with per-article video URL field and auto
  MIME type detection for YouTube/Vimeo/direct files. Includes DB
  migration for og_video column. (closes #59)
- Add Pinterest rich pin tags: article:tag from Joomla content tags
  on article pages, product:availability from MokoSuiteShop stock
  quantity on product pages. (closes #60)
2026-06-23 10:36:04 -05:00
Jonathan Miller 881bb0a2ae feat: add fediverse:creator tag, character counters, LinkedIn preview
- Add fediverse:creator meta tag for Mastodon/Fediverse author
  attribution — first extension on any CMS to support this (closes #57)
- Add live character count indicators with green/yellow/red color
  coding on OG title, description, SEO title, and meta description
  fields in the article/menu editor (closes #58)
- Add LinkedIn social preview card alongside existing Facebook and
  Twitter/X previews in the editor (closes #61)
2026-06-23 10:36:04 -05:00
Jonathan Miller e9b34522d3 chore: remove Makefile
Build automation handled by CI workflows. Closes #81
2026-06-23 10:36:03 -05:00
jmiller 9aeb588937 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-22 00:35:27 +00:00
jmiller 9cdc7915a3 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 22:55:46 +00:00
jmiller 72ffaded49 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 22:55:45 +00:00
jmiller 23f6fe12a0 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 22:55:44 +00:00
jmiller 4c1d630673 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-21 22:55:42 +00:00
gitea-actions[bot] 560c7458c6 chore: promote changelog [Unreleased] → [01.03.00] 2026-06-21 22:28:20 +00:00
gitea-actions[bot] e39b617464 chore(release): build 01.03.00 [skip ci]
Publish to Composer / Publish Package (release) Successful in 38s
2026-06-21 22:28:14 +00:00
jmiller dac22fdcc4 fix: restore updateservers to package manifest (#55) 2026-06-21 22:25:04 +00:00
64 changed files with 4031 additions and 985 deletions
+1
View File
@@ -156,6 +156,7 @@ vendor/
composer.lock
*.phar
codeception.phar
.phpunit.cache/
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
+1 -1
View File
@@ -22,7 +22,7 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
+21 -11
View File
@@ -27,9 +27,18 @@ name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
types: [opened, synchronize, closed]
branches:
- main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
@@ -43,7 +52,7 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
@@ -57,6 +66,7 @@ jobs:
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
@@ -92,7 +102,7 @@ jobs:
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
@@ -111,7 +121,7 @@ jobs:
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Extract [Unreleased] section from changelog
@@ -259,7 +269,7 @@ jobs:
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
@@ -284,7 +294,7 @@ jobs:
- name: Update release notes and promote changelog
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
@@ -353,7 +363,7 @@ jobs:
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
@@ -382,7 +392,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
@@ -406,7 +416,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
@@ -427,7 +437,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
@@ -453,5 +463,5 @@ jobs:
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+6
View File
@@ -13,6 +13,12 @@
name: "Generic: Project CI"
on:
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
permissions:
@@ -0,0 +1,68 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
# VERSION: 01.00.00
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
name: "Universal: CI Issue Reporter"
on:
workflow_call:
inputs:
gate:
description: "CI gate name (e.g. PR Validation, Repository Health)"
required: true
type: string
details:
description: "Human-readable failure description"
required: true
type: string
severity:
description: "error or warning"
required: false
type: string
default: "error"
workflow:
description: "Workflow name for the issue title"
required: false
type: string
default: ""
secrets:
MOKOGITEA_TOKEN:
required: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
report:
name: "Report: ${{ inputs.gate }}"
runs-on: ubuntu-latest
steps:
- name: Clone MokoCLI
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
- name: Report CI failure
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
/tmp/mokocli/cli/ci_issue_reporter.sh \
--gate "${{ inputs.gate }}" \
--details "${{ inputs.details }}" \
--severity "${{ inputs.severity }}" \
--workflow "${{ inputs.workflow }}"
+10 -10
View File
@@ -21,7 +21,7 @@ permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
cleanup:
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
-76
View File
@@ -1,76 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
-126
View File
@@ -1,126 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+5 -5
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.03.01
# VERSION: 01.04.15
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -19,7 +19,7 @@ permissions:
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
@@ -28,8 +28,8 @@ jobs:
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
@@ -58,7 +58,7 @@ jobs:
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
+9 -22
View File
@@ -496,39 +496,26 @@ jobs:
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
gate: "PR Validation"
workflow: "PR Check"
severity: error
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
secrets: inherit
+4 -4
View File
@@ -20,7 +20,7 @@ permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
@@ -55,14 +55,14 @@ jobs:
- name: Validate metadata against Joomla manifest
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${GITEA_TOKEN}" \
--token "${MOKOGITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${GITEA_URL}/api/v1" \
--api-base "${MOKOGITEA_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
+6 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# VERSION: 05.02.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
@@ -59,6 +59,11 @@ jobs:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
submodules: recursive
- name: Update submodules to main
run: |
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
- name: Setup mokocli tools
env:
+18 -13
View File
@@ -29,12 +29,20 @@ jobs:
steps:
- name: Rename branch
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
set -euo pipefail
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
fi
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
@@ -42,25 +50,22 @@ jobs:
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
+24 -36
View File
@@ -77,7 +77,7 @@ jobs:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
@@ -671,42 +671,30 @@ jobs:
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
report-scripts:
name: "Report: Scripts Governance"
needs: [access_check, scripts_governance]
if: >-
always() &&
(needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
steps:
- name: Checkout
uses: actions/checkout@v4
needs.scripts_governance.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
gate: "Scripts Governance"
workflow: "Repo Health"
severity: error
details: "Scripts directory policy violations detected. Review required and allowed directories."
secrets: inherit
- name: "File issues for failed gates"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
report-health:
name: "Report: Repository Health"
needs: [access_check, repo_health]
if: >-
always() &&
needs.repo_health.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
gate: "Repository Health"
workflow: "Repo Health"
severity: error
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
secrets: inherit
-82
View File
@@ -1,82 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
-312
View File
@@ -1,312 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 05.00.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update Server
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
run: |
BRANCH="${{ github.ref_name }}"
# Configure git for bot pushes
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Auto-bump patch version
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Strip any existing suffix before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
else
STABILITY="development"
fi
# Version suffix per stability stream
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
*) SUFFIX=""; TAG="stable" ;;
esac
# Propagate version with stability suffix to all manifest files
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Re-read version (now includes suffix from version_set_platform)
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# Permission check: admin or maintain role required
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${{ steps.meta.outputs.display_version }}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
+130
View File
@@ -0,0 +1,130 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
# PATH: /.mokogitea/workflows/version-set.yml
# VERSION: 01.00.00
# BRIEF: Set or reset the extension version across all version-bearing files
name: "Joomla: Set Version"
on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 01.00.00)"
required: true
type: string
branch:
description: "Branch to update (default: current)"
required: false
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
set-version:
name: Set Version to ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
exit 1
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Update manifest version
run: |
MANIFEST=""
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla extension manifest found — skipping manifest update"
else
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
fi
- name: Update README.md version
run: |
if [ -f "README.md" ]; then
if grep -qP '^\s*VERSION:\s*\d' README.md; then
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
echo "README.md version updated to ${VERSION}"
else
echo "::warning::No VERSION line found in README.md — skipping"
fi
fi
- name: Update CHANGELOG.md
run: |
if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
# Check if this version already has an entry
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
else
# Insert new version entry after [Unreleased] or at the top after header
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
else
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
fi
echo "CHANGELOG.md: added entry for ${VERSION}"
fi
else
echo "::warning::No CHANGELOG.md found — skipping"
fi
- name: Update FILE INFORMATION blocks
run: |
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
while IFS= read -r -d '' FILE; do
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
echo "Updated FILE INFORMATION VERSION in ${FILE}"
fi
done
- name: Commit and push
run: |
git config user.name "Moko Consulting [bot]"
git config user.email "hello@mokoconsulting.tech"
git add -A
if git diff --cached --quiet; then
echo "No version changes detected — nothing to commit"
else
git commit -m "chore: set version to ${VERSION} [skip bump]
Authored-by: Moko Consulting"
git push
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
fi
+12 -4
View File
@@ -13,6 +13,7 @@
name: "Universal: Workflow Sync Trigger"
on:
workflow_dispatch:
pull_request:
types: [closed]
branches:
@@ -26,8 +27,9 @@ jobs:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]'))
steps:
- name: Determine platform from repo name
@@ -49,8 +51,14 @@ jobs:
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install PHP
run: |
if ! command -v php &> /dev/null; then
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
fi
- name: Install dependencies
run: |
+32 -1
View File
@@ -1,6 +1,6 @@
# Changelog
<!-- VERSION: 01.03.01 -->
<!-- VERSION: 01.04.15 -->
All notable changes to MokoSuiteOpenGraph will be documented in this file.
@@ -15,6 +15,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Fix multilingual data corruption in content plugin load/save (#41)
### Added
- Fediverse/Mastodon `fediverse:creator` meta tag — first extension on any CMS to support this (#57)
- Live character count indicators on OG title, OG description, SEO title, meta description fields with color-coded warnings (#58)
- LinkedIn social preview card in article/menu editor alongside Facebook and Twitter/X previews (#61)
- `og:video` meta tag support with per-article video URL field, auto-detect MIME type for YouTube/Vimeo/direct files (#59)
- Pinterest rich pin tags: `article:tag` from Joomla content tags, `product:availability` from MokoSuiteShop stock (#60)
- FAQ JSON-LD schema with auto-detection from article h3/h4 headings (#62)
- HowTo JSON-LD schema with auto-detection from ordered lists (#63)
- Event JSON-LD schema with per-article event fields (dates, venue, tickets) (#64)
- LocalBusiness JSON-LD schema with global plugin configuration (#65)
- Recipe JSON-LD schema with per-article fields (times, ingredients, nutrition) (#66)
- VideoObject JSON-LD schema for articles with video URLs (#67)
- SEO content scoring panel with 7 checks and pass/fail indicators (#68)
- Discord, Mastodon, and Slack social preview cards in editor (#69)
- Custom JSON-LD schema builder — per-article textarea for any schema.org type (#70)
- AI-powered meta tag generation with Claude and OpenAI API support (#71)
- XML sitemap generation on article save, respects noindex directives (#72)
- OG coverage dashboard in tag manager with coverage percentage (#73)
- Per-platform image resizing: Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400 (#74)
- PHPUnit test suite with 16 unit tests for JsonLdBuilder (#75)
- OpenAPI 3.0 specification for REST API (#80)
- Site-wide default OG title and description plugin parameters
- Discord embed color via `theme-color` meta tag (color picker in plugin config)
- LinkedIn article tags: `article:published_time`, `article:modified_time`, `article:author`
@@ -40,6 +60,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Facebook App ID and Telegram channel support
- Database table `#__mokoog_tags` with multilingual unique key
### Fixed
- Fix SQL driver attribute `mysql``mysqli` in component manifest preventing fresh installs
- Add exception logging to BatchController batch skip (#76)
- Align form maxlength attributes with DB schema limits (#77)
- Add `strip_tags()` input sanitization on OG text fields (#79)
- Only emit `og:video:secure_url` for HTTPS URLs
- Only emit `og:video:width/height` for direct files, not embeds
- Consolidate duplicate MokoSuiteShop product blocks
- Fix stale `com_virtuemart` reference in SQL comment
- Use component language keys for og_video field in tag.xml
### Changed
- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38)
- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39)
+36 -18
View File
@@ -1,28 +1,46 @@
# Code of Conduct
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.04.15
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1
-->
# Contributor Covenant Code of Conduct
## Our Pledge
We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone.
## Our Standards
- Be empathetic and kind
- Be respectful of differing opinions
- Accept constructive feedback
- Own mistakes and learn from them
Examples of behavior that contributes to a positive environment:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
Examples of unacceptable behavior:
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
Unacceptable behavior includes sexualized language/imagery, trolling, harassment, doxing, and other inappropriate conduct.
## Enforcement
Report incidents to **hello@mokoconsulting.tech** or through GitHub Discussions if you prefer a community-visible approach. Private complaints will be reviewed promptly and fairly.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at hello@mokoconsulting.tech. All complaints will be reviewed and investigated.
## Enforcement Guidelines
1. **Correction** — Private warning
2. **Warning** — Formal warning and limited interaction
3. **Temporary Ban** — Time-boxed exclusion
4. **Permanent Ban** — Removal from the community
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
Adapted from the Contributor Covenant v2.1.
+149 -22
View File
@@ -1,34 +1,161 @@
# Contributing to MokoJoomOpenGraph
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing to MokoJoomOpenGraph.
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Getting Started
## Branching Workflow
1. Fork the repository on Gitea
2. Create a feature branch from `dev` (`feature/your-feature`)
3. Make your changes following the coding standards below
4. Submit a pull request targeting `dev`
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
## Branch Strategy
### Step by step
- `main` — stable releases only
- `dev` — active development
- `feature/*` — new features (target `dev`)
- `fix/*` — bug fixes (target `dev`)
- `hotfix/*` — urgent fixes (target `dev` or `main`)
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
## Coding Standards
2. **Work and commit** on your feature branch. Push to origin.
- PHP 8.1+ required
- Follow Joomla coding standards
- SPDX license headers on all PHP files
- Use `SubscriberInterface` for event subscription
- Use `bind() -> check() -> store()` for Table operations
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
## Reporting Issues
Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/issues).
Use the repository's issue tracker with the appropriate template.
## License
---
By contributing, you agree that your contributions will be licensed under GPL-3.0-or-later.
*Moko Consulting <hello@mokoconsulting.tech>*
+119
View File
@@ -0,0 +1,119 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of
the GNU General Public License as published by the Free Software Foundation; either version 3
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
FILE INFORMATION
DEFGROUP: mokoconsulting-tech.Template-Joomla
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/Template-Joomla
VERSION: 01.04.15
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for Template-Joomla
-->
[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.00.04-blue)](https://github.com/mokoconsulting-tech/MokoStandards)
# Project Governance
## Overview
This document defines the governance model for the `Template-Joomla` repository within the
`mokoconsulting-tech` organization. It is automatically maintained by
[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) v04.00.04.
Full governance policy is defined in the MokoStandards source repository:
[docs/policy/GOVERNANCE.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md)
---
## Roles and Responsibilities
### Maintainer
**GitHub**: @mokoconsulting-tech
**Authority**: Final decision-making authority on all matters for this repository.
**Responsibilities**:
- Review and merge pull requests
- Maintain code quality and standards compliance
- Manage releases and versioning
- Respond to issues and security reports
### Contributors
**Authority**: Submit changes via pull requests.
**Requirements**:
- Read and accept `CODE_OF_CONDUCT.md`
- Follow `CONTRIBUTING.md` guidelines
---
## Decision-Making
All changes must be submitted as pull requests. The maintainer (@mokoconsulting-tech)
reviews and approves all changes before they are merged.
### Sole Operator Policy
This organization operates under a **sole operator** model. The maintainer (@mokoconsulting-tech)
is the sole employee and owner and may self-approve pull requests when no second reviewer is
available. The following requirements remain mandatory regardless:
1. **Pull Requests Required** — all changes to protected branches go through a PR.
2. **Automated Checks** — all CI checks must pass before merging.
3. **Audit Trail** — issues, pull requests, and commit history are preserved.
4. **Documentation** — changes are documented in `CHANGELOG.md`.
See the full policy:
[Sole Operator Policy](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md#sole-operator-policy)
---
## Change Management
| Change Type | Approval | Process |
|-------------|----------|---------|
| Routine (docs, bug fixes) | Maintainer | PR → CI pass → merge |
| Significant (new features) | Maintainer | PR with description → CI pass → merge |
| Major (breaking, architecture) | Maintainer | Issue discussion → PR → CI pass → merge |
| Emergency (security) | Maintainer | Labelled `EMERGENCY` → immediate merge → post-mortem |
---
## Reporting Issues
- **Bugs / Features**: Open a [GitHub Issue](https://github.com/mokoconsulting-tech/Template-Joomla/issues)
- **Security vulnerabilities**: See [SECURITY.md](./SECURITY.md)
- **Code of Conduct**: See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
- **Contact**: dev@mokoconsulting.tech
---
## Metadata
| Field | Value |
| ------------- | ----------------------------------------------- |
| Document Type | Policy |
| Domain | Governance |
| Applies To | mokoconsulting-tech/Template-Joomla |
| Jurisdiction | Tennessee, USA |
| Maintainer | @mokoconsulting-tech |
| Standards | MokoStandards v04.00.04 |
| Repo | https://github.com/mokoconsulting-tech/Template-Joomla |
| Path | /GOVERNANCE.md |
| Status | Active — auto-maintained by MokoStandards |
-203
View File
@@ -1,203 +0,0 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# MokoJoomOpenGraph — Open Graph & social sharing meta tag management
# ==============================================================================
# CONFIGURATION - Customize these for your extension
# ==============================================================================
# Extension Configuration
EXTENSION_NAME := mokoog
EXTENSION_TYPE := package
# Options: module, plugin, component, package, template
EXTENSION_VERSION := 1.0.0
# Module Configuration (for modules only)
MODULE_TYPE := site
# Options: site, admin
# Plugin Configuration (for plugins only)
PLUGIN_GROUP := system
# Options: system, content, user, authentication, etc.
# Directories
SRC_DIR := src
BUILD_DIR := build
DIST_DIR := dist
DOCS_DIR := docs
# Joomla Installation (for local testing - customize paths)
JOOMLA_ROOT := /var/www/html/joomla
JOOMLA_VERSION := 4
# Tools
PHP := php
COMPOSER := composer
NPM := npm
PHPCS := vendor/bin/phpcs
PHPCBF := vendor/bin/phpcbf
PHPUNIT := vendor/bin/phpunit
ZIP := zip
# Coding Standards
PHPCS_STANDARD := Joomla
# Colors for output
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
# ==============================================================================
# TARGETS
# ==============================================================================
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
.PHONY: install-deps
install-deps: ## Install all dependencies (Composer + npm)
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) install; \
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
fi
.PHONY: lint
lint: ## Run PHP linter (syntax check)
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: phpcs
phpcs: ## Run PHP CodeSniffer (Joomla standards)
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
@if [ -f "$(PHPCS)" ]; then \
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
else \
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \
fi
.PHONY: validate
validate: lint phpcs ## Run all validation checks
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
.PHONY: clean
clean: ## Clean build artifacts
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets
@echo "Minifying assets..."
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "No minify script found"; \
fi
.PHONY: build
build: clean validate minify ## Build extension package
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
# Determine package prefix based on extension type
@case "$(EXTENSION_TYPE)" in \
module) \
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
plugin) \
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
component) \
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
package) \
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
template) \
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
*) \
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
exit 1; \
;; \
esac; \
\
mkdir -p "$$BUILD_TARGET"; \
\
echo "Building $$PACKAGE_PREFIX..."; \
\
rsync -av --progress \
--exclude='$(BUILD_DIR)' \
--exclude='$(DIST_DIR)' \
--exclude='.git*' \
--exclude='vendor/' \
--exclude='node_modules/' \
--exclude='tests/' \
--exclude='Makefile' \
--exclude='composer.json' \
--exclude='composer.lock' \
--exclude='package.json' \
--exclude='package-lock.json' \
--exclude='phpunit.xml' \
--exclude='*.md' \
--exclude='.editorconfig' \
. "$$BUILD_TARGET/"; \
\
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
\
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
.PHONY: package
package: build ## Alias for build
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
.PHONY: release
release: validate build ## Create a release (validate + build)
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
.PHONY: version
version: ## Display version information
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
@echo " Name: $(EXTENSION_NAME)"
@echo " Type: $(EXTENSION_TYPE)"
@echo " Version: $(EXTENSION_VERSION)"
.PHONY: security-check
security-check: ## Run security checks on dependencies
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
fi
.PHONY: all
all: install-deps validate build ## Run complete build pipeline
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
# Default target
.DEFAULT_GOAL := help
+19 -3
View File
@@ -1,6 +1,6 @@
# MokoSuiteOpenGraph
<!-- VERSION: 01.03.01 -->
<!-- VERSION: 01.04.15 -->
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6.
@@ -16,6 +16,9 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
- **LinkedIn** — `article:published_time`, `article:modified_time`, `article:author`
- **Discord** — Custom embed color via `theme-color` meta tag
- **Telegram** — `telegram:channel` for link previews
- **Mastodon/Fediverse** — `fediverse:creator` for author attribution (first extension on any CMS)
- **Pinterest** — Rich pin tags: `article:tag`, `product:availability`, `product:price`
- **og:video** — Per-article video URLs with auto MIME type detection (YouTube/Vimeo/direct)
- **Facebook** — `fb:app_id` support, `og:image:width`/`og:image:height` for instant previews
### Content Management
@@ -31,7 +34,8 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
- **Meta description** — Per-page meta description control
- **Robots directive** — Per-page noindex/nofollow settings
- **Canonical URL** — Custom canonical URL overrides
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization schemas
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization, FAQ, HowTo, Event, Recipe, LocalBusiness, VideoObject, and custom schemas
- **SEO content scoring** — 7-check analysis panel with pass/fail indicators in the editor
### Admin Tools
- **Tag manager dashboard** — View and manage all OG records centrally
@@ -39,13 +43,20 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
- **CSV import/export** — Bulk manage OG data via CSV files
- **SEO health badges** — Visual indicators for missing descriptions, long titles, noindex
- **Debug links** — Quick links to Facebook Debugger, LinkedIn Inspector, Google Rich Results
- **Live preview** — Real-time Facebook and Twitter/X card preview in the editor
- **Live preview** — Real-time Facebook, Twitter/X, LinkedIn, Discord, Mastodon, and Slack card previews in the editor
- **Character count indicators** — Green/yellow/red warnings on OG and SEO text fields
- **OG coverage dashboard** — Coverage percentage and missing field counts
- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI
### Developer Features
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400
- **XML sitemap** — Auto-generates sitemap.xml on article save, respects noindex
- **OpenAPI spec** — Full REST API documentation at `openapi.yaml`
- **PHPUnit tests** — 16 unit tests for JsonLdBuilder schema outputs
## Installation
@@ -63,6 +74,11 @@ Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to config
- Facebook App ID
- Discord embed color
- Telegram channel
- Fediverse/Mastodon creator handle
- LocalBusiness schema (address, phone, hours, geo)
- XML sitemap generation
- AI meta generation (Claude/OpenAI API key)
- Per-platform image resizing
- Auto-generation, image resize, JSON-LD, and description length settings
## License
+241
View File
@@ -0,0 +1,241 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
# FILE INFORMATION
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md
VERSION: 01.04.15
BRIEF: Security vulnerability reporting and handling policy
-->
# Security Policy
## Purpose and Scope
This document defines the security vulnerability reporting, response, and disclosure policy for this Joomla Plugin template repository. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues.
## Supported Versions
Security updates are provided for the following versions:
| Version | Supported |
| ------- | ------------------ |
| 01.x.x | :white_check_mark: |
| < 01.0 | :x: |
Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches.
## Reporting a Vulnerability
### Where to Report
**DO NOT** create public GitHub issues for security vulnerabilities.
Report security vulnerabilities privately to:
**Email**: `security@mokoconsulting.tech`
**Subject Line**: `[SECURITY] Template-Joomla - Brief Description`
### What to Include
A complete vulnerability report should include:
1. **Description**: Clear explanation of the vulnerability
2. **Impact**: Potential security impact and severity assessment
3. **Affected Versions**: Which versions are vulnerable
4. **Reproduction Steps**: Detailed steps to reproduce the issue
5. **Proof of Concept**: Code, configuration, or demonstration (if applicable)
6. **Suggested Fix**: Proposed remediation (if known)
7. **Disclosure Timeline**: Your expectations for public disclosure
### Response Timeline
* **Initial Response**: Within 3 business days
* **Assessment Complete**: Within 7 business days
* **Fix Timeline**: Depends on severity (see below)
* **Disclosure**: Coordinated with reporter
## Severity Classification
Vulnerabilities are classified using the following severity levels:
### Critical
* Remote code execution
* Authentication bypass
* Data breach or exposure of sensitive information
* **Fix Timeline**: 7 days
### High
* Privilege escalation
* SQL injection or command injection
* Cross-site scripting (XSS) with significant impact
* **Fix Timeline**: 14 days
### Medium
* Information disclosure (limited scope)
* Denial of service
* Security misconfigurations with moderate impact
* **Fix Timeline**: 30 days
### Low
* Security best practice violations
* Minor information leaks
* Issues requiring user interaction or complex preconditions
* **Fix Timeline**: 60 days or next release
## Remediation Process
1. **Acknowledgment**: Security team confirms receipt and begins investigation
2. **Assessment**: Vulnerability is validated, severity assigned, and impact analyzed
3. **Development**: Security patch is developed and tested
4. **Review**: Patch undergoes security review and validation
5. **Release**: Fixed version is released with security advisory
6. **Disclosure**: Public disclosure follows coordinated timeline
## Security Advisories
Security advisories are published via:
* GitHub Security Advisories
* Release notes and CHANGELOG.md
* Email notification to project users (if mailing list is established)
Advisories include:
* CVE identifier (if applicable)
* Severity rating
* Affected versions
* Fixed versions
* Mitigation steps
* Attribution (with reporter consent)
## Security Best Practices
For projects using this template:
### Required Controls
* Enable GitHub security features (Dependabot, code scanning)
* Implement branch protection on `main`
* Require code review for all changes
* Enforce signed commits (recommended)
* Use secrets management (never commit credentials)
* Maintain security documentation
* Follow secure coding standards defined in MokoStandards
### Joomla Plugin Security
* Follow Joomla security best practices
* Validate and sanitize all user input
* Use Joomla's database API to prevent SQL injection
* Properly escape output to prevent XSS
* Implement proper access control checks
* Use Joomla's session and authentication APIs
* Keep Joomla and dependencies up to date
### CI/CD Security
* Validate all inputs
* Sanitize outputs
* Use least privilege access
* Pin dependencies with hash verification
* Scan for vulnerabilities in dependencies
* Audit third-party actions and tools
#### Automated Security Scanning
All repositories SHOULD implement:
**CodeQL Analysis**:
* Enabled for PHP and other supported languages
* Runs on: push to main, pull requests, weekly schedule
* Query sets: `security-extended` and `security-and-quality`
* Configuration: `.github/workflows/codeql-analysis.yml`
**Dependabot Security Updates**:
* Weekly scans for vulnerable dependencies
* Automated pull requests for security patches
* Configuration: `.github/dependabot.yml`
**Secret Scanning**:
* Enabled by default with push protection
* Prevents accidental credential commits
### Dependency Management
* Keep dependencies up to date
* Monitor security advisories for dependencies
* Remove unused dependencies
* Audit new dependencies before adoption
* Document security-critical dependencies
## Compliance and Governance
This security policy is aligned with MokoStandards. Deviations require documented justification.
Security policies are reviewed and updated at least annually or following significant security incidents.
## Attribution and Recognition
We acknowledge and appreciate responsible disclosure. With your permission, we will:
* Credit you in security advisories
* List you in CHANGELOG.md for the fix release
* Recognize your contribution publicly (if desired)
## Contact and Escalation
* **Security Team**: security@mokoconsulting.tech
* **Primary Contact**: hello@mokoconsulting.tech
* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub
## Out of Scope
The following are explicitly out of scope:
* Issues in third-party dependencies (report directly to maintainers)
* Social engineering attacks
* Physical security issues
* Denial of service via resource exhaustion without amplification
* Issues requiring physical access to systems
* Theoretical vulnerabilities without proof of exploitability
---
## Metadata
| Field | Value |
| ------------ | ------------------------------------------------------------------------------------------------------------ |
| Document | Security Policy |
| Path | /SECURITY.md |
| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) |
| Owner | Moko Consulting |
| Scope | Security vulnerability handling |
| Status | Active |
| Effective | 2026-01-16 |
## Revision History
| Date | Change Description | Author |
| ---------- | ------------------------------------------------- | --------------- |
| 2026-01-16 | Initial creation for template repository | Moko Consulting |
+16 -2
View File
@@ -15,9 +15,23 @@
"php": ">=8.1"
},
"require-dev": {
"squizlabs/php_codesniffer": "^3.7",
"joomla/coding-standards": "^3.0",
"phpstan/phpstan": "^1.10",
"joomla/coding-standards": "^3.0"
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload": {
"psr-4": {
"Joomla\\Plugin\\System\\MokoOG\\": "source/packages/plg_system_mokoog/src/",
"Joomla\\Plugin\\Content\\MokoOG\\": "source/packages/plg_content_mokoog/src/",
"Joomla\\Plugin\\WebServices\\MokoOG\\": "source/packages/plg_webservices_mokoog/src/",
"Joomla\\Component\\MokoOG\\Administrator\\": "source/packages/com_mokoog/src/"
}
},
"autoload-dev": {
"psr-4": {
"Mokoconsulting\\MokoOG\\Tests\\": "tests/"
}
},
"minimum-stability": "alpha",
"prefer-stable": true,
+670
View File
@@ -0,0 +1,670 @@
openapi: 3.0.3
info:
title: MokoSuiteOpenGraph API
version: 1.0.0
description: |
REST API for managing Open Graph, SEO meta, and structured-data tags in
Joomla via the MokoSuiteOpenGraph extension.
**Requires Joomla 6.0 or higher.**
The API follows Joomla's Web Services conventions and returns responses in
[JSON:API](https://jsonapi.org/) format. All endpoints require
authentication via a Joomla API token.
contact:
name: Moko Consulting
email: hello@mokoconsulting.tech
license:
name: GPL-3.0-or-later
url: https://www.gnu.org/licenses/gpl-3.0.html
servers:
- url: /api/index.php/v1
description: Joomla Web Services API
security:
- apiToken: []
tags:
- name: Tags
description: CRUD operations for Open Graph tag records
paths:
/mokoog/tags:
get:
operationId: listTags
summary: List OG tags
description: |
Returns a paginated collection of OG tag records. Supports filtering
by content type, published state, and language.
tags: [Tags]
parameters:
- name: "filter[content_type]"
in: query
description: Filter by content type (e.g. `com_content`, `menu`, `com_mokoshop`)
schema:
type: string
example: com_content
- name: "filter[content_id]"
in: query
description: Filter by content ID
schema:
type: integer
example: 42
- name: "filter[published]"
in: query
description: Filter by published state
schema:
type: integer
enum: [0, 1]
- name: "filter[language]"
in: query
description: Filter by language tag (e.g. `en-GB`, `*`)
schema:
type: string
example: "*"
- name: "filter[search]"
in: query
description: Free-text search across tag fields
schema:
type: string
- name: "page[offset]"
in: query
description: Number of records to skip (pagination offset)
schema:
type: integer
minimum: 0
default: 0
- name: "page[limit]"
in: query
description: Maximum number of records to return
schema:
type: integer
minimum: 1
maximum: 100
default: 25
- name: "list[fullordering]"
in: query
description: Sort order for results
schema:
type: string
enum:
- a.id ASC
- a.id DESC
- a.og_title ASC
- a.og_title DESC
- a.modified ASC
- a.modified DESC
default: a.modified DESC
responses:
"200":
description: A JSON:API collection of OG tags
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/TagCollection"
example:
links:
self: "/api/index.php/v1/mokoog/tags"
data:
- type: tags
id: "1"
attributes:
content_type: com_content
content_id: 42
og_title: "My Article Title"
og_description: "A brief description for social sharing."
og_image: "images/mokoog/og-banner.jpg"
og_type: article
seo_title: "My Article | Example Site"
meta_description: "A brief meta description for search engines."
robots: "index, follow"
canonical_url: "https://example.com/my-article"
language: "*"
published: 1
created: "2026-06-01T12:00:00+00:00"
modified: "2026-06-15T08:30:00+00:00"
meta:
total-pages: 1
"401":
$ref: "#/components/responses/Unauthorized"
post:
operationId: createTag
summary: Create an OG tag
description: |
Creates a new OG tag record. The combination of `content_type`,
`content_id`, and `language` must be unique.
tags: [Tags]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TagCreateRequest"
example:
content_type: com_content
content_id: 42
og_title: "My Article Title"
og_description: "A brief description for social sharing."
og_image: "images/mokoog/og-banner.jpg"
og_type: article
language: "*"
published: 1
responses:
"200":
description: The created tag
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/TagDocument"
example:
links:
self: "/api/index.php/v1/mokoog/tags/1"
data:
type: tags
id: "1"
attributes:
content_type: com_content
content_id: 42
og_title: "My Article Title"
og_description: "A brief description for social sharing."
og_image: "images/mokoog/og-banner.jpg"
og_type: article
seo_title: ""
meta_description: ""
robots: ""
canonical_url: ""
language: "*"
published: 1
created: "2026-06-23T10:00:00+00:00"
modified: "2026-06-23T10:00:00+00:00"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
/mokoog/tags/{id}:
parameters:
- $ref: "#/components/parameters/TagId"
get:
operationId: getTag
summary: Get a single OG tag
tags: [Tags]
responses:
"200":
description: A single OG tag resource
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/TagDocument"
example:
links:
self: "/api/index.php/v1/mokoog/tags/1"
data:
type: tags
id: "1"
attributes:
content_type: com_content
content_id: 42
og_title: "My Article Title"
og_description: "A brief description for social sharing."
og_image: "images/mokoog/og-banner.jpg"
og_type: article
seo_title: "My Article | Example Site"
meta_description: "A brief meta description for search engines."
robots: "index, follow"
canonical_url: "https://example.com/my-article"
language: "*"
published: 1
created: "2026-06-01T12:00:00+00:00"
modified: "2026-06-15T08:30:00+00:00"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
patch:
operationId: updateTag
summary: Update an OG tag
description: Partially updates an existing OG tag. Only supplied fields are changed.
tags: [Tags]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TagUpdateRequest"
example:
og_title: "Updated Title"
og_description: "Updated social description."
published: 0
responses:
"200":
description: The updated tag
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/TagDocument"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
delete:
operationId: deleteTag
summary: Delete an OG tag
tags: [Tags]
responses:
"204":
description: Tag deleted successfully
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/mokoog/lookup/{content_type}/{content_id}:
get:
operationId: lookupTag
summary: Look up an OG tag by content type and content ID
description: |
Resolves an OG tag by its `content_type` and `content_id` pair and
returns the full tag resource. This is a convenience endpoint that
avoids the caller needing to know the internal tag ID.
tags: [Tags]
parameters:
- name: content_type
in: path
required: true
description: |
The content type identifier (e.g. `com_content`, `menu`,
`com_mokoshop`). Must match the pattern `[a-z][a-z0-9_.]*`.
schema:
type: string
pattern: "^[a-z][a-z0-9_.]*$"
example: com_content
- name: content_id
in: path
required: true
description: The content item ID
schema:
type: integer
minimum: 1
example: 42
responses:
"200":
description: The matching OG tag resource
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/TagDocument"
"400":
description: Missing or invalid content_type / content_id
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
errors:
- title: Bad Request
status: 400
detail: "content_type and content_id are required"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
description: No OG tag found for the given content_type and content_id
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
errors:
- title: Not Found
status: 404
detail: "OG tag not found for com_content:42"
components:
securitySchemes:
apiToken:
type: apiKey
name: X-Joomla-Token
in: header
description: |
Joomla API token. Can also be passed as the `api-token` query
parameter. Generate a token from the Joomla administrator panel
under Users > Manage > [user] > Joomla API Token tab.
parameters:
TagId:
name: id
in: path
required: true
description: The OG tag record ID
schema:
type: integer
minimum: 1
example: 1
schemas:
TagAttributes:
type: object
description: Full set of OG tag attributes returned by the API
properties:
content_type:
type: string
description: |
Content type identifier (e.g. `com_content`, `menu`,
`com_mokoshop`). Must match `[a-z][a-z0-9_.]*`.
pattern: "^[a-z][a-z0-9_.]*$"
maxLength: 100
example: com_content
content_id:
type: integer
description: The ID of the associated content item
minimum: 1
example: 42
og_title:
type: string
description: Open Graph title (`og:title`)
maxLength: 255
example: "My Article Title"
og_description:
type: string
description: Open Graph description (`og:description`)
example: "A brief description for social sharing."
og_image:
type: string
description: Relative path to the Open Graph image (`og:image`)
maxLength: 512
example: "images/mokoog/og-banner.jpg"
og_type:
type: string
description: Open Graph type (`og:type`)
default: article
enum:
- article
- website
- product
- profile
- book
- music.song
- music.album
- video.movie
- video.episode
- video.other
example: article
seo_title:
type: string
description: SEO page title (used in `<title>` tag)
maxLength: 70
example: "My Article | Example Site"
meta_description:
type: string
description: Meta description for search engines
maxLength: 200
example: "A brief meta description for search engines."
robots:
type: string
description: |
Comma-separated robots directives. Valid directives: `index`,
`noindex`, `follow`, `nofollow`, `none`, `noarchive`,
`nosnippet`, `noimageindex`, `max-snippet`, `max-image-preview`.
maxLength: 100
example: "index, follow"
canonical_url:
type: string
format: uri
description: Canonical URL for the page
maxLength: 512
example: "https://example.com/my-article"
language:
type: string
description: Joomla language tag (`*` for all languages)
maxLength: 7
default: "*"
example: "*"
published:
type: integer
description: Published state (1 = published, 0 = unpublished)
enum: [0, 1]
default: 1
example: 1
created:
type: string
format: date-time
description: Record creation timestamp (read-only)
readOnly: true
example: "2026-06-01T12:00:00+00:00"
modified:
type: string
format: date-time
description: Last modification timestamp (read-only)
readOnly: true
example: "2026-06-15T08:30:00+00:00"
TagResource:
type: object
description: A single OG tag in JSON:API resource format
required: [type, id, attributes]
properties:
type:
type: string
enum: [tags]
example: tags
id:
type: string
description: The record ID as a string (per JSON:API spec)
example: "1"
attributes:
$ref: "#/components/schemas/TagAttributes"
TagDocument:
type: object
description: JSON:API document containing a single tag resource
properties:
links:
type: object
properties:
self:
type: string
example: "/api/index.php/v1/mokoog/tags/1"
data:
$ref: "#/components/schemas/TagResource"
TagCollection:
type: object
description: JSON:API document containing a collection of tag resources
properties:
links:
type: object
properties:
self:
type: string
example: "/api/index.php/v1/mokoog/tags"
data:
type: array
items:
$ref: "#/components/schemas/TagResource"
meta:
type: object
properties:
total-pages:
type: integer
description: Total number of pages available
example: 1
TagCreateRequest:
type: object
description: Request body for creating a new OG tag
required:
- content_type
- content_id
properties:
content_type:
type: string
pattern: "^[a-z][a-z0-9_.]*$"
maxLength: 100
example: com_content
content_id:
type: integer
minimum: 1
example: 42
og_title:
type: string
maxLength: 255
og_description:
type: string
og_image:
type: string
maxLength: 512
og_type:
type: string
default: article
enum:
- article
- website
- product
- profile
- book
- music.song
- music.album
- video.movie
- video.episode
- video.other
og_video:
type: string
format: uri
description: Open Graph video URL (`og:video`)
maxLength: 512
seo_title:
type: string
maxLength: 70
meta_description:
type: string
maxLength: 200
robots:
type: string
maxLength: 100
canonical_url:
type: string
format: uri
maxLength: 512
language:
type: string
maxLength: 7
default: "*"
published:
type: integer
enum: [0, 1]
default: 1
TagUpdateRequest:
type: object
description: |
Request body for updating an OG tag. All fields are optional; only
supplied fields are modified.
properties:
og_title:
type: string
maxLength: 255
og_description:
type: string
og_image:
type: string
maxLength: 512
og_type:
type: string
enum:
- article
- website
- product
- profile
- book
- music.song
- music.album
- video.movie
- video.episode
- video.other
og_video:
type: string
format: uri
maxLength: 512
seo_title:
type: string
maxLength: 70
meta_description:
type: string
maxLength: 200
robots:
type: string
maxLength: 100
canonical_url:
type: string
format: uri
maxLength: 512
language:
type: string
maxLength: 7
published:
type: integer
enum: [0, 1]
ErrorResponse:
type: object
description: JSON:API error response
properties:
errors:
type: array
items:
type: object
properties:
title:
type: string
example: Not Found
status:
type: integer
example: 404
detail:
type: string
example: "Item not found."
responses:
BadRequest:
description: Invalid request data
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
errors:
- title: Bad Request
status: 400
detail: "Content type is required."
Unauthorized:
description: Missing or invalid API token
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
errors:
- title: Forbidden
status: 403
detail: "You are not authorised to access this resource."
NotFound:
description: Resource not found
content:
application/vnd.api+json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
errors:
- title: Not Found
status: 404
detail: "Item not found."
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>source/packages</directory>
</include>
</source>
</phpunit>
+12 -4
View File
@@ -30,7 +30,7 @@
label="COM_MOKOOG_FIELD_OG_TITLE"
description="COM_MOKOOG_FIELD_OG_TITLE_DESC"
filter="string"
maxlength="70"
maxlength="255"
/>
<field
name="og_description"
@@ -39,7 +39,7 @@
description="COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
maxlength="512"
/>
<field
name="og_image"
@@ -60,6 +60,14 @@
<option value="product">Product</option>
<option value="profile">Profile</option>
</field>
<field
name="og_video"
type="url"
label="COM_MOKOOG_FIELD_OG_VIDEO"
description="COM_MOKOOG_FIELD_OG_VIDEO_DESC"
filter="url"
validate="url"
/>
<field
name="published"
type="list"
@@ -77,7 +85,7 @@
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
maxlength="255"
/>
<field
name="meta_description"
@@ -86,7 +94,7 @@
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
maxlength="255"
/>
<field
name="robots"
@@ -32,6 +32,8 @@ COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
COM_MOKOOG_FIELD_OG_TYPE="OG Type"
COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
COM_MOKOOG_FIELD_OG_VIDEO="Video URL"
COM_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video for social sharing previews. Supports direct video URLs and YouTube/Vimeo links."
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
@@ -57,3 +59,10 @@ COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
COM_MOKOOG_COVERAGE_TITLE="OG Tag Coverage"
COM_MOKOOG_COVERAGE_PERCENT="OG Coverage"
COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags"
COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title"
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
@@ -32,6 +32,8 @@ COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
COM_MOKOOG_FIELD_OG_TYPE="OG Type"
COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
COM_MOKOOG_FIELD_OG_VIDEO="Video URL"
COM_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video for social sharing previews. Supports direct video URLs and YouTube/Vimeo links."
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
@@ -57,3 +59,10 @@ COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
COM_MOKOOG_COVERAGE_TITLE="OG Tag Coverage"
COM_MOKOOG_COVERAGE_PERCENT="OG Coverage"
COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags"
COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title"
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
+4 -4
View File
@@ -8,7 +8,7 @@
-->
<extension type="component" method="upgrade">
<name>com_mokoog</name>
<version>01.03.01</version>
<version>01.04.15</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -23,17 +23,17 @@
<install>
<sql>
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
<file driver="mysqli" charset="utf8">sql/install.mysql.sql</file>
</sql>
</install>
<uninstall>
<sql>
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
<file driver="mysqli" charset="utf8">sql/uninstall.mysql.sql</file>
</sql>
</uninstall>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
<schemapath type="mysqli">sql/updates/mysql</schemapath>
</schemas>
</update>
@@ -6,12 +6,16 @@
CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`content_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'e.g. com_content, menu, com_virtuemart',
`content_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'e.g. com_content, menu, com_mokoshop',
`content_id` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`og_title` VARCHAR(255) NOT NULL DEFAULT '',
`og_description` TEXT NOT NULL,
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
`og_video` VARCHAR(512) NOT NULL DEFAULT '',
`event_data` TEXT NULL,
`recipe_data` TEXT NULL,
`custom_schema` TEXT NULL,
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
`robots` VARCHAR(100) NOT NULL DEFAULT '',
@@ -0,0 +1,5 @@
--
-- MokoJoomOpenGraph 01.03.00 - Add og_video column
--
ALTER TABLE `#__mokoog_tags` ADD COLUMN `og_video` VARCHAR(512) NOT NULL DEFAULT '' AFTER `og_type`;
@@ -0,0 +1,6 @@
--
-- MokoJoomOpenGraph 01.04.00 - Add event_data and recipe_data columns
--
ALTER TABLE `#__mokoog_tags` ADD COLUMN `event_data` TEXT NULL AFTER `og_video`;
ALTER TABLE `#__mokoog_tags` ADD COLUMN `recipe_data` TEXT NULL AFTER `event_data`;
@@ -0,0 +1 @@
/* 01.04.09 — no schema changes */
@@ -0,0 +1 @@
/* 01.04.10 — no schema changes */
@@ -0,0 +1 @@
/* 01.04.11 — no schema changes */
@@ -0,0 +1 @@
/* 01.04.12 — no schema changes */
@@ -0,0 +1 @@
/* 01.04.13 — no schema changes */
@@ -0,0 +1 @@
/* 01.04.14 — no schema changes */
@@ -0,0 +1 @@
/* 01.04.15 — no schema changes */
@@ -0,0 +1 @@
ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`;
@@ -120,6 +120,7 @@ class BatchController extends BaseController
$created++;
} catch (\RuntimeException $e) {
$skipped++;
\Joomla\CMS\Log\Log::add('Batch insert failed for article ' . $article->id . ': ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
@@ -0,0 +1,58 @@
<?php
/**
* @package MokoJoomOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
$db = Factory::getDbo();
// Total published articles
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1'));
$totalArticles = (int) $db->loadResult();
// Articles with OG tags
$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1'));
$articlesWithOg = (int) $db->loadResult();
// Articles missing OG data fields
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1'));
$missingTitle = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1'));
$missingDesc = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1'));
$missingImage = (int) $db->loadResult();
$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0;
?>
<div class="mokoog-coverage card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_TITLE'); ?></h4>
<div class="row">
<div class="col-md-3 text-center">
<div class="display-4 <?php echo $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); ?>">
<?php echo $coverage; ?>%
</div>
<small class="text-muted"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></small>
</div>
<div class="col-md-9">
<ul class="list-unstyled">
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $articlesWithOg, $totalArticles); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', $missingTitle); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', $missingDesc); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', $missingImage); ?></li>
</ul>
</div>
</div>
</div>
</div>
@@ -21,6 +21,7 @@ use Joomla\CMS\Session\Session;
$token = Session::getFormToken();
?>
<?php include __DIR__ . '/coverage.php'; ?>
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
@@ -16,7 +16,7 @@
label="PLG_CONTENT_MOKOOG_FIELD_OG_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC"
filter="string"
maxlength="70"
maxlength="255"
/>
<field
name="og_description"
@@ -25,7 +25,7 @@
description="PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
maxlength="512"
/>
<field
name="og_image"
@@ -49,6 +49,14 @@
<option value="music.song">Music</option>
<option value="video.other">Video</option>
</field>
<field
name="og_video"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO"
description="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC"
filter="url"
validate="url"
/>
</fieldset>
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
@@ -58,7 +66,7 @@
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
maxlength="255"
/>
<field
name="meta_description"
@@ -67,7 +75,7 @@
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
maxlength="255"
/>
<field
name="robots"
@@ -93,5 +101,29 @@
validate="url"
/>
</fieldset>
<fieldset name="mokoog_event" label="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC">
<field name="event_start" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_START" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
<field name="event_end" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_END" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
<field name="event_location" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC" filter="string" />
<field name="event_address" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC" filter="string" />
<field name="event_price" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC" filter="string" />
<field name="event_currency" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC" filter="string" default="USD" />
<field name="event_url" type="url" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC" filter="url" />
</fieldset>
<fieldset name="mokoog_recipe" label="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC">
<field name="recipe_prep_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC" filter="string" hint="PT15M" />
<field name="recipe_cook_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC" filter="string" hint="PT30M" />
<field name="recipe_yield" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC" filter="string" hint="4 servings" />
<field name="recipe_calories" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC" filter="string" hint="350" />
<field name="recipe_ingredients" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC" filter="string" rows="5" hint="One ingredient per line" />
<field name="recipe_category" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC" filter="string" hint="Dessert" />
<field name="recipe_cuisine" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC" filter="string" hint="Italian" />
</fieldset>
<fieldset name="mokoog_custom_schema" label="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC">
<field name="custom_schema" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA" description="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC" filter="raw" rows="12" class="input-xxlarge" />
</fieldset>
</fields>
</form>
@@ -14,6 +14,9 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recomme
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
@@ -26,3 +29,42 @@ PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
@@ -14,6 +14,9 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recomme
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
@@ -26,3 +29,42 @@ PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
@@ -102,3 +102,154 @@
color: #536471;
margin-top: 2px;
}
/* LinkedIn card */
.mokoog-card-li {
border: 1px solid #e0dfdc;
border-radius: 2px;
}
.mokoog-card-li .mokoog-card-body {
border-top-color: #e0dfdc;
}
.mokoog-card-li .mokoog-card-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.9);
}
.mokoog-card-li .mokoog-card-domain {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
text-transform: none;
}
/* Discord card */
.mokoog-card-dc {
background: #2b2d31;
border-left: 4px solid #5865f2;
border-radius: 4px;
}
.mokoog-card-dc .mokoog-card-body {
border-top: none;
}
.mokoog-card-dc .mokoog-card-img {
height: 200px;
margin: 0 12px 12px;
border-radius: 4px;
}
.mokoog-card-dc .mokoog-card-title {
font-size: 16px;
font-weight: 700;
color: #00a8fc;
}
.mokoog-card-dc .mokoog-card-desc {
font-size: 14px;
color: #dbdee1;
}
.mokoog-card-dc .mokoog-card-domain {
font-size: 12px;
color: #b5bac1;
text-transform: none;
}
/* Mastodon card */
.mokoog-card-ma {
border: 1px solid #c8ccd0;
border-radius: 8px;
}
.mokoog-card-ma .mokoog-card-img {
border-radius: 8px 8px 0 0;
}
.mokoog-card-ma .mokoog-card-body {
border-top-color: #c8ccd0;
}
.mokoog-card-ma .mokoog-card-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.mokoog-card-ma .mokoog-card-desc {
font-size: 13px;
color: #606984;
}
.mokoog-card-ma .mokoog-card-domain {
font-size: 12px;
color: #606984;
text-transform: none;
}
/* Slack card */
.mokoog-card-sl {
border-left: 4px solid #36c5f0;
border-radius: 0;
background: #fff;
}
.mokoog-card-sl .mokoog-card-body {
border-top: none;
padding: 8px 12px;
}
.mokoog-card-sl .mokoog-card-title {
font-size: 15px;
font-weight: 700;
color: #1264a3;
}
.mokoog-card-sl .mokoog-card-desc {
font-size: 14px;
color: #1d1c1d;
}
.mokoog-card-sl .mokoog-card-domain {
font-size: 12px;
color: #616061;
text-transform: none;
margin-top: 4px;
}
/* Character count indicators */
.mokoog-char-count {
display: block;
font-size: 12px;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.mokoog-char-ok {
color: #2e7d32;
}
.mokoog-char-warn {
color: #f57c00;
}
.mokoog-char-over {
color: #d32f2f;
font-weight: 600;
}
/* SEO scoring panel */
.mokoog-seo-score { margin: 15px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6; }
.mokoog-seo-heading { margin: 0 0 10px; font-size: 14px; color: #666; }
.mokoog-seo-list { list-style: none; padding: 0; margin: 0 0 10px; }
.mokoog-seo-item { padding: 4px 0; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.mokoog-seo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.mokoog-seo-pass { background: #2e7d32; }
.mokoog-seo-fail { background: #d32f2f; }
.mokoog-seo-total { font-size: 14px; font-weight: 600; padding-top: 8px; border-top: 1px solid #dee2e6; }
.mokoog-seo-total-good { color: #2e7d32; }
.mokoog-seo-total-ok { color: #f57c00; }
.mokoog-seo-total-bad { color: #d32f2f; }
@@ -15,9 +15,87 @@ document.addEventListener('DOMContentLoaded', function () {
ogDesc: document.getElementById('jform_mokoog_og_description'),
ogImage: document.getElementById('jform_mokoog_og_image'),
articleTitle: document.getElementById('jform_title'),
metaDesc: document.getElementById('jform_metadesc')
metaDesc: document.getElementById('jform_metadesc'),
seoTitle: document.getElementById('jform_mokoog_seo_title'),
metaDescription: document.getElementById('jform_mokoog_meta_description')
};
// Character count indicators
var charLimits = [
{ field: fields.ogTitle, optimal: 60, max: 90 },
{ field: fields.ogDesc, optimal: 155, max: 200 },
{ field: fields.seoTitle, optimal: 60, max: 70 },
{ field: fields.metaDescription, optimal: 155, max: 160 }
];
charLimits.forEach(function (cfg) {
if (!cfg.field) return;
var counter = document.createElement('span');
counter.className = 'mokoog-char-count';
cfg.field.parentNode.appendChild(counter);
function refresh() {
var len = cfg.field.value.length;
counter.textContent = len + '/' + cfg.optimal;
if (len > cfg.max) {
counter.className = 'mokoog-char-count mokoog-char-over';
} else if (len > cfg.optimal) {
counter.className = 'mokoog-char-count mokoog-char-warn';
} else {
counter.className = 'mokoog-char-count mokoog-char-ok';
}
}
cfg.field.addEventListener('input', refresh);
cfg.field.addEventListener('change', refresh);
refresh();
});
// AI Generate buttons
['ogTitle', 'ogDesc'].forEach(function(fieldKey) {
var field = fields[fieldKey];
if (!field) return;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-primary mokoog-ai-btn';
btn.textContent = 'Generate with AI';
btn.dataset.target = fieldKey;
field.parentNode.appendChild(btn);
btn.addEventListener('click', function() {
var articleTitle = fields.articleTitle ? fields.articleTitle.value : '';
btn.disabled = true;
btn.textContent = 'Generating...';
var formData = new FormData();
formData.append('task', 'mokoog.aiGenerate');
formData.append('field', fieldKey === 'ogTitle' ? 'title' : 'description');
formData.append('article_title', articleTitle);
formData.append(Joomla.getOptions('csrf.token'), 1);
fetch(window.location.origin + '/administrator/index.php?option=com_ajax&plugin=mokoog&group=system&format=json', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.data && data.data[0]) {
field.value = data.data[0];
field.dispatchEvent(new Event('input'));
}
btn.disabled = false;
btn.textContent = 'Generate with AI';
})
.catch(function() {
btn.disabled = false;
btn.textContent = 'Generate with AI';
});
});
});
// Find the mokoog fieldset and insert preview after it
var fieldset = document.querySelector('[data-showon-id="mokoog"]') ||
document.getElementById('attrib-mokoog') ||
@@ -110,6 +188,137 @@ document.addEventListener('DOMContentLoaded', function () {
twCard.appendChild(twBody);
wrapper.appendChild(twCard);
// LinkedIn preview card
var liLabel = document.createElement('small');
liLabel.className = 'mokoog-platform-label';
liLabel.textContent = 'LinkedIn';
wrapper.appendChild(liLabel);
var liCard = document.createElement('div');
liCard.className = 'mokoog-card mokoog-card-li';
var liImg = document.createElement('div');
liImg.id = 'mokoog-li-img';
liImg.className = 'mokoog-card-img';
liCard.appendChild(liImg);
var liBody = document.createElement('div');
liBody.className = 'mokoog-card-body';
var liTitle = document.createElement('div');
liTitle.id = 'mokoog-li-title';
liTitle.className = 'mokoog-card-title';
liBody.appendChild(liTitle);
var liDomain = document.createElement('div');
liDomain.id = 'mokoog-li-domain';
liDomain.className = 'mokoog-card-domain';
liBody.appendChild(liDomain);
liCard.appendChild(liBody);
wrapper.appendChild(liCard);
// Discord preview card
var dcLabel = document.createElement('small');
dcLabel.className = 'mokoog-platform-label';
dcLabel.textContent = 'Discord';
wrapper.appendChild(dcLabel);
var dcCard = document.createElement('div');
dcCard.className = 'mokoog-card mokoog-card-dc';
var dcBody = document.createElement('div');
dcBody.className = 'mokoog-card-body';
var dcTitle = document.createElement('div');
dcTitle.id = 'mokoog-dc-title';
dcTitle.className = 'mokoog-card-title';
dcBody.appendChild(dcTitle);
var dcDesc = document.createElement('div');
dcDesc.id = 'mokoog-dc-desc';
dcDesc.className = 'mokoog-card-desc';
dcBody.appendChild(dcDesc);
var dcDomain = document.createElement('div');
dcDomain.id = 'mokoog-dc-domain';
dcDomain.className = 'mokoog-card-domain';
dcBody.appendChild(dcDomain);
dcCard.appendChild(dcBody);
var dcImg = document.createElement('div');
dcImg.id = 'mokoog-dc-img';
dcImg.className = 'mokoog-card-img';
dcCard.appendChild(dcImg);
wrapper.appendChild(dcCard);
// Mastodon preview card
var maLabel = document.createElement('small');
maLabel.className = 'mokoog-platform-label';
maLabel.textContent = 'Mastodon';
wrapper.appendChild(maLabel);
var maCard = document.createElement('div');
maCard.className = 'mokoog-card mokoog-card-ma';
var maImg = document.createElement('div');
maImg.id = 'mokoog-ma-img';
maImg.className = 'mokoog-card-img';
maCard.appendChild(maImg);
var maBody = document.createElement('div');
maBody.className = 'mokoog-card-body';
var maTitle = document.createElement('div');
maTitle.id = 'mokoog-ma-title';
maTitle.className = 'mokoog-card-title';
maBody.appendChild(maTitle);
var maDesc = document.createElement('div');
maDesc.id = 'mokoog-ma-desc';
maDesc.className = 'mokoog-card-desc';
maBody.appendChild(maDesc);
var maDomain = document.createElement('div');
maDomain.id = 'mokoog-ma-domain';
maDomain.className = 'mokoog-card-domain';
maBody.appendChild(maDomain);
maCard.appendChild(maBody);
wrapper.appendChild(maCard);
// Slack preview card
var slLabel = document.createElement('small');
slLabel.className = 'mokoog-platform-label';
slLabel.textContent = 'Slack';
wrapper.appendChild(slLabel);
var slCard = document.createElement('div');
slCard.className = 'mokoog-card mokoog-card-sl';
var slBody = document.createElement('div');
slBody.className = 'mokoog-card-body';
var slTitle = document.createElement('div');
slTitle.id = 'mokoog-sl-title';
slTitle.className = 'mokoog-card-title';
slBody.appendChild(slTitle);
var slDesc = document.createElement('div');
slDesc.id = 'mokoog-sl-desc';
slDesc.className = 'mokoog-card-desc';
slBody.appendChild(slDesc);
var slDomain = document.createElement('div');
slDomain.id = 'mokoog-sl-domain';
slDomain.className = 'mokoog-card-domain';
slBody.appendChild(slDomain);
slCard.appendChild(slBody);
wrapper.appendChild(slCard);
preview.appendChild(wrapper);
fieldset.parentNode.insertBefore(preview, fieldset.nextSibling);
@@ -152,19 +361,139 @@ document.addEventListener('DOMContentLoaded', function () {
} else {
twImgEl.style.display = 'none';
}
// LinkedIn (shorter truncation: title 70, no description shown in card)
var liTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
document.getElementById('mokoog-li-title').textContent = liTitle;
document.getElementById('mokoog-li-domain').textContent = domain;
var liImgEl = document.getElementById('mokoog-li-img');
if (img) {
liImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
liImgEl.style.display = '';
} else {
liImgEl.style.display = 'none';
}
// Discord (title 256, desc 350)
var dcTitle = title.length > 256 ? title.substring(0, 253) + '...' : title;
var dcDesc = desc.length > 350 ? desc.substring(0, 347) + '...' : desc;
document.getElementById('mokoog-dc-title').textContent = dcTitle;
document.getElementById('mokoog-dc-desc').textContent = dcDesc;
document.getElementById('mokoog-dc-domain').textContent = domain;
var dcImgEl = document.getElementById('mokoog-dc-img');
if (img) {
dcImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
dcImgEl.style.display = '';
} else {
dcImgEl.style.display = 'none';
}
// Mastodon (title 70, desc 200)
var maTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
var maDesc = desc.length > 200 ? desc.substring(0, 197) + '...' : desc;
document.getElementById('mokoog-ma-title').textContent = maTitle;
document.getElementById('mokoog-ma-desc').textContent = maDesc;
document.getElementById('mokoog-ma-domain').textContent = domain;
var maImgEl = document.getElementById('mokoog-ma-img');
if (img) {
maImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
maImgEl.style.display = '';
} else {
maImgEl.style.display = 'none';
}
// Slack (title 70, desc 150, no image)
var slTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
var slDesc = desc.length > 150 ? desc.substring(0, 147) + '...' : desc;
document.getElementById('mokoog-sl-title').textContent = slTitle;
document.getElementById('mokoog-sl-desc').textContent = slDesc;
document.getElementById('mokoog-sl-domain').textContent = domain;
}
// SEO scoring panel
var seoChecks = [
{ id: 'og-title', label: 'OG Title', check: function() { return fields.ogTitle && fields.ogTitle.value.length > 0; }},
{ id: 'og-desc', label: 'OG Description', check: function() { return fields.ogDesc && fields.ogDesc.value.length > 0; }},
{ id: 'og-image', label: 'OG Image', check: function() { return fields.ogImage && fields.ogImage.value.length > 0; }},
{ id: 'seo-title', label: 'SEO Title', check: function() { return fields.seoTitle && fields.seoTitle.value.length > 0; }},
{ id: 'meta-desc', label: 'Meta Description', check: function() { return fields.metaDescription && fields.metaDescription.value.length > 0; }},
{ id: 'title-length', label: 'Title Length (\u226460)', check: function() {
var t = (fields.ogTitle && fields.ogTitle.value) || (fields.articleTitle && fields.articleTitle.value) || '';
return t.length > 0 && t.length <= 60;
}},
{ id: 'desc-length', label: 'Description Length (\u2264160)', check: function() {
var d = (fields.ogDesc && fields.ogDesc.value) || (fields.metaDesc && fields.metaDesc.value) || '';
return d.length > 0 && d.length <= 160;
}}
];
var seoPanel = document.createElement('div');
seoPanel.className = 'mokoog-seo-score';
var seoHeading = document.createElement('h4');
seoHeading.className = 'mokoog-seo-heading';
seoHeading.textContent = 'SEO Analysis';
seoPanel.appendChild(seoHeading);
var seoList = document.createElement('ul');
seoList.className = 'mokoog-seo-list';
var seoDots = {};
seoChecks.forEach(function (chk) {
var li = document.createElement('li');
li.className = 'mokoog-seo-item';
var dot = document.createElement('span');
dot.className = 'mokoog-seo-dot mokoog-seo-fail';
seoDots[chk.id] = dot;
li.appendChild(dot);
var label = document.createElement('span');
label.textContent = chk.label;
li.appendChild(label);
seoList.appendChild(li);
});
seoPanel.appendChild(seoList);
var seoTotal = document.createElement('div');
seoTotal.className = 'mokoog-seo-total';
seoPanel.appendChild(seoTotal);
wrapper.parentNode.insertBefore(seoPanel, wrapper.nextSibling);
function updateSeoScore() {
var passed = 0;
seoChecks.forEach(function (chk) {
var ok = chk.check();
if (ok) passed++;
seoDots[chk.id].className = 'mokoog-seo-dot ' + (ok ? 'mokoog-seo-pass' : 'mokoog-seo-fail');
});
seoTotal.textContent = passed + '/' + seoChecks.length + ' checks passed';
if (passed === seoChecks.length) {
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-good';
} else if (passed >= Math.ceil(seoChecks.length / 2)) {
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-ok';
} else {
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-bad';
}
}
Object.values(fields).forEach(function (el) {
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
el.addEventListener('input', function () { updatePreview(); updateSeoScore(); });
el.addEventListener('change', function () { updatePreview(); updateSeoScore(); });
}
});
if (fields.ogImage) {
var observer = new MutationObserver(updatePreview);
var observer = new MutationObserver(function () { updatePreview(); updateSeoScore(); });
observer.observe(fields.ogImage, { attributes: true, attributeFilter: ['value'] });
}
updatePreview();
updateSeoScore();
});
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoJoomOpenGraph</name>
<version>01.03.01</version>
<version>01.04.15</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -98,7 +98,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$ogData = $this->loadOgData($contentType, $id, $language);
if ($ogData) {
$form->bind(['mokoog' => (array) $ogData]);
$bindData = (array) $ogData;
// Unpack JSON blob fields into individual form fields
foreach (['event_data', 'recipe_data'] as $jsonField) {
if (!empty($bindData[$jsonField])) {
$decoded = json_decode($bindData[$jsonField], true);
if (\is_array($decoded)) {
foreach ($decoded as $key => $value) {
$bindData[$key] = $value;
}
}
}
unset($bindData[$jsonField]);
}
$form->bind(['mokoog' => $bindData]);
}
}
}
@@ -194,7 +211,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'og_title', 'og_description', 'og_image', 'og_type',
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
'event_data', 'recipe_data', 'custom_schema',
'seo_title', 'meta_description', 'robots', 'canonical_url',
]))
->from($db->quoteName('#__mokoog_tags'))
@@ -245,14 +263,18 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
'content_type' => $contentType,
'content_id' => $contentId,
'language' => $language,
'og_title' => trim($ogData['og_title'] ?? ''),
'og_description' => trim($ogData['og_description'] ?? ''),
'og_title' => strip_tags(trim($ogData['og_title'] ?? '')),
'og_description' => strip_tags(trim($ogData['og_description'] ?? '')),
'og_image' => trim($ogData['og_image'] ?? ''),
'og_type' => trim($ogData['og_type'] ?? 'article'),
'seo_title' => trim($ogData['seo_title'] ?? ''),
'meta_description' => trim($ogData['meta_description'] ?? ''),
'og_video' => $this->sanitizeUrl($ogData['og_video'] ?? ''),
'event_data' => $this->packJsonFields($ogData, ['event_start', 'event_end', 'event_location', 'event_address', 'event_price', 'event_currency', 'event_url']),
'recipe_data' => $this->packJsonFields($ogData, ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine']),
'custom_schema' => $this->validateJson($ogData['custom_schema'] ?? ''),
'seo_title' => strip_tags(trim($ogData['seo_title'] ?? '')),
'meta_description' => strip_tags(trim($ogData['meta_description'] ?? '')),
'robots' => trim($robots),
'canonical_url' => trim($ogData['canonical_url'] ?? ''),
'canonical_url' => $this->sanitizeUrl($ogData['canonical_url'] ?? ''),
'published' => 1,
'modified' => Factory::getDate()->toSql(),
];
@@ -266,6 +288,69 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
}
}
/**
* Pack form fields into a JSON string for storage.
*
* @param array $ogData Form data array
* @param array $fields Field names to pack
*
* @return string JSON string or empty
*/
private function packJsonFields(array $ogData, array $fields): string
{
$data = [];
foreach ($fields as $field) {
$val = trim($ogData[$field] ?? '');
if ($val !== '') {
$data[$field] = $val;
}
}
return !empty($data) ? json_encode($data) : '';
}
/**
* Validate a JSON string — returns trimmed JSON or empty string if invalid.
*
* @param string $json Raw JSON input
*
* @return string
*/
private function validateJson(string $json): string
{
$json = trim($json);
if ($json === '' || json_decode($json) === null) {
return '';
}
return $json;
}
/**
* Sanitize a URL to only allow http/https schemes.
*
* @param string $url Raw URL value
*
* @return string Sanitized URL or empty string
*/
private function sanitizeUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
return '';
}
return $url;
}
/**
* Extract the language tag from content data.
*
@@ -23,6 +23,8 @@ PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR="Fediverse Creator"
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC="Your Fediverse/Mastodon handle (e.g. @user@mastodon.social). Outputs a fediverse:creator meta tag for author attribution on Mastodon and other Fediverse platforms."
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
@@ -35,5 +37,59 @@ PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS="Local Business"
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED="Enable LocalBusiness Schema"
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC="Output LocalBusiness JSON-LD structured data on all pages."
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME="Business Name"
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC="Your business name for structured data."
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE="Business Type"
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC="Schema.org business type."
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET="Street Address"
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC="Street address of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY="City"
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC="City of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION="State/Region"
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC="State or region of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL="Postal Code"
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC="Postal/ZIP code of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY="Country"
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC="Country code (e.g. US, GB, DE)."
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE="Phone"
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC="Business phone number."
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL="Email"
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC="Business email address."
PLG_SYSTEM_MOKOOG_FIELD_LB_URL="Website URL"
PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC="Business website URL."
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS="Opening Hours"
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC="Opening hours in schema.org format (e.g. Mo-Fr 09:00-17:00)."
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE="Latitude"
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC="Geographic latitude of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
@@ -23,6 +23,8 @@ PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR="Fediverse Creator"
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC="Your Fediverse/Mastodon handle (e.g. @user@mastodon.social). Outputs a fediverse:creator meta tag for author attribution on Mastodon and other Fediverse platforms."
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
@@ -35,5 +37,59 @@ PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS="Local Business"
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED="Enable LocalBusiness Schema"
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC="Output LocalBusiness JSON-LD structured data on all pages."
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME="Business Name"
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC="Your business name for structured data."
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE="Business Type"
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC="Schema.org business type."
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET="Street Address"
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC="Street address of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY="City"
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC="City of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION="State/Region"
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC="State or region of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL="Postal Code"
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC="Postal/ZIP code of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY="Country"
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC="Country code (e.g. US, GB, DE)."
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE="Phone"
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC="Business phone number."
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL="Email"
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC="Business email address."
PLG_SYSTEM_MOKOOG_FIELD_LB_URL="Website URL"
PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC="Business website URL."
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS="Opening Hours"
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC="Opening hours in schema.org format (e.g. Mo-Fr 09:00-17:00)."
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE="Latitude"
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC="Geographic latitude of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
+194 -1
View File
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoJoomOpenGraph</name>
<version>01.03.01</version>
<version>01.04.15</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -106,6 +106,14 @@
description="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC"
default=""
/>
<field
name="fediverse_creator"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR"
description="PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC"
default=""
filter="string"
/>
</fieldset>
<fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED">
<field
@@ -150,6 +158,17 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="platform_resize"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE"
description="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_enabled"
type="radio"
@@ -161,6 +180,28 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_faq"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ"
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_howto"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO"
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_breadcrumbs"
type="radio"
@@ -173,6 +214,158 @@
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="localbusiness" label="PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS">
<field
name="lb_enabled"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="lb_name"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_NAME"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC"
default=""
filter="string"
/>
<field
name="lb_type"
type="list"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC"
default="LocalBusiness"
>
<option value="LocalBusiness">LocalBusiness</option>
<option value="Restaurant">Restaurant</option>
<option value="Store">Store</option>
<option value="MedicalBusiness">MedicalBusiness</option>
<option value="LegalService">LegalService</option>
<option value="FinancialService">FinancialService</option>
<option value="EducationalOrganization">EducationalOrganization</option>
</field>
<field
name="lb_street"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_STREET"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC"
default=""
filter="string"
/>
<field
name="lb_city"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_CITY"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC"
default=""
filter="string"
/>
<field
name="lb_region"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_REGION"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC"
default=""
filter="string"
/>
<field
name="lb_postal"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC"
default=""
filter="string"
/>
<field
name="lb_country"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC"
default="US"
filter="string"
/>
<field
name="lb_phone"
type="tel"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC"
default=""
/>
<field
name="lb_email"
type="email"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC"
default=""
/>
<field
name="lb_url"
type="url"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_URL"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC"
default=""
/>
<field
name="lb_opening_hours"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC"
default=""
filter="string"
/>
<field
name="lb_latitude"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC"
default=""
filter="string"
/>
<field
name="lb_longitude"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC"
default=""
filter="string"
/>
<field
name="lb_price_range"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE"
description="PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC"
default=""
filter="string"
/>
</fieldset>
<fieldset name="sitemap" label="PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP">
<field name="sitemap_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC" default="0" class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sitemap_changefreq" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC" default="weekly">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</field>
</fieldset>
<fieldset name="ai" label="PLG_SYSTEM_MOKOOG_FIELDSET_AI">
<field name="ai_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC" default="0" class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="ai_provider" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER" description="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC" default="claude">
<option value="claude">Claude (Anthropic)</option>
<option value="openai">OpenAI</option>
</field>
<field name="ai_api_key" type="password" label="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY" description="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC" filter="string" />
<field name="ai_model" type="text" label="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL" description="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC" default="claude-haiku-4-5-20251001" filter="string" />
</fieldset>
</fields>
</config>
</extension>
@@ -19,6 +19,7 @@ use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
use Joomla\Plugin\System\MokoOG\Helper\SitemapBuilder;
final class MokoOG extends CMSPlugin implements SubscriberInterface
{
@@ -37,6 +38,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return [
'onAfterRoute' => 'onAfterRoute',
'onBeforeCompileHead' => 'onBeforeCompileHead',
'onContentAfterSave' => 'onContentAfterSaveRebuildSitemap',
'onAjaxMokoog' => 'onAjaxMokoog',
];
}
@@ -92,7 +95,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if ($catOg) {
// Merge: category fills any gaps in the content-level data
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
if (empty($ogData->$field) && !empty($catOg->$field)) {
$ogData->$field = $catOg->$field;
}
@@ -156,7 +159,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->setMetaData('twitter:description', $description);
if ($image) {
$doc->setMetaData('twitter:image', $this->resolveImageUrl($image));
$twitterImage = ($this->params->get('auto_resize', 1) && $this->params->get('platform_resize', 0))
? ImageHelper::resizeForPlatform($image, 'twitter')
: $image;
$doc->setMetaData('twitter:image', $this->resolveImageUrl($twitterImage));
}
if ($twitterSite) {
@@ -177,6 +183,38 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->setMetaData('theme-color', $discordColor);
}
// Fediverse/Mastodon creator attribution
$fediverseCreator = $this->params->get('fediverse_creator', '');
if ($fediverseCreator) {
$doc->setMetaData('fediverse:creator', $fediverseCreator);
}
// og:video tags
$videoUrl = $ogData->og_video ?? '';
if ($videoUrl) {
$doc->setMetaData('og:video', $videoUrl, 'property');
if (str_starts_with($videoUrl, 'https://')) {
$doc->setMetaData('og:video:secure_url', $videoUrl, 'property');
}
// Detect video type from URL — embeds vs direct files
$isEmbed = str_contains($videoUrl, 'youtube.com') || str_contains($videoUrl, 'youtu.be')
|| str_contains($videoUrl, 'vimeo.com');
if ($isEmbed) {
$doc->setMetaData('og:video:type', 'text/html', 'property');
} else {
$ext = strtolower(pathinfo(parse_url($videoUrl, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
$mimeMap = ['mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg'];
$doc->setMetaData('og:video:type', $mimeMap[$ext] ?? 'video/mp4', 'property');
$doc->setMetaData('og:video:width', '1280', 'property');
$doc->setMetaData('og:video:height', '720', 'property');
}
}
// LinkedIn article tags
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property');
@@ -189,13 +227,34 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
}
// MokoSuiteShop product meta tags
// MokoSuiteShop product meta tags (pricing + Pinterest availability)
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
$productData = $this->loadShopProduct($id);
if ($productData) {
$doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property');
$doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property');
$availability = ((int) ($productData->stock_qty ?? 0) > 0) ? 'instock' : 'outofstock';
$doc->setMetaData('product:availability', $availability, 'property');
}
}
// Pinterest article:tag rich pins (from Joomla content tags)
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$db = Factory::getDbo();
$tagQuery = $db->getQuery(true)
->select($db->quoteName('t.title'))
->from($db->quoteName('#__tags', 't'))
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
->where($db->quoteName('m.content_item_id') . ' = ' . $id)
->where($db->quoteName('t.published') . ' = 1');
$db->setQuery($tagQuery);
$tags = $db->loadColumn();
foreach ($tags as $tag) {
$doc->setMetaData('article:tag', $tag, 'property');
}
}
@@ -229,6 +288,85 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->addCustomTag(JsonLdBuilder::toScriptTag($schema));
}
if (!empty($ogData->og_video)) {
$videoSchema = JsonLdBuilder::buildVideo($ogData->og_video, $title, $description, $imageUrl);
if ($videoSchema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($videoSchema));
}
}
// FAQ schema (auto-detected from article headings)
if ($this->params->get('jsonld_faq', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
$faqItems = $this->extractFaqFromContent($id);
if (!empty($faqItems)) {
$faqSchema = JsonLdBuilder::buildFaq($faqItems);
if ($faqSchema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($faqSchema));
}
}
}
// HowTo schema (auto-detected from ordered lists)
if ($this->params->get('jsonld_howto', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
$howToSteps = $this->extractHowToFromContent($id);
if (!empty($howToSteps)) {
$howToSchema = JsonLdBuilder::buildHowTo($title, $howToSteps, $imageUrl);
if ($howToSchema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($howToSchema));
}
}
}
// Event JSON-LD from per-article event data
$eventJson = $ogData->event_data ?? '';
if (!empty($eventJson)) {
$eventObj = json_decode($eventJson);
if ($eventObj && !empty($eventObj->event_start)) {
$eventSchema = JsonLdBuilder::buildEvent($title, $description, $imageUrl, $eventObj);
if ($eventSchema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($eventSchema));
}
}
}
// Recipe JSON-LD from per-article recipe data
$recipeJson = $ogData->recipe_data ?? '';
if (!empty($recipeJson)) {
$recipeObj = json_decode($recipeJson);
if ($recipeObj) {
$recipeSchema = JsonLdBuilder::buildRecipe($title, $description, $imageUrl, $recipeObj);
if ($recipeSchema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($recipeSchema));
}
}
}
// Custom JSON-LD schema (user-provided)
$customSchema = $ogData->custom_schema ?? '';
if (!empty($customSchema)) {
$decoded = json_decode($customSchema, true);
if ($decoded) {
if (empty($decoded['@context'])) {
$decoded['@context'] = 'https://schema.org';
}
$doc->addCustomTag(JsonLdBuilder::toScriptTag($decoded));
}
}
if ($this->params->get('jsonld_breadcrumbs', 1)) {
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
@@ -237,6 +375,15 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
}
}
// LocalBusiness JSON-LD
if ($this->params->get('lb_enabled', 0)) {
$lbSchema = JsonLdBuilder::buildLocalBusiness($this->params);
if ($lbSchema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($lbSchema));
}
}
}
/**
@@ -299,6 +446,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
'og_description' => '',
'og_image' => '',
'og_type' => '',
'og_video' => '',
'event_data' => '',
'recipe_data' => '',
'custom_schema' => '',
'seo_title' => '',
'meta_description' => '',
'robots' => '',
@@ -569,6 +720,226 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return $article->author_name ?? '';
}
/**
* Extract FAQ question/answer pairs from article content.
*
* @param int $articleId Article ID
*
* @return array Array of ['question' => '...', 'answer' => '...'] pairs
*/
private function extractFaqFromContent(int $articleId): array
{
$article = $this->loadArticle($articleId);
if (!$article) {
return [];
}
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
if (trim($content) === '') {
return [];
}
$faqItems = [];
if (preg_match_all('/<h[34][^>]*>(.*?)<\/h[34]>\s*((?:<p[^>]*>.*?<\/p>\s*)+)/si', $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$question = trim(strip_tags($match[1]));
$answer = trim(strip_tags($match[2]));
if ($question !== '' && $answer !== '') {
$faqItems[] = [
'question' => $question,
'answer' => $answer,
];
}
}
}
return $faqItems;
}
/**
* Extract HowTo steps from ordered lists in article content.
*
* @param int $articleId Article ID
*
* @return array Array of ['name' => '...', 'text' => '...'] pairs
*/
private function extractHowToFromContent(int $articleId): array
{
$article = $this->loadArticle($articleId);
if (!$article) {
return [];
}
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
if (!preg_match('/<ol[^>]*>(.*?)<\/ol>/si', $content, $olMatch)) {
return [];
}
if (!preg_match_all('/<li[^>]*>(.*?)<\/li>/si', $olMatch[1], $liMatches)) {
return [];
}
$steps = [];
foreach ($liMatches[1] as $liHtml) {
$text = trim(strip_tags($liHtml));
if ($text === '') {
continue;
}
$name = $text;
if (preg_match('/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/si', $liHtml, $boldMatch)) {
$name = trim(strip_tags($boldMatch[1]));
} elseif (preg_match('/^([^.!?]+[.!?])/', $text, $sentenceMatch)) {
$name = trim($sentenceMatch[1]);
}
$steps[] = [
'name' => $name,
'text' => $text,
];
}
return $steps;
}
/**
* Rebuild sitemap.xml when article content is saved.
*
* @param Event $event The event
*
* @return void
*/
public function onContentAfterSaveRebuildSitemap(Event $event): void
{
if (!$this->params->get('sitemap_enabled', 0)) {
return;
}
[$context] = array_values($event->getArguments());
if ($context !== 'com_content.article') {
return;
}
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
$xml = SitemapBuilder::generate($changefreq);
if (!SitemapBuilder::writeToFile($xml)) {
\Joomla\CMS\Log\Log::add('MokoOG: Failed to write sitemap.xml — check file permissions', \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
/**
* Handle AJAX requests for AI meta tag generation.
*
* @param Event $event The event
*
* @return void
*/
public function onAjaxMokoog(Event $event): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator')) {
return;
}
if (!\Joomla\CMS\Session\Session::checkToken()) {
$event->setArgument('result', ['Invalid Token']);
return;
}
if (!$this->params->get('ai_enabled', 0)) {
$event->setArgument('result', ['AI generation is not enabled']);
return;
}
$apiKey = $this->params->get('ai_api_key', '');
$provider = $this->params->get('ai_provider', 'claude');
$model = $this->params->get('ai_model', 'claude-haiku-4-5-20251001');
if (empty($apiKey)) {
$event->setArgument('result', ['API key not configured']);
return;
}
$input = $app->getInput();
$field = $input->getString('field', 'title');
$articleTitle = mb_substr(strip_tags($input->getString('article_title', '')), 0, 200);
$prompt = $field === 'title'
? "Generate a concise, engaging social media sharing title (max 60 characters) for an article titled: \"$articleTitle\". Return only the title text, no quotes or explanation."
: "Generate a compelling social media sharing description (max 155 characters) for an article titled: \"$articleTitle\". Return only the description text, no quotes or explanation.";
try {
$result = $this->callAiApi($provider, $apiKey, $model, $prompt);
$event->setArgument('result', [$result]);
} catch (\Exception $e) {
$event->setArgument('result', ['Error: ' . $e->getMessage()]);
}
}
/**
* Call an AI API (Claude or OpenAI) with a prompt.
*
* @param string $provider Provider name (claude or openai)
* @param string $apiKey API key
* @param string $model Model name
* @param string $prompt Prompt text
*
* @return string Generated text
*/
private function callAiApi(string $provider, string $apiKey, string $model, string $prompt): string
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
if ($provider === 'claude') {
$response = $http->post(
'https://api.anthropic.com/v1/messages',
json_encode([
'model' => $model,
'max_tokens' => 200,
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
[
'Content-Type' => 'application/json',
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
]
);
$data = json_decode($response->body, true);
return trim($data['content'][0]['text'] ?? '');
}
$response = $http->post(
'https://api.openai.com/v1/chat/completions',
json_encode([
'model' => $model,
'max_tokens' => 200,
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
[
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey,
]
);
$data = json_decode($response->body, true);
return trim($data['choices'][0]['message']['content'] ?? '');
}
/**
* Warn administrators once per session when no license key is configured.
*
@@ -149,6 +149,137 @@ class ImageHelper
return $outputRel;
}
/**
* Resize an image for a specific platform.
*
* @param string $imagePath Relative image path
* @param string $platform Platform name (facebook, twitter, pinterest, whatsapp)
*
* @return string Path to the resized image
*/
public static function resizeForPlatform(string $imagePath, string $platform): string
{
$sizes = [
'facebook' => ['width' => 1200, 'height' => 630],
'twitter' => ['width' => 1200, 'height' => 600],
'pinterest' => ['width' => 1000, 'height' => 1500],
'whatsapp' => ['width' => 400, 'height' => 400],
];
if (!isset($sizes[$platform])) {
return self::resize($imagePath);
}
$size = $sizes[$platform];
return self::resizeToSize($imagePath, $size['width'], $size['height'], $platform);
}
/**
* Resize an image to specific dimensions with a platform-specific subdirectory.
*
* @param string $imagePath Image path relative to JPATH_ROOT
* @param int $width Target width
* @param int $height Target height
* @param string $subdir Subdirectory name for output (e.g. platform name)
*
* @return string Path to the output image (relative to JPATH_ROOT)
*/
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): string
{
// Resolve absolute path
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
if (!is_file($absPath)) {
return $imagePath;
}
$imageInfo = getimagesize($absPath);
if (!$imageInfo) {
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
return $imagePath;
}
[$origWidth, $origHeight, $type] = $imageInfo;
// Skip if already at or below target size
if ($origWidth <= $width && $origHeight <= $height) {
return $imagePath;
}
// Build output directory with optional subdirectory
$outputRelDir = self::OUTPUT_DIR . ($subdir ? '/' . $subdir : '');
$outputDir = JPATH_ROOT . '/' . $outputRelDir;
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . $outputRelDir, Log::WARNING, 'mokoog');
return $imagePath;
}
// Generate output filename based on source hash + dimensions
$hash = md5($imagePath . $width . $height);
$outputName = $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = $outputRelDir . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
return $outputRel;
}
// Load source image
$source = self::loadImage($absPath, $type);
if (!$source) {
return $imagePath;
}
// Calculate crop dimensions (center crop to target aspect ratio)
$targetRatio = $width / $height;
$sourceRatio = $origWidth / $origHeight;
if ($sourceRatio > $targetRatio) {
// Source is wider — crop sides
$cropHeight = $origHeight;
$cropWidth = (int) round($origHeight * $targetRatio);
$cropX = (int) round(($origWidth - $cropWidth) / 2);
$cropY = 0;
} else {
// Source is taller — crop top/bottom
$cropWidth = $origWidth;
$cropHeight = (int) round($origWidth / $targetRatio);
$cropX = 0;
$cropY = (int) round(($origHeight - $cropHeight) / 2);
}
// Create output canvas and resample
$output = imagecreatetruecolor($width, $height);
imagecopyresampled(
$output,
$source,
0,
0,
$cropX,
$cropY,
$width,
$height,
$cropWidth,
$cropHeight
);
// Save as JPEG
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
imagedestroy($source);
imagedestroy($output);
return $outputRel;
}
/**
* Remove a generated image file.
*
@@ -248,6 +248,413 @@ class JsonLdBuilder
return $schema;
}
/**
* Build VideoObject schema for pages with a video URL.
*
* @param string $videoUrl Video URL (e.g. YouTube, Vimeo, or direct)
* @param string $title Video title
* @param string $description Video description
* @param string $imageUrl Thumbnail image URL (absolute)
*
* @return array|null
*/
public static function buildVideo(string $videoUrl, string $title, string $description, string $imageUrl): ?array
{
if (empty($videoUrl)) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'VideoObject',
'name' => $title,
'description' => $description,
'thumbnailUrl' => $imageUrl,
'contentUrl' => $videoUrl,
'uploadDate' => Factory::getDate()->toISO8601(),
];
// Add embedUrl for YouTube and Vimeo
if (preg_match('/youtube\.com|youtu\.be|vimeo\.com/i', $videoUrl)) {
$schema['embedUrl'] = $videoUrl;
}
return $schema;
}
/**
* Build LocalBusiness schema from plugin parameters.
*
* @param object $params Plugin parameters object
*
* @return array|null
*/
public static function buildLocalBusiness(object $params): ?array
{
$name = trim((string) $params->get('lb_name', ''));
if ($name === '') {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => $params->get('lb_type', 'LocalBusiness'),
'name' => $name,
];
// Build PostalAddress
$address = [];
$street = trim((string) $params->get('lb_street', ''));
$city = trim((string) $params->get('lb_city', ''));
$region = trim((string) $params->get('lb_region', ''));
$postal = trim((string) $params->get('lb_postal', ''));
$country = trim((string) $params->get('lb_country', ''));
if ($street !== '') {
$address['streetAddress'] = $street;
}
if ($city !== '') {
$address['addressLocality'] = $city;
}
if ($region !== '') {
$address['addressRegion'] = $region;
}
if ($postal !== '') {
$address['postalCode'] = $postal;
}
if ($country !== '') {
$address['addressCountry'] = $country;
}
if (!empty($address)) {
$address['@type'] = 'PostalAddress';
$schema['address'] = $address;
}
// Contact properties
$phone = trim((string) $params->get('lb_phone', ''));
$email = trim((string) $params->get('lb_email', ''));
$url = trim((string) $params->get('lb_url', ''));
if ($phone !== '') {
$schema['telephone'] = $phone;
}
if ($email !== '') {
$schema['email'] = $email;
}
if ($url !== '') {
$schema['url'] = $url;
}
// Opening hours
$openingHours = trim((string) $params->get('lb_opening_hours', ''));
if ($openingHours !== '') {
$schema['openingHours'] = $openingHours;
}
// GeoCoordinates
$latitude = trim((string) $params->get('lb_latitude', ''));
$longitude = trim((string) $params->get('lb_longitude', ''));
if ($latitude !== '' && $longitude !== '') {
$schema['geo'] = [
'@type' => 'GeoCoordinates',
'latitude' => $latitude,
'longitude' => $longitude,
];
}
// Price range
$priceRange = trim((string) $params->get('lb_price_range', ''));
if ($priceRange !== '') {
$schema['priceRange'] = $priceRange;
}
return $schema;
}
/**
* Build FAQPage schema from question/answer pairs.
*
* @param array $questions Array of ['question' => '...', 'answer' => '...'] pairs
*
* @return array|null
*/
public static function buildFaq(array $questions): ?array
{
if (empty($questions)) {
return null;
}
$mainEntity = [];
foreach ($questions as $item) {
$question = trim($item['question'] ?? '');
$answer = trim($item['answer'] ?? '');
if ($question === '' || $answer === '') {
continue;
}
$mainEntity[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $answer,
],
];
}
if (empty($mainEntity)) {
return null;
}
return [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $mainEntity,
];
}
/**
* Build HowTo schema from step-by-step instructions.
*
* @param string $title HowTo title
* @param array $steps Array of ['name' => 'Step title', 'text' => 'Step instructions']
* @param string $imageUrl Optional image URL (absolute)
*
* @return array|null
*/
public static function buildHowTo(string $title, array $steps, string $imageUrl = ''): ?array
{
if (empty($steps)) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'HowTo',
'name' => $title,
];
if (!empty($imageUrl)) {
$schema['image'] = $imageUrl;
}
$schema['step'] = [];
foreach ($steps as $step) {
$schema['step'][] = [
'@type' => 'HowToStep',
'name' => $step['name'],
'text' => $step['text'],
];
}
return $schema;
}
/**
* Build Event schema from per-article event data.
*
* @param string $title Event/article title
* @param string $description Event description
* @param string $imageUrl Image URL (absolute)
* @param object $eventData Decoded event_data with event_start, event_end, etc.
*
* @return array|null
*/
public static function buildEvent(string $title, string $description, string $imageUrl, object $eventData): ?array
{
$startDate = $eventData->event_start ?? '';
if (empty($startDate)) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Event',
'name' => $title,
'description' => $description,
'startDate' => $startDate,
'url' => Uri::getInstance()->toString(),
];
$endDate = $eventData->event_end ?? '';
if (!empty($endDate)) {
$schema['endDate'] = $endDate;
}
if (!empty($imageUrl)) {
$schema['image'] = $imageUrl;
}
$locationName = $eventData->event_location ?? '';
$address = $eventData->event_address ?? '';
if (!empty($locationName) || !empty($address)) {
$location = ['@type' => 'Place'];
if (!empty($locationName)) {
$location['name'] = $locationName;
}
if (!empty($address)) {
$location['address'] = [
'@type' => 'PostalAddress',
'streetAddress' => $address,
];
}
$schema['location'] = $location;
}
$price = $eventData->event_price ?? '';
$currency = $eventData->event_currency ?? 'USD';
$ticketUrl = $eventData->event_url ?? '';
if ($price !== '') {
$offer = [
'@type' => 'Offer',
'price' => number_format((float) $price, 2, '.', ''),
'priceCurrency' => $currency ?: 'USD',
'availability' => 'https://schema.org/InStock',
];
if (!empty($ticketUrl)) {
$offer['url'] = $ticketUrl;
}
$schema['offers'] = $offer;
} elseif (!empty($ticketUrl)) {
$schema['offers'] = [
'@type' => 'Offer',
'price' => '0.00',
'priceCurrency' => $currency ?: 'USD',
'availability' => 'https://schema.org/InStock',
'url' => $ticketUrl,
];
}
return $schema;
}
/**
* Build Recipe schema from per-article recipe data.
*
* @param string $title Recipe/article title
* @param string $description Recipe/article description
* @param string $imageUrl Image URL (absolute)
* @param object $recipeData Decoded recipe_data object
*
* @return array|null
*/
public static function buildRecipe(string $title, string $description, string $imageUrl, object $recipeData): ?array
{
$fields = ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine'];
$hasData = false;
foreach ($fields as $field) {
if (!empty($recipeData->$field)) {
$hasData = true;
break;
}
}
if (!$hasData) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Recipe',
'name' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
];
if (!empty($imageUrl)) {
$schema['image'] = $imageUrl;
}
if (!empty($recipeData->recipe_prep_time)) {
$schema['prepTime'] = $recipeData->recipe_prep_time;
}
if (!empty($recipeData->recipe_cook_time)) {
$schema['cookTime'] = $recipeData->recipe_cook_time;
}
if (!empty($recipeData->recipe_prep_time) && !empty($recipeData->recipe_cook_time)) {
try {
$prep = new \DateInterval($recipeData->recipe_prep_time);
$cook = new \DateInterval($recipeData->recipe_cook_time);
$totalMinutes = ($prep->h * 60 + $prep->i) + ($cook->h * 60 + $cook->i);
$hours = intdiv($totalMinutes, 60);
$minutes = $totalMinutes % 60;
$totalTime = 'PT';
if ($hours > 0) {
$totalTime .= $hours . 'H';
}
if ($minutes > 0) {
$totalTime .= $minutes . 'M';
}
if ($totalTime !== 'PT') {
$schema['totalTime'] = $totalTime;
}
} catch (\Exception $e) {
// Invalid duration format
}
}
if (!empty($recipeData->recipe_yield)) {
$schema['recipeYield'] = $recipeData->recipe_yield;
}
if (!empty($recipeData->recipe_calories)) {
$schema['nutrition'] = [
'@type' => 'NutritionInformation',
'calories' => $recipeData->recipe_calories . ' calories',
];
}
if (!empty($recipeData->recipe_ingredients)) {
$ingredients = array_filter(
array_map('trim', preg_split('/\r\n|\r|\n/', $recipeData->recipe_ingredients)),
fn($line) => $line !== ''
);
if (!empty($ingredients)) {
$schema['recipeIngredient'] = array_values($ingredients);
}
}
if (!empty($recipeData->recipe_category)) {
$schema['recipeCategory'] = $recipeData->recipe_category;
}
if (!empty($recipeData->recipe_cuisine)) {
$schema['recipeCuisine'] = $recipeData->recipe_cuisine;
}
return $schema;
}
/**
* Encode a schema array to a JSON-LD script tag string.
*
@@ -0,0 +1,110 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
/**
* XML Sitemap builder.
*
* Generates a sitemap.xml containing all published articles, excluding
* those marked with noindex robots directives in the mokoog_tags table.
*/
class SitemapBuilder
{
/**
* Generate sitemap XML content.
*
* @param string $changefreq Default change frequency for entries
*
* @return string Complete sitemap XML
*/
public static function generate(string $changefreq = 'weekly'): string
{
$allowed = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
$changefreq = \in_array($changefreq, $allowed, true) ? $changefreq : 'weekly';
$db = Factory::getDbo();
// Get all published articles
$query = $db->getQuery(true)
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
->from($db->quoteName('#__content', 'a'))
->where($db->quoteName('a.state') . ' = 1');
$db->setQuery($query);
$articles = $db->loadObjectList();
// Get noindex articles from mokoog_tags
$noindexQuery = $db->getQuery(true)
->select($db->quoteName('content_id'))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%'));
$db->setQuery($noindexQuery);
$noindexIds = array_map('intval', $db->loadColumn());
$root = rtrim(Uri::root(), '/');
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
// Homepage
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . $root . '/</loc>' . "\n";
$xml .= ' <changefreq>daily</changefreq>' . "\n";
$xml .= ' <priority>1.0</priority>' . "\n";
$xml .= ' </url>' . "\n";
foreach ($articles as $article) {
// Skip noindexed
if (\in_array((int) $article->id, $noindexIds, true)) {
continue;
}
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
? date('Y-m-d', strtotime($article->modified)) : '';
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . htmlspecialchars($url, ENT_XML1) . '</loc>' . "\n";
if ($lastmod) {
$xml .= ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
}
$xml .= ' <changefreq>' . $changefreq . '</changefreq>' . "\n";
$xml .= ' <priority>0.8</priority>' . "\n";
$xml .= ' </url>' . "\n";
}
$xml .= '</urlset>';
return $xml;
}
/**
* Write sitemap XML to the site root.
*
* @param string $xml The sitemap XML content
*
* @return bool True on success
*/
public static function writeToFile(string $xml): bool
{
$path = JPATH_ROOT . '/sitemap.xml';
return (bool) file_put_contents($path, $xml);
}
}
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoJoomOpenGraph</name>
<version>01.03.01</version>
<version>01.04.15</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteOpenGraph</name>
<packagename>mokoog</packagename>
<version>01.03.01</version>
<version>01.04.15</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+257
View File
@@ -0,0 +1,257 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage Tests
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Mokoconsulting\MokoOG\Tests\Unit\Helper;
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
use PHPUnit\Framework\TestCase;
class JsonLdBuilderTest extends TestCase
{
// ── FAQPage ──────────────────────────────────────────────────────
public function testBuildFaqReturnsNullForEmptyArray(): void
{
$this->assertNull(JsonLdBuilder::buildFaq([]));
}
public function testBuildFaqSkipsEmptyQuestions(): void
{
$faqs = [
['question' => '', 'answer' => 'An answer'],
['question' => 'Valid?', 'answer' => ''],
['question' => ' ', 'answer' => 'Still empty'],
];
$this->assertNull(JsonLdBuilder::buildFaq($faqs));
}
public function testBuildFaqReturnsValidSchema(): void
{
$faqs = [
['question' => 'What is OG?', 'answer' => 'Open Graph protocol.'],
['question' => 'Why use it?', 'answer' => 'Better social previews.'],
];
$result = JsonLdBuilder::buildFaq($faqs);
$this->assertNotNull($result);
$this->assertSame('https://schema.org', $result['@context']);
$this->assertSame('FAQPage', $result['@type']);
$this->assertCount(2, $result['mainEntity']);
$this->assertSame('Question', $result['mainEntity'][0]['@type']);
$this->assertSame('What is OG?', $result['mainEntity'][0]['name']);
$this->assertSame('Open Graph protocol.', $result['mainEntity'][0]['acceptedAnswer']['text']);
}
// ── HowTo ────────────────────────────────────────────────────────
public function testBuildHowToReturnsNullForEmptySteps(): void
{
$this->assertNull(JsonLdBuilder::buildHowTo('Test', []));
$this->assertNull(JsonLdBuilder::buildHowTo('Test', ['', ' ']));
}
public function testBuildHowToReturnsValidSchema(): void
{
$result = JsonLdBuilder::buildHowTo('Install Joomla', ['Download ZIP', 'Upload files', 'Run installer']);
$this->assertNotNull($result);
$this->assertSame('HowTo', $result['@type']);
$this->assertSame('Install Joomla', $result['name']);
$this->assertCount(3, $result['step']);
$this->assertSame(1, $result['step'][0]['position']);
$this->assertSame('HowToStep', $result['step'][0]['@type']);
$this->assertSame('Download ZIP', $result['step'][0]['text']);
$this->assertArrayNotHasKey('image', $result);
}
public function testBuildHowToIncludesImageWhenProvided(): void
{
$result = JsonLdBuilder::buildHowTo('Fix a bike', ['Remove wheel'], 'https://example.com/bike.jpg');
$this->assertNotNull($result);
$this->assertSame('https://example.com/bike.jpg', $result['image']);
}
// ── Recipe ───────────────────────────────────────────────────────
public function testBuildRecipeReturnsNullWhenNoData(): void
{
$data = (object) ['name' => '', 'description' => ''];
$this->assertNull(JsonLdBuilder::buildRecipe($data));
}
public function testBuildRecipeCalculatesTotalTime(): void
{
$data = (object) [
'name' => 'Pasta',
'prepTime' => 'PT15M',
'cookTime' => 'PT30M',
];
$result = JsonLdBuilder::buildRecipe($data);
$this->assertNotNull($result);
$this->assertSame('Recipe', $result['@type']);
$this->assertSame('PT45M', $result['totalTime']);
}
public function testBuildRecipeSplitsIngredientsByNewline(): void
{
$data = (object) [
'name' => 'Salad',
'ingredients' => "Lettuce\nTomato\nOnion",
];
$result = JsonLdBuilder::buildRecipe($data);
$this->assertNotNull($result);
$this->assertSame(['Lettuce', 'Tomato', 'Onion'], $result['recipeIngredient']);
}
// ── Event ────────────────────────────────────────────────────────
public function testBuildEventReturnsNullWithoutStartDate(): void
{
$data = (object) ['name' => 'Conference', 'startDate' => ''];
$this->assertNull(JsonLdBuilder::buildEvent($data));
}
public function testBuildEventIncludesLocationAndOffers(): void
{
$data = (object) [
'name' => 'Tech Summit',
'startDate' => '2026-09-01T09:00:00',
'endDate' => '2026-09-01T17:00:00',
'location' => (object) [
'name' => 'Convention Center',
'address' => '123 Main St',
],
'offers' => (object) [
'price' => '99.00',
'currency' => 'EUR',
'url' => 'https://example.com/tickets',
],
];
$result = JsonLdBuilder::buildEvent($data);
$this->assertNotNull($result);
$this->assertSame('Event', $result['@type']);
$this->assertSame('2026-09-01T09:00:00', $result['startDate']);
$this->assertSame('2026-09-01T17:00:00', $result['endDate']);
$this->assertSame('Place', $result['location']['@type']);
$this->assertSame('Convention Center', $result['location']['name']);
$this->assertSame('Offer', $result['offers']['@type']);
$this->assertSame('99.00', $result['offers']['price']);
$this->assertSame('EUR', $result['offers']['priceCurrency']);
}
// ── LocalBusiness ────────────────────────────────────────────────
public function testBuildLocalBusinessReturnsNullWithoutName(): void
{
$params = $this->createParamsMock([]);
$this->assertNull(JsonLdBuilder::buildLocalBusiness($params));
}
public function testBuildLocalBusinessIncludesAddress(): void
{
$params = $this->createParamsMock([
'business_name' => 'Moko Consulting',
'street_address' => '456 Oak Ave',
'city' => 'Austin',
'region' => 'TX',
'postal_code' => '78701',
'country' => 'US',
'telephone' => '+1-555-0100',
]);
$result = JsonLdBuilder::buildLocalBusiness($params);
$this->assertNotNull($result);
$this->assertSame('LocalBusiness', $result['@type']);
$this->assertSame('Moko Consulting', $result['name']);
$this->assertSame('PostalAddress', $result['address']['@type']);
$this->assertSame('456 Oak Ave', $result['address']['streetAddress']);
$this->assertSame('Austin', $result['address']['addressLocality']);
$this->assertSame('TX', $result['address']['addressRegion']);
$this->assertSame('78701', $result['address']['postalCode']);
$this->assertSame('US', $result['address']['addressCountry']);
$this->assertSame('+1-555-0100', $result['telephone']);
}
// ── VideoObject ──────────────────────────────────────────────────
public function testBuildVideoReturnsNullForEmptyUrl(): void
{
$this->assertNull(JsonLdBuilder::buildVideo(''));
}
public function testBuildVideoAddsEmbedUrlForYoutube(): void
{
$result = JsonLdBuilder::buildVideo(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'Test Video',
'A description'
);
$this->assertNotNull($result);
$this->assertSame('VideoObject', $result['@type']);
$this->assertSame('https://www.youtube.com/watch?v=dQw4w9WgXcQ', $result['contentUrl']);
$this->assertSame('https://www.youtube.com/embed/dQw4w9WgXcQ', $result['embedUrl']);
$this->assertSame('Test Video', $result['name']);
}
// ── toScriptTag ──────────────────────────────────────────────────
public function testToScriptTagEscapesClosingScriptTags(): void
{
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => 'Test </script><script>alert(1)</script>',
];
$output = JsonLdBuilder::toScriptTag($schema);
$this->assertStringStartsWith('<script type="application/ld+json">', $output);
$this->assertStringEndsWith('</script>', $output);
// The closing </script> inside the JSON must be escaped
$this->assertStringNotContainsString('</script><script>', $output);
$this->assertStringContainsString('<\\/script>', $output);
}
// ── Helper ───────────────────────────────────────────────────────
/**
* Create a mock object that mimics Joomla's Registry->get($key, $default).
*/
private function createParamsMock(array $values): object
{
return new class ($values) {
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public function get(string $key, mixed $default = ''): mixed
{
return $this->data[$key] ?? $default;
}
};
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage Tests
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
define('_JEXEC', 1);
require_once __DIR__ . '/../vendor/autoload.php';