diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index d21d862..7d3ea45 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -31,8 +31,9 @@ This is the LightSpeed Site Plugin — custom blocks and site-specific functiona
| Main plugin bootstrap | `ls-plugin.php` |
| PHP includes | `inc/` |
| Block source files | `src/blocks/` |
-| Built block assets | `blocks/` |
-| Static assets | `assets/` |
+| JS/CSS source files | `src/js/`, `src/css/` |
+| Built CSS/JS assets | `build/` |
+| Static non-compiled assets | `assets/` |
| Block patterns | `patterns/` |
| Translation files | `languages/` |
| End-user docs | `docs/` |
@@ -69,12 +70,20 @@ See `.github/instructions/` for detailed guidance:
## Validation and linting
```bash
+npm run build # Build all source JS/CSS assets from src/ into build/
npm run plugin:validate # Validate plugin structure
npm run security:scan # PHP security scan
composer run phpcs # PHP coding standards
npm run lint # JS + CSS + JSON linting
```
+## Asset build rule
+
+- Add authored JS and CSS files under `src/`.
+- Run `npm run build` after changing JS or CSS.
+- Enqueue or include only files from `build/` in PHP.
+- Do not treat `assets/js/` or `assets/css/` as source directories.
+
---
## Accessibility
diff --git a/.github/instructions/assets.instructions.md b/.github/instructions/assets.instructions.md
index 83dba51..859308b 100644
--- a/.github/instructions/assets.instructions.md
+++ b/.github/instructions/assets.instructions.md
@@ -8,22 +8,24 @@ applyTo: "assets/**,src/**"
| Folder | Content |
| ---------------- | ------------------------------------------------------ |
-| `assets/css/` | Static (pre-built) CSS files for frontend or admin |
-| `assets/js/` | Static (pre-built) JS files not managed by block build |
+| `assets/css/` | Static non-source assets only; do not add authored CSS source here |
+| `assets/js/` | Static non-source assets only; do not add authored JS source here |
| `assets/images/` | Plugin images (logos, backgrounds, etc.) |
| `assets/icons/` | SVG or PNG icons |
| `src/blocks/` | Block source files — compiled by `@wordpress/scripts` |
-| `src/css/` | Non-block CSS source files |
-| `src/js/` | Non-block JS source files |
-| `blocks/` | Built block assets — output of `npm run build` |
+| `src/css/` | Non-block CSS source files for the build pipeline |
+| `src/js/` | Non-block JS source files for the build pipeline |
+| `build/` | Built CSS/JS assets output by `npm run build` |
## Rules
- Do not mix source and built files in the same folder.
+- Put authored JS and CSS source files in `src/`, not `assets/`.
- `src/` contains files that need compilation.
-- `assets/` contains files that are already production-ready.
-- `blocks/` contains built block output from `@wordpress/scripts`.
-- Do not commit compiled output from `src/` — use `npm run build` to generate it.
+- `build/` contains the generated files that are enqueued or included by PHP.
+- Treat `assets/` as static non-compiled assets only, such as images or icons.
+- After changing JS or CSS in `src/`, run `npm run build` to regenerate the `build/` output.
+- Do not enqueue or include JS/CSS directly from `src/`.
## WordPress preset syntax
@@ -32,20 +34,22 @@ applyTo: "assets/**,src/**"
## Enqueuing assets in PHP
-Use `wp_enqueue_style()` and `wp_enqueue_script()` with versioning:
+Use `wp_enqueue_style()` and `wp_enqueue_script()` with versioning, and point them at built files:
```php
wp_enqueue_style(
'ls-plugin-frontend',
- LS_PLUGIN_PLUGIN_URL . 'assets/css/frontend.css',
+ LS_PLUGIN_PLUGIN_URL . 'build/css/frontend.css',
[],
LS_PLUGIN_VERSION
);
```
+When webpack generates `*.asset.php` metadata files, use them for dependencies and versions.
+
## Block assets
-Block assets (editor and frontend CSS/JS) are declared in `block.json` and enqueued automatically by `register_block_type()`.
+Block assets (editor and frontend CSS/JS) are declared in `block.json` and built into `build/`, then enqueued automatically by `register_block_type()`.
Do not manually enqueue block scripts — let `block.json` handle it.
## Image and icon guidelines
diff --git a/.github/instructions/plugin-structure.instructions.md b/.github/instructions/plugin-structure.instructions.md
index 7b32d72..4fc34ae 100644
--- a/.github/instructions/plugin-structure.instructions.md
+++ b/.github/instructions/plugin-structure.instructions.md
@@ -31,8 +31,8 @@ applyTo: "**"
|---|---|
| `inc/` | Optional PHP include files — loaded from main plugin file |
| `src/` | Source files for compilation (blocks, CSS, JS) |
-| `blocks/` | Built block assets — output of `npm run build` |
-| `assets/` | Static (pre-built) CSS, JS, images, icons |
+| `build/` | Built CSS/JS assets — output of `npm run build` |
+| `assets/` | Static non-compiled assets such as images and icons |
| `patterns/` | WordPress block patterns (PHP with header comments) |
| `templates/` | Optional block templates and template parts |
| `languages/` | Translation files (.pot, .po, .mo) |
@@ -51,6 +51,8 @@ Plugin-specific values are already set for this repo:
- Do not put developer reports in `docs/`.
- Do not put built assets in `src/`.
+- Do not add authored JS or CSS source files to `assets/`.
+- Do not enqueue raw files from `src/`; build them first and include the generated files from `build/`.
- Do not add Playwright, Storybook, Docker, webpack config, or Vite.
- Do not add a PHP autoloader unless there is a genuine reason.
- Do not add issue templates or pull request templates.
diff --git a/.github/schemas/scf-field-group.schema.json b/.github/schemas/scf-field-group.schema.json
new file mode 100644
index 0000000..9d5c2fd
--- /dev/null
+++ b/.github/schemas/scf-field-group.schema.json
@@ -0,0 +1,91 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://lightspeedwp.agency/schemas/scf-field-group.schema.json",
+ "title": "SCF Field Group",
+ "description": "Schema for Secure Custom Fields field group JSON exports.",
+ "type": "object",
+ "required": [
+ "key",
+ "title",
+ "fields",
+ "location"
+ ],
+ "properties": {
+ "key": {
+ "type": "string",
+ "pattern": "^group_"
+ },
+ "title": {
+ "type": "string"
+ },
+ "fields": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["key", "type"],
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "sub_fields": {
+ "type": "array"
+ },
+ "layouts": {
+ "type": "array"
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "location": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["param", "operator", "value"],
+ "properties": {
+ "param": {
+ "type": "string"
+ },
+ "operator": {
+ "type": "string"
+ },
+ "value": {
+ "type": ["string", "number", "boolean"]
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ },
+ "position": {
+ "type": "string"
+ },
+ "style": {
+ "type": "string"
+ },
+ "label_placement": {
+ "type": "string"
+ },
+ "instruction_placement": {
+ "type": "string"
+ },
+ "hide_on_screen": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/AGENTS.md b/AGENTS.md
index e402d97..7a18d5b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,8 +36,8 @@ Responsibilities:
├── readme.txt Lightweight WordPress distribution placeholder
├── inc/ PHP include files (optional, loaded from main plugin file)
├── src/ Source files: src/blocks/, src/css/, src/js/
-├── blocks/ Built or registered block asset directories
-├── assets/ Static assets: assets/css/, assets/js/, assets/images/, assets/icons/
+├── build/ Built CSS/JS assets generated by npm build
+├── assets/ Static non-compiled assets: images, icons, other fixed files
├── patterns/ WordPress block patterns (PHP files with header comments)
├── templates/ Optional block templates and template parts
├── languages/ Translation files (.pot, .po, .mo)
@@ -137,11 +137,15 @@ register_block_type( LS_PLUGIN_PLUGIN_DIR . 'blocks/my-block' );
## Asset Conventions
-- **Static assets** (ready-to-use CSS, JS, images, icons): `assets/`
- **Source files** (need compilation): `src/`
-- **Built block assets**: `blocks/`
+- **Built CSS/JS assets**: `build/`
+- **Static non-compiled assets** (images, icons, fixed files): `assets/`
- Do not mix source and built assets in the same folder.
- Frontend CSS and editor CSS should be separate files where appropriate.
+- Add authored JS and CSS to `src/js/`, `src/css/`, or `src/blocks/`.
+- Run `npm run build` after changing JS or CSS source files.
+- PHP must enqueue or include built assets from `build/`, never raw source files from `src/`.
+- Do not add authored JS or CSS source files to `assets/js/` or `assets/css/`.
---
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0485734..eca0453 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,13 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a Button Icon selector panel for core Button blocks, including left/right positioning and up/down icon options.
- Added a Back to Top option as a `core/button` variation so users inherit native Button styling controls and icon compatibility.
- Added smooth scrolling support for Back to Top button clicks and internal anchor links using vanilla JavaScript.
-
- Linkable Group Blocks support for `core/group`, `core/column`, and `core/cover`, including custom URLs and current-post linking from the block toolbar.
+- Added plugin-managed SCF Local JSON handling and validation utilities for field groups, post types, and taxonomies.
+- Added an SCF field-group schema for repository validation workflows.
+- Added SCF JSON definitions for a Portfolio post type, Industry and Service taxonomies, and Portfolio custom fields.
+- Added SCF permalink controls on the WordPress Permalinks screen for portfolio archives and related taxonomies.
### Changed
- Changed Back to Top implementation from a standalone custom block to a `core/button` variation.
- Changed Back to Top frontend targeting to use a dedicated wrapper class (`is-back-to-top`) for reliable JS and CSS behaviour.
- Changed Back to Top visibility to always display (removed scroll-threshold hide/show behaviour).
+- Changed linkable block source assets to load from `src/` and updated build configuration to match the new asset layout.
+- Changed the plugin bootstrap to load SCF JSON configuration, validation, and permalink management classes.
### Deprecated
diff --git a/inc/class-permalinks.php b/inc/class-permalinks.php
new file mode 100644
index 0000000..e49a676
--- /dev/null
+++ b/inc/class-permalinks.php
@@ -0,0 +1,278 @@
+ 'portfolio',
+ 'portfolio-industry' => 'portfolio-industry',
+ 'portfolio-service' => 'portfolio-service',
+ );
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ add_action( 'admin_init', array( $this, 'register_permalink_settings' ) );
+ add_action( 'admin_init', array( $this, 'save_custom_permalink_fields' ), 20 );
+ add_filter( 'acf/post_type/registration_args', array( $this, 'apply_post_type_slugs' ), 10, 2 );
+ add_filter( 'acf/taxonomy/registration_args', array( $this, 'apply_taxonomy_slugs' ), 10, 2 );
+ }
+
+ /**
+ * Register the setting to save custom fields.
+ */
+ public function register_permalink_settings() {
+ register_setting(
+ 'permalink',
+ 'ls_plugin_scf_slugs',
+ array(
+ 'type' => 'array',
+ 'sanitize_callback' => array( $this, 'sanitize_permalink_fields' ),
+ 'default' => $this->defaults,
+ )
+ );
+
+ add_settings_section(
+ 'ls_plugin_scf_permalink_section',
+ '',
+ array( $this, 'permalink_fields' ),
+ 'permalink'
+ );
+ }
+
+ /**
+ * Sanitize the custom permalink fields before saving.
+ *
+ * @param array $input Raw input from the form.
+ * @return array Sanitized input.
+ */
+ public function sanitize_permalink_fields( $input ) {
+ $sanitized = array();
+
+ foreach ( $this->defaults as $key => $default ) {
+ $field_key = 'ls_plugin_scf_' . $key;
+ $sanitized[ $field_key ] = isset( $input[ $field_key ] ) ? sanitize_title( $input[ $field_key ] ) : '';
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Register new fields to the permalink settings page.
+ */
+ public function permalink_fields() {
+ // Get existing options or defaults.
+ $options = get_option( 'ls_plugin_scf_slugs', $this->defaults );
+
+ $post_type_fields = array(
+ 'portfolio' => array(
+ 'label' => esc_html__( 'Portfolio', 'ls-plugin' ),
+ 'description' => esc_html__( 'Single Portfolio Archive', 'ls-plugin' ),
+ ),
+ );
+
+ $taxonomy_fields = array(
+ 'portfolio-industry' => array(
+ 'label' => esc_html__( 'Industry', 'ls-plugin' ),
+ 'description' => esc_html__( 'Portfolio Industry Taxonomy', 'ls-plugin' ),
+ ),
+ 'portfolio-service' => array(
+ 'label' => esc_html__( 'Service', 'ls-plugin' ),
+ 'description' => esc_html__( 'Portfolio Service Taxonomy', 'ls-plugin' ),
+ ),
+ );
+ ?>
+
+
+
+
+
+
+
+
+ sanitize_permalink_fields( $input );
+ update_option( 'ls_plugin_scf_slugs', $sanitized );
+ }
+ }
+
+ /**
+ * Apply custom post type slugs from saved options.
+ *
+ * Filters SCF post type registration arguments to apply custom slugs.
+ * This hook is called by ACF/SCF before register_post_type().
+ *
+ * @param array $args Post type registration arguments.
+ * @param array $post SCF post type configuration.
+ * @return array Modified post type registration arguments.
+ */
+ public function apply_post_type_slugs( $args, $post ) {
+ $slug_options = get_option( 'ls_plugin_scf_slugs', $this->defaults );
+
+ // Check if this is a post type we manage.
+ $post_type_slug = $post['post_type'] ?? '';
+ $has_archive = $args['has_archive'] ?? false;
+
+ if ( ! $has_archive ) {
+ return $args;
+ }
+
+ if ( 'ls_plugin_portfolio' === $post_type_slug ) {
+ $field_key = 'ls_plugin_scf_portfolio';
+ $custom_slug = isset( $slug_options[ $field_key ] ) ? $slug_options[ $field_key ] : '';
+
+ if ( '' !== $custom_slug ) {
+ $args['rewrite'] = $args['rewrite'] ?? array();
+ $args['rewrite']['slug'] = $custom_slug;
+ $args['has_archive'] = $custom_slug;
+ }
+ }
+
+ return $args;
+ }
+
+ /**
+ * Apply custom taxonomy slugs from saved options.
+ *
+ * Filters SCF taxonomy registration arguments to apply custom slugs.
+ * This hook is called by ACF/SCF before register_taxonomy().
+ *
+ * @param array $args Taxonomy registration arguments.
+ * @param array $post SCF taxonomy configuration.
+ * @return array Modified taxonomy registration arguments.
+ */
+ public function apply_taxonomy_slugs( $args, $post ) {
+ $slug_options = get_option( 'ls_plugin_scf_slugs', $this->defaults );
+
+ $taxonomy_slug = $post['taxonomy'] ?? '';
+
+ // Map taxonomy slugs to custom configuration keys.
+ $taxonomy_mapping = array(
+ 'ls_plugin_portfolio_industry' => 'portfolio-industry',
+ 'ls_plugin_portfolio_service' => 'portfolio-service',
+ );
+
+ if ( ! isset( $taxonomy_mapping[ $taxonomy_slug ] ) ) {
+ return $args;
+ }
+
+ $config_key = $taxonomy_mapping[ $taxonomy_slug ];
+ $field_key = 'ls_plugin_scf_' . $config_key;
+ $custom_slug = isset( $slug_options[ $field_key ] ) ? $slug_options[ $field_key ] : '';
+
+ if ( '' !== $custom_slug ) {
+ $args['rewrite'] = $args['rewrite'] ?? array();
+ $args['rewrite']['slug'] = $custom_slug;
+ }
+
+ return $args;
+ }
+}
diff --git a/inc/class-scf-json-validator.php b/inc/class-scf-json-validator.php
new file mode 100644
index 0000000..16be087
--- /dev/null
+++ b/inc/class-scf-json-validator.php
@@ -0,0 +1,435 @@
+schema_path = LS_PLUGIN_PLUGIN_DIR . '.github/schemas/scf-field-group.schema.json';
+
+ if ( class_exists( 'LS_Plugin_SCF_JSON' ) ) {
+ $this->scf_json = new LS_Plugin_SCF_JSON();
+ }
+
+ $this->load_schema();
+ }
+
+ /**
+ * Load JSON schema.
+ *
+ * @return bool
+ */
+ private function load_schema() {
+ if ( ! file_exists( $this->schema_path ) ) {
+ return false;
+ }
+
+ $schema_json = file_get_contents( $this->schema_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->schema = json_decode( $schema_json, true );
+
+ return null !== $this->schema;
+ }
+
+ /**
+ * Validate one field-group JSON file.
+ *
+ * @param string $file_path Path to JSON file.
+ * @return array{valid: bool, errors: array, warnings: array}
+ */
+ public function validate( $file_path ) {
+ $result = array(
+ 'valid' => true,
+ 'errors' => array(),
+ 'warnings' => array(),
+ );
+
+ if ( ! file_exists( $file_path ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = 'File does not exist: ' . $file_path;
+ return $result;
+ }
+
+ $json_content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $field_group = json_decode( $json_content, true );
+
+ if ( JSON_ERROR_NONE !== json_last_error() ) {
+ $result['valid'] = false;
+ $result['errors'][] = 'Invalid JSON: ' . json_last_error_msg();
+ return $result;
+ }
+
+ $this->validate_root_structure( $field_group, $result );
+
+ if ( ! empty( $result['errors'] ) ) {
+ $result['valid'] = false;
+ return $result;
+ }
+
+ $this->validate_field_group_properties( $field_group, $result );
+ $this->validate_fields( $field_group, $result );
+ $this->validate_location_rules( $field_group, $result );
+ $this->validate_display_properties( $field_group, $result );
+
+ $result['valid'] = empty( $result['errors'] );
+
+ return $result;
+ }
+
+ /**
+ * Validate top-level required structure.
+ *
+ * @param array $field_group Field-group data.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_root_structure( $field_group, &$result ) {
+ $required = array( 'key', 'title', 'fields', 'location' );
+
+ foreach ( $required as $property ) {
+ if ( ! isset( $field_group[ $property ] ) ) {
+ $result['errors'][] = sprintf( 'Missing required property: %s', $property );
+ }
+ }
+
+ if ( isset( $field_group['key'] ) ) {
+ if ( ! is_string( $field_group['key'] ) ) {
+ $result['errors'][] = 'Field group key must be a string';
+ } elseif ( 0 !== strpos( $field_group['key'], 'group_' ) ) {
+ $result['errors'][] = 'Field group key must start with "group_"';
+ }
+ }
+
+ if ( isset( $field_group['title'] ) && ! is_string( $field_group['title'] ) ) {
+ $result['errors'][] = 'Field group title must be a string';
+ }
+
+ if ( isset( $field_group['fields'] ) && ! is_array( $field_group['fields'] ) ) {
+ $result['errors'][] = 'Fields must be an array';
+ }
+
+ if ( isset( $field_group['location'] ) && ! is_array( $field_group['location'] ) ) {
+ $result['errors'][] = 'Location must be an array';
+ }
+ }
+
+ /**
+ * Validate field-group display properties.
+ *
+ * @param array $field_group Field-group data.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_field_group_properties( $field_group, &$result ) {
+ $valid_positions = array( 'acf_after_title', 'normal', 'side' );
+ if ( isset( $field_group['position'] ) && ! in_array( $field_group['position'], $valid_positions, true ) ) {
+ $result['errors'][] = sprintf(
+ 'Invalid position "%s". Must be one of: %s',
+ $field_group['position'],
+ implode( ', ', $valid_positions )
+ );
+ }
+
+ $valid_styles = array( 'default', 'seamless' );
+ if ( isset( $field_group['style'] ) && ! in_array( $field_group['style'], $valid_styles, true ) ) {
+ $result['warnings'][] = sprintf(
+ 'Invalid style "%s". Should be one of: %s',
+ $field_group['style'],
+ implode( ', ', $valid_styles )
+ );
+ }
+
+ $valid_label_placements = array( 'top', 'left' );
+ if ( isset( $field_group['label_placement'] ) && ! in_array( $field_group['label_placement'], $valid_label_placements, true ) ) {
+ $result['warnings'][] = 'Invalid label_placement value';
+ }
+
+ $valid_instruction_placements = array( 'label', 'field' );
+ if ( isset( $field_group['instruction_placement'] ) && ! in_array( $field_group['instruction_placement'], $valid_instruction_placements, true ) ) {
+ $result['warnings'][] = 'Invalid instruction_placement value';
+ }
+ }
+
+ /**
+ * Validate all fields.
+ *
+ * @param array $field_group Field-group data.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_fields( $field_group, &$result ) {
+ if ( ! isset( $field_group['fields'] ) || ! is_array( $field_group['fields'] ) ) {
+ return;
+ }
+
+ if ( empty( $field_group['fields'] ) ) {
+ $result['warnings'][] = 'Field group has no fields defined';
+ return;
+ }
+
+ foreach ( $field_group['fields'] as $index => $field ) {
+ $this->validate_field( $field, $index, $result );
+ }
+ }
+
+ /**
+ * Validate an individual field.
+ *
+ * @param array $field Field config.
+ * @param int $index Field index.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_field( $field, $index, &$result ) {
+ if ( ! isset( $field['key'] ) ) {
+ $result['errors'][] = sprintf( 'Field at index %d missing required property: key', $index );
+ return;
+ }
+
+ if ( ! isset( $field['type'] ) ) {
+ $result['errors'][] = sprintf( 'Field "%s" missing required property: type', $field['key'] );
+ return;
+ }
+
+ if ( ! in_array( $field['type'], $this->valid_field_types, true ) ) {
+ $result['errors'][] = sprintf(
+ 'Invalid field type "%s" in field "%s"',
+ $field['type'],
+ $field['key']
+ );
+ }
+
+ $layout_types = array( 'tab', 'accordion', 'message' );
+ if ( ! in_array( $field['type'], $layout_types, true ) ) {
+ if ( 0 !== strpos( $field['key'], 'field_' ) ) {
+ $result['errors'][] = sprintf(
+ 'Field key "%s" must start with "field_"',
+ $field['key']
+ );
+ }
+
+ if ( ! isset( $field['name'] ) || empty( $field['name'] ) ) {
+ $result['warnings'][] = sprintf(
+ 'Field "%s" should have a name for database storage',
+ $field['key']
+ );
+ }
+ }
+
+ $container_types = array( 'group', 'repeater', 'flexible_content' );
+ if ( in_array( $field['type'], $container_types, true ) ) {
+ if ( ! isset( $field['sub_fields'] ) || ! is_array( $field['sub_fields'] ) ) {
+ $result['errors'][] = sprintf(
+ 'Field "%s" of type "%s" must have sub_fields array',
+ $field['key'],
+ $field['type']
+ );
+ }
+ }
+
+ if ( 'flexible_content' === $field['type'] ) {
+ if ( ! isset( $field['layouts'] ) || ! is_array( $field['layouts'] ) ) {
+ $result['errors'][] = sprintf(
+ 'Flexible content field "%s" must have layouts array',
+ $field['key']
+ );
+ }
+ }
+ }
+
+ /**
+ * Validate location rule groups.
+ *
+ * @param array $field_group Field-group data.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_location_rules( $field_group, &$result ) {
+ if ( ! isset( $field_group['location'] ) || ! is_array( $field_group['location'] ) ) {
+ return;
+ }
+
+ if ( empty( $field_group['location'] ) ) {
+ $result['warnings'][] = 'Field group has no location rules defined';
+ return;
+ }
+
+ foreach ( $field_group['location'] as $group_index => $group ) {
+ if ( ! is_array( $group ) ) {
+ $result['errors'][] = sprintf( 'Location group %d must be an array', $group_index );
+ continue;
+ }
+
+ foreach ( $group as $rule_index => $rule ) {
+ $this->validate_location_rule( $rule, $group_index, $rule_index, $result );
+ }
+ }
+ }
+
+ /**
+ * Validate one location rule.
+ *
+ * @param array $rule Rule config.
+ * @param int $group_index Group index.
+ * @param int $rule_index Rule index.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_location_rule( $rule, $group_index, $rule_index, &$result ) {
+ if ( ! isset( $rule['param'] ) ) {
+ $result['errors'][] = sprintf( 'Location rule [%d][%d] missing param', $group_index, $rule_index );
+ return;
+ }
+
+ if ( ! in_array( $rule['param'], $this->valid_location_params, true ) ) {
+ $result['warnings'][] = sprintf(
+ 'Location param "%s" may not be valid in [%d][%d]',
+ $rule['param'],
+ $group_index,
+ $rule_index
+ );
+ }
+
+ if ( ! isset( $rule['operator'] ) ) {
+ $result['errors'][] = sprintf( 'Location rule [%d][%d] missing operator', $group_index, $rule_index );
+ }
+
+ if ( ! isset( $rule['value'] ) ) {
+ $result['errors'][] = sprintf( 'Location rule [%d][%d] missing value', $group_index, $rule_index );
+ }
+ }
+
+ /**
+ * Validate selected display properties.
+ *
+ * @param array $field_group Field-group data.
+ * @param array $result Validation result (by reference).
+ * @return void
+ */
+ private function validate_display_properties( $field_group, &$result ) {
+ if ( isset( $field_group['hide_on_screen'] ) && is_array( $field_group['hide_on_screen'] ) ) {
+ $valid_hide_values = array(
+ 'permalink', 'the_content', 'excerpt', 'discussion', 'comments',
+ 'revisions', 'slug', 'author', 'format', 'page_attributes',
+ 'featured_image', 'categories', 'tags', 'send-trackbacks',
+ );
+
+ foreach ( $field_group['hide_on_screen'] as $hide_value ) {
+ if ( ! in_array( $hide_value, $valid_hide_values, true ) ) {
+ $result['warnings'][] = sprintf( 'Unknown hide_on_screen value: %s', $hide_value );
+ }
+ }
+ }
+ }
+
+ /**
+ * Validate all JSON files from plugin SCF directory.
+ *
+ * @return array
+ */
+ public function validate_all_files() {
+ $results = array();
+
+ if ( ! $this->scf_json ) {
+ return $results;
+ }
+
+ $files = $this->scf_json->get_json_files();
+
+ foreach ( $files as $file_path ) {
+ $filename = basename( $file_path );
+ $results[ $filename ] = $this->validate( $file_path );
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get loaded schema.
+ *
+ * @return array|null
+ */
+ public function get_schema() {
+ return $this->schema;
+ }
+
+ /**
+ * Whether schema file loaded successfully.
+ *
+ * @return bool
+ */
+ public function has_schema() {
+ return ! empty( $this->schema );
+ }
+}
diff --git a/inc/class-scf-json.php b/inc/class-scf-json.php
new file mode 100644
index 0000000..702bda7
--- /dev/null
+++ b/inc/class-scf-json.php
@@ -0,0 +1,228 @@
+json_path = LS_PLUGIN_PLUGIN_DIR . 'scf-json';
+
+ add_filter( 'acf/settings/save_json', array( $this, 'set_save_path' ) );
+ add_filter( 'acf/settings/load_json', array( $this, 'add_load_path' ) );
+
+ add_filter( 'acf/settings/save_json/type=acf-post-type', array( $this, 'set_save_path' ) );
+ add_filter( 'acf/settings/save_json/type=acf-taxonomy', array( $this, 'set_save_path' ) );
+ add_filter( 'acf/json/load_paths', array( $this, 'add_post_type_load_paths' ) );
+ add_filter( 'acf/json/load_paths', array( $this, 'add_taxonomy_load_paths' ) );
+
+ $this->maybe_create_directory();
+ }
+
+ /**
+ * Set the save path for JSON exports.
+ *
+ * @param string $path Default save path.
+ * @return string
+ */
+ public function set_save_path( $path ) {
+ return $this->json_path;
+ }
+
+ /**
+ * Add custom load path for field groups.
+ *
+ * @param array $paths Existing load paths.
+ * @return array
+ */
+ public function add_load_path( $paths ) {
+ return $this->add_unique_load_path( $paths );
+ }
+
+ /**
+ * Add custom load path for post types.
+ *
+ * @param array $paths Existing load paths.
+ * @return array
+ */
+ public function add_post_type_load_paths( $paths ) {
+ return $this->add_unique_load_path( $paths );
+ }
+
+ /**
+ * Add custom load path for taxonomies.
+ *
+ * @param array $paths Existing load paths.
+ * @return array
+ */
+ public function add_taxonomy_load_paths( $paths ) {
+ return $this->add_unique_load_path( $paths );
+ }
+
+ /**
+ * Add the plugin path once to load paths.
+ *
+ * @param array $paths Existing load paths.
+ * @return array
+ */
+ private function add_unique_load_path( $paths ) {
+ if ( ! is_array( $paths ) ) {
+ $paths = array();
+ }
+
+ if ( ! in_array( $this->json_path, $paths, true ) ) {
+ $paths[] = $this->json_path;
+ }
+
+ return $paths;
+ }
+
+ /**
+ * Create JSON directory when missing.
+ *
+ * @return bool
+ */
+ private function maybe_create_directory() {
+ if ( ! is_dir( $this->json_path ) ) {
+ return wp_mkdir_p( $this->json_path );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get JSON directory path.
+ *
+ * @return string
+ */
+ public function get_json_path() {
+ return $this->json_path;
+ }
+
+ /**
+ * Get all JSON files in the directory.
+ *
+ * @return array
+ */
+ public function get_json_files() {
+ $files = glob( $this->json_path . '/*.json' );
+
+ return $files ? $files : array();
+ }
+
+ /**
+ * Basic JSON validation for one file.
+ *
+ * @param string $file_path Path to JSON file.
+ * @return array{valid: bool, errors: array}
+ */
+ public function validate_json_file( $file_path ) {
+ $result = array(
+ 'valid' => true,
+ 'errors' => array(),
+ );
+
+ if ( ! file_exists( $file_path ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = 'File does not exist.';
+ return $result;
+ }
+
+ $json_content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $data = json_decode( $json_content, true );
+
+ if ( JSON_ERROR_NONE !== json_last_error() ) {
+ $result['valid'] = false;
+ $result['errors'][] = 'Invalid JSON: ' . json_last_error_msg();
+ return $result;
+ }
+
+ $required = array( 'key', 'title', 'fields', 'location' );
+ foreach ( $required as $property ) {
+ if ( ! isset( $data[ $property ] ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = sprintf( 'Missing required property: %s', $property );
+ }
+ }
+
+ if ( isset( $data['key'] ) && 0 !== strpos( $data['key'], 'group_' ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = 'Field group key must start with "group_".';
+ }
+
+ if ( isset( $data['fields'] ) && ! is_array( $data['fields'] ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = 'Fields must be an array.';
+ }
+
+ if ( isset( $data['fields'] ) && is_array( $data['fields'] ) ) {
+ foreach ( $data['fields'] as $index => $field ) {
+ $field_required = array( 'key', 'type' );
+ foreach ( $field_required as $prop ) {
+ if ( ! isset( $field[ $prop ] ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = sprintf(
+ 'Field at index %d is missing required property: %s',
+ $index,
+ $prop
+ );
+ }
+ }
+
+ $layout_types = array( 'tab', 'accordion', 'message' );
+ if ( isset( $field['key'] ) && ! in_array( $field['type'], $layout_types, true ) ) {
+ if ( 0 !== strpos( $field['key'], 'field_' ) ) {
+ $result['valid'] = false;
+ $result['errors'][] = sprintf(
+ 'Field "%s" key must start with "field_".',
+ isset( $field['label'] ) ? $field['label'] : $field['key']
+ );
+ }
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Validate all JSON files in the directory.
+ *
+ * @return array
+ */
+ public function validate_all_json_files() {
+ $results = array();
+ $files = $this->get_json_files();
+
+ foreach ( $files as $file ) {
+ $filename = basename( $file );
+ $results[ $filename ] = $this->validate_json_file( $file );
+ }
+
+ return $results;
+ }
+}
diff --git a/ls-plugin.php b/ls-plugin.php
index 808aed2..9c40f01 100644
--- a/ls-plugin.php
+++ b/ls-plugin.php
@@ -46,12 +46,36 @@ function ls_plugin_load_textdomain() {
function ls_plugin_init() {
require_once LS_PLUGIN_PLUGIN_DIR . 'inc/linkable-blocks.php';
require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-style-switcher.php';
+ require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-scf-json.php';
+ require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-scf-json-validator.php';
+ require_once LS_PLUGIN_PLUGIN_DIR . 'inc/class-permalinks.php';
$style_switcher = new LS_Plugin_Style_Switcher();
$style_switcher->register_hooks();
+
+ // Configure SCF to use plugin-managed Local JSON paths.
+ new LS_Plugin_SCF_JSON();
+
+ // Manage custom permalinks for SCF post types and taxonomies.
+ new LS_Plugin\Permalinks();
}
add_action( 'plugins_loaded', 'ls_plugin_init' );
+/**
+ * Returns a shared SCF JSON validator instance.
+ *
+ * @return LS_Plugin_SCF_JSON_Validator
+ */
+function ls_plugin_get_scf_json_validator() {
+ static $validator = null;
+
+ if ( null === $validator ) {
+ $validator = new LS_Plugin_SCF_JSON_Validator();
+ }
+
+ return $validator;
+}
+
/**
* Enqueues Button Icon editor assets.
*
diff --git a/scf-json/group_ls_plugin_portfolio_fields.json b/scf-json/group_ls_plugin_portfolio_fields.json
new file mode 100644
index 0000000..15a1e9f
--- /dev/null
+++ b/scf-json/group_ls_plugin_portfolio_fields.json
@@ -0,0 +1,66 @@
+{
+ "key": "group_ls_plugin_portfolio_fields",
+ "title": "Portfolio Fields",
+ "fields": [
+ {
+ "key": "field_ls_plugin_portfolio_logo",
+ "label": "Portfolio Logo",
+ "name": "ls_plugin_portfolio_logo",
+ "aria-label": "",
+ "type": "image",
+ "instructions": "",
+ "required": 0,
+ "conditional_logic": 0,
+ "wrapper": {
+ "width": "",
+ "class": "",
+ "id": ""
+ },
+ "return_format": "array",
+ "library": "all",
+ "min_width": "",
+ "min_height": "",
+ "min_size": "",
+ "max_width": "",
+ "max_height": "",
+ "max_size": "",
+ "mime_types": "",
+ "preview_size": "medium"
+ },
+ {
+ "key": "field_ls_plugin_portfolio_website",
+ "label": "Portfolio Website",
+ "name": "ls_plugin_portfolio_website",
+ "aria-label": "",
+ "type": "url",
+ "instructions": "",
+ "required": 0,
+ "conditional_logic": 0,
+ "wrapper": {
+ "width": "",
+ "class": "",
+ "id": ""
+ },
+ "placeholder": "https://",
+ "default_value": ""
+ }
+ ],
+ "location": [
+ [
+ {
+ "param": "post_type",
+ "operator": "==",
+ "value": "ls_plugin_portfolio"
+ }
+ ]
+ ],
+ "menu_order": 0,
+ "position": "normal",
+ "style": "default",
+ "label_placement": "top",
+ "instruction_placement": "label",
+ "hide_on_screen": [],
+ "active": true,
+ "description": "",
+ "show_in_rest": 1
+}
diff --git a/scf-json/post_type_portfolio.json b/scf-json/post_type_portfolio.json
new file mode 100644
index 0000000..925dc00
--- /dev/null
+++ b/scf-json/post_type_portfolio.json
@@ -0,0 +1,86 @@
+{
+ "key": "post_type_ls_plugin_portfolio",
+ "title": "Portfolios",
+ "menu_order": 0,
+ "active": true,
+ "post_type": "ls_plugin_portfolio",
+ "advanced_configuration": true,
+ "labels": {
+ "name": "Portfolios",
+ "singular_name": "Portfolio",
+ "menu_name": "Portfolios",
+ "all_items": "All Portfolios",
+ "edit_item": "Edit Portfolio",
+ "view_item": "View Portfolio",
+ "view_items": "View Portfolios",
+ "add_new_item": "Add New Portfolio",
+ "add_new": "Add New",
+ "new_item": "New Portfolio",
+ "parent_item_colon": "Parent Portfolio:",
+ "search_items": "Search Portfolios",
+ "not_found": "No portfolios found",
+ "not_found_in_trash": "No portfolios found in Trash",
+ "archives": "Portfolio Archives",
+ "attributes": "Portfolio Attributes",
+ "insert_into_item": "Insert into portfolio",
+ "uploaded_to_this_item": "Uploaded to this portfolio",
+ "filter_items_list": "Filter portfolios list",
+ "filter_by_date": "Filter portfolios by date",
+ "items_list_navigation": "Portfolios list navigation",
+ "items_list": "Portfolios list",
+ "item_published": "Portfolio published.",
+ "item_published_privately": "Portfolio published privately.",
+ "item_reverted_to_draft": "Portfolio reverted to draft.",
+ "item_scheduled": "Portfolio scheduled.",
+ "item_updated": "Portfolio updated.",
+ "item_link": "Portfolio Link",
+ "item_link_description": "A link to a portfolio."
+ },
+ "description": "",
+ "public": true,
+ "hierarchical": false,
+ "exclude_from_search": false,
+ "publicly_queryable": true,
+ "show_ui": true,
+ "show_in_menu": true,
+ "admin_menu_parent": "",
+ "show_in_admin_bar": true,
+ "show_in_nav_menus": true,
+ "show_in_rest": true,
+ "rest_base": "",
+ "rest_namespace": "wp/v2",
+ "rest_controller_class": "WP_REST_Posts_Controller",
+ "menu_position": "",
+ "menu_icon": {
+ "type": "dashicons",
+ "value": "dashicons-portfolio"
+ },
+ "rename_capabilities": false,
+ "singular_capability_name": "post",
+ "plural_capability_name": "posts",
+ "supports": [
+ "title",
+ "editor",
+ "excerpt",
+ "thumbnail",
+ "custom-fields"
+ ],
+ "taxonomies": [
+ "ls_plugin_portfolio_industry",
+ "ls_plugin_portfolio_service"
+ ],
+ "has_archive": true,
+ "has_archive_slug": "portfolio",
+ "rewrite": {
+ "permalink_rewrite": "custom_permalink",
+ "slug": "portfolio",
+ "with_front": "1",
+ "feeds": "1",
+ "pages": "1"
+ },
+ "query_var": "post_type_key",
+ "query_var_name": "",
+ "can_export": true,
+ "delete_with_user": false,
+ "enter_title_here": ""
+}
diff --git a/scf-json/taxonomy-portfolio-industry.json b/scf-json/taxonomy-portfolio-industry.json
new file mode 100644
index 0000000..31a7438
--- /dev/null
+++ b/scf-json/taxonomy-portfolio-industry.json
@@ -0,0 +1,70 @@
+{
+ "key": "taxonomy_ls_plugin_portfolio_industry",
+ "title": "Industry",
+ "menu_order": 0,
+ "active": true,
+ "taxonomy": "ls_plugin_portfolio_industry",
+ "object_type": [
+ "ls_plugin_portfolio"
+ ],
+ "advanced_configuration": true,
+ "labels": {
+ "name": "Industries",
+ "singular_name": "Industry",
+ "menu_name": "Industries",
+ "all_items": "All Industries",
+ "edit_item": "Edit Industry",
+ "view_item": "View Industry",
+ "update_item": "Update Industry",
+ "add_new_item": "Add New Industry",
+ "new_item_name": "New Industry Name",
+ "parent_item": "Parent Industry",
+ "parent_item_colon": "Parent Industry:",
+ "search_items": "Search Industries",
+ "popular_items": "Popular Industries",
+ "separate_items_with_commas": "Separate industries with commas",
+ "add_or_remove_items": "Add or remove industries",
+ "choose_from_most_used": "Choose from the most used industries",
+ "not_found": "No industries found",
+ "items_list_navigation": "Industries list navigation",
+ "items_list": "Industries list",
+ "back_to_items": "Back to Industries",
+ "item_link": "Industry Link",
+ "item_link_description": "A link to an industry."
+ },
+ "description": "",
+ "capabilities": {
+ "manage_terms": "manage_categories",
+ "edit_terms": "manage_categories",
+ "delete_terms": "manage_categories",
+ "assign_terms": "edit_posts"
+ },
+ "public": true,
+ "publicly_queryable": true,
+ "hierarchical": true,
+ "show_ui": true,
+ "show_in_menu": true,
+ "show_in_nav_menus": true,
+ "show_in_rest": true,
+ "rest_base": "portfolio-industry",
+ "rest_namespace": "wp/v2",
+ "rest_controller_class": "",
+ "show_tagcloud": true,
+ "show_in_quick_edit": true,
+ "show_admin_column": true,
+ "rewrite": {
+ "slug": "portfolio-industry",
+ "permalink_rewrite": "custom_permalink",
+ "with_front": "1",
+ "rewrite_hierarchical": "1"
+ },
+ "query_var": "post_type_key",
+ "query_var_name": "",
+ "default_term": {
+ "default_term_enabled": "0"
+ },
+ "sort": false,
+ "meta_box": "default",
+ "meta_box_cb": "",
+ "meta_box_sanitize_cb": ""
+}
diff --git a/scf-json/taxonomy-portfolio-service.json b/scf-json/taxonomy-portfolio-service.json
new file mode 100644
index 0000000..2a08fa8
--- /dev/null
+++ b/scf-json/taxonomy-portfolio-service.json
@@ -0,0 +1,68 @@
+{
+ "key": "taxonomy_ls_plugin_portfolio_service",
+ "title": "Service",
+ "menu_order": 0,
+ "active": true,
+ "taxonomy": "ls_plugin_portfolio_service",
+ "object_type": [
+ "ls_plugin_portfolio"
+ ],
+ "advanced_configuration": true,
+ "labels": {
+ "name": "Services",
+ "singular_name": "Service",
+ "menu_name": "Services",
+ "all_items": "All Services",
+ "edit_item": "Edit Service",
+ "view_item": "View Service",
+ "update_item": "Update Service",
+ "add_new_item": "Add New Service",
+ "new_item_name": "New Service Name",
+ "search_items": "Search Services",
+ "popular_items": "Popular Services",
+ "separate_items_with_commas": "Separate services with commas",
+ "add_or_remove_items": "Add or remove services",
+ "choose_from_most_used": "Choose from the most used services",
+ "not_found": "No services found",
+ "items_list_navigation": "Services list navigation",
+ "items_list": "Services list",
+ "back_to_items": "Back to Services",
+ "item_link": "Service Link",
+ "item_link_description": "A link to a service."
+ },
+ "description": "",
+ "capabilities": {
+ "manage_terms": "manage_categories",
+ "edit_terms": "manage_categories",
+ "delete_terms": "manage_categories",
+ "assign_terms": "edit_posts"
+ },
+ "public": true,
+ "publicly_queryable": true,
+ "hierarchical": false,
+ "show_ui": true,
+ "show_in_menu": true,
+ "show_in_nav_menus": true,
+ "show_in_rest": true,
+ "rest_base": "portfolio-service",
+ "rest_namespace": "wp/v2",
+ "rest_controller_class": "",
+ "show_tagcloud": true,
+ "show_in_quick_edit": true,
+ "show_admin_column": true,
+ "rewrite": {
+ "slug": "portfolio-service",
+ "permalink_rewrite": "custom_permalink",
+ "with_front": "1",
+ "rewrite_hierarchical": "0"
+ },
+ "query_var": "post_type_key",
+ "query_var_name": "",
+ "default_term": {
+ "default_term_enabled": "0"
+ },
+ "sort": false,
+ "meta_box": "default",
+ "meta_box_cb": "",
+ "meta_box_sanitize_cb": ""
+}
diff --git a/assets/css/linkable-blocks.css b/src/css/linkable-blocks.css
similarity index 100%
rename from assets/css/linkable-blocks.css
rename to src/css/linkable-blocks.css
diff --git a/assets/js/linkable-blocks-editor.js b/src/js/linkable-blocks-editor.js
similarity index 100%
rename from assets/js/linkable-blocks-editor.js
rename to src/js/linkable-blocks-editor.js
diff --git a/assets/js/linkable-blocks-frontend.js b/src/js/linkable-blocks-frontend.js
similarity index 100%
rename from assets/js/linkable-blocks-frontend.js
rename to src/js/linkable-blocks-frontend.js
diff --git a/webpack.config.cjs b/webpack.config.cjs
index ad33f8a..b82752b 100644
--- a/webpack.config.cjs
+++ b/webpack.config.cjs
@@ -12,9 +12,9 @@ module.exports = {
'js/back-to-top-view': path.resolve( process.cwd(), 'src/plugins/back-to-top', 'view.js' ),
'css/back-to-top': path.resolve( process.cwd(), 'src/plugins/back-to-top', 'style.scss' ),
'js/style-switcher': path.resolve( process.cwd(), 'src/js', 'style-switcher.js' ),
- 'js/linkable-blocks-editor': path.resolve( process.cwd(), 'assets/js', 'linkable-blocks-editor.js' ),
- 'js/linkable-blocks-frontend': path.resolve( process.cwd(), 'assets/js', 'linkable-blocks-frontend.js' ),
- 'css/linkable-blocks': path.resolve( process.cwd(), 'assets/css', 'linkable-blocks.css' ),
+ 'js/linkable-blocks-editor': path.resolve( process.cwd(), 'src/js', 'linkable-blocks-editor.js' ),
+ 'js/linkable-blocks-frontend': path.resolve( process.cwd(), 'src/js', 'linkable-blocks-frontend.js' ),
+ 'css/linkable-blocks': path.resolve( process.cwd(), 'src/css', 'linkable-blocks.css' ),
} ),
output: {
...defaultConfig.output,