Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion components/ranking-image/ranking-image.component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ props:
contentMediaType: image/*
x-allowed-schemes:
- public
- https
examples:
- public://placeholder-1000x500.png
- https://placehold.co/1000x500
decorative:
type: boolean
title: Decorative
Expand Down
33 changes: 0 additions & 33 deletions modules/custom/az_media/az_media.install
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,6 @@
* az_media module.
*/

use Drupal\Core\File\FileExists;

/**
* Implements hook_install().
*/
function az_media_install() {
_az_media_copy_placeholder_images();
}

/**
* Copies az_media's shipped placeholder images into public://.
*
* These are plain files, not media entities - they exist only to back
* SDC prop `examples` values (e.g. az_quickstart:ranking-image's `src`)
* so those examples resolve to a real, renderable public:// file on
* every site, without ever appearing in the Media Library.
*/
function _az_media_copy_placeholder_images() {
$file_system = \Drupal::service('file_system');
$source_dir = \Drupal::service('extension.list.module')->getPath('az_media') . '/images';
$filenames = ['placeholder-1000x500.png'];
foreach ($filenames as $filename) {
$file_system->copy("{$source_dir}/{$filename}", "public://{$filename}", FileExists::Replace);
}
}

/**
* Implements hook_update_last_removed().
*/
Expand All @@ -55,10 +29,3 @@ function az_media_update_1021301() {
function az_media_update_1130101() {
\Drupal::service('module_installer')->install(['media_library_form_element']);
}

/**
* Copy az_media's shipped placeholder images into public:// on existing sites.
*/
function az_media_update_1130102() {
_az_media_copy_placeholder_images();
}
116 changes: 116 additions & 0 deletions modules/custom/az_media/az_media.module
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,42 @@
* Contains az_media.module.
*/

use Drupal\canvas\PropExpressions\StructuredData\ReferencedBundleSpecificBranches;
use Drupal\canvas\PropExpressions\StructuredData\FieldTypePropExpression;
use Drupal\canvas\PropExpressions\StructuredData\ReferenceFieldTypePropExpression;
use Drupal\canvas\TypedData\BetterEntityDataDefinition;
use Drupal\canvas\PropExpressions\StructuredData\FieldPropExpression;
use Drupal\canvas\PropExpressions\StructuredData\ReferenceFieldPropExpression;
use Drupal\media\Plugin\media\Source\Image;
use Drupal\media\MediaTypeInterface;
use Drupal\media\Entity\MediaType;
use Drupal\canvas\PropShape\CandidateStorablePropShape;
use Drupal\Core\Entity\EntityFormInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Logger\RfcLogLevel;
use Drupal\responsive_image\Entity\ResponsiveImageStyle;
use Drupal\responsive_image\ResponsiveImageBuilder;

/**
* Implements hook_module_implements_alter().
*/
function az_media_module_implements_alter(&$implementations, $hook) {
// Ensure az_media_canvas_storable_prop_shape_alter() runs LAST for this
// hook, so it can reliably override whatever Canvas's own
// media_library-registered implementation
// (mediaLibraryStorablePropShapeAlter()) set. Module weight alone
// doesn't guarantee this - both az_media and media_library currently
// have weight 0, so without this, ordering would depend on install
// order / core.extension's module list order, which isn't something to
// rely on implicitly.
if ($hook === 'canvas_storable_prop_shape_alter' && isset($implementations['az_media'])) {
$group = $implementations['az_media'];
unset($implementations['az_media']);
$implementations['az_media'] = $group;
}
}

/**
* Implements hook_library_info_alter().
*/
Expand Down Expand Up @@ -212,3 +241,90 @@ function az_media_form_media_form_alter(&$form, FormStateInterface $form_state,
$form['field_focal_point_y']['widget'][0]['value']['#attributes']['class'][] = 'js-focal-point-y-value';
}
}

/**
* Implements hook_canvas_storable_prop_shape_alter().
*
* Restores the media-library-backed widget for an SDC string prop whose
* x-allowed-schemes includes 'public' alongside 'https' (e.g. so an
* `examples` value can reference an external placeholder image without
* breaking real editing) - only fires if Canvas actually invokes it, so
* it's safe for this to reference Canvas's own classes without a typed
* parameter or file-level `use` imports for them.
*
* Canvas's own
* \Drupal\canvas\Hook\ShapeMatchingHooks::mediaLibraryStorablePropShapeAlter()
* (registered "on behalf of" media_library) only applies the media-library
* widget when x-allowed-schemes is EXACTLY `['public']` (strict ===
* match, confirmed by reading it directly). Adding 'https' to the array
* for ANY reason - even just to let an example value validate - makes
* that exact-match gate fail, and Canvas's own core matching
* (JsonSchemaInterpreter\JsonSchemaStringFormat::computeStorablePropShape())
* takes the http/https branch instead, silently swapping in a plain
* upload field (fieldWidget: image_image) with no Media Library
* integration at all - confirmed empirically, not assumed.
*
* hook_canvas_storable_prop_shape_alter() implementations run in
* sequence and each one can freely overwrite what an earlier one set, so
* this doesn't need to patch or duplicate Canvas's own gating logic -
* it just needs to run after media_library's implementation and reapply
* the same media-library expression/widget whenever 'public' is present,
* regardless of what else is in the array. Confirmed empirically: with
* x-allowed-schemes: ['public', 'https'], this restores the exact same
* fieldWidget (media_library_widget) and fieldTypeProp expression
* (entity_reference -> media.field.entity -> file.uri) as the original
* public-only case, and real editor-selected values still always
* resolve to public://... - the 'https' scheme is never actually
* produced by this widget, it only exists in the schema to let a
* hand-written `examples` string validate.
*
* @see \Drupal\canvas\Hook\ShapeMatchingHooks::mediaLibraryStorablePropShapeAlter()
* @see \Drupal\canvas\PropShape\CandidateStorablePropShape
*/
function az_media_canvas_storable_prop_shape_alter($storable_prop_shape) {
if (!is_object($storable_prop_shape) || !($storable_prop_shape instanceof CandidateStorablePropShape)) {
return;
}
$schema = $storable_prop_shape->shape->schema;
if (
($schema['type'] ?? NULL) !== 'string' ||
($schema['contentMediaType'] ?? NULL) !== 'image/*' ||
!in_array('public', $schema['x-allowed-schemes'] ?? [], TRUE)
) {
return;
}

$media_types = array_filter(
MediaType::loadMultiple(),
fn (MediaTypeInterface $type): bool => is_a($type->getSource(), Image::class)
);
if (empty($media_types)) {
return;
}
ksort($media_types);

// Whenever the list of media types changes, this hook should be
// called again for this shape.
$storable_prop_shape->addCacheTags(['config:media_type_list']);

$branches = [];
foreach ($media_types as $media_type_id => $media_type) {
$source_field_name = $media_type->getSource()->getSourceFieldDefinition($media_type)->getName();
$branches["entity:media:$media_type_id"] = new ReferenceFieldPropExpression(
new FieldPropExpression(BetterEntityDataDefinition::create('media', $media_type_id), $source_field_name, NULL, 'entity'),
new FieldPropExpression(BetterEntityDataDefinition::create('file'), 'uri', NULL, 'value'),
);
}

$storable_prop_shape->fieldTypeProp = new ReferenceFieldTypePropExpression(
new FieldTypePropExpression('entity_reference', 'entity'),
count($branches) === 1 ? reset($branches) : new ReferencedBundleSpecificBranches($branches),
);
$media_type_ids = array_keys($media_types);
$storable_prop_shape->fieldStorageSettings = ['target_type' => 'media'];
$storable_prop_shape->fieldInstanceSettings = [
'handler' => 'default:media',
'handler_settings' => ['target_bundles' => array_combine($media_type_ids, $media_type_ids)],
];
$storable_prop_shape->fieldWidget = 'media_library_widget';
}
1 change: 1 addition & 0 deletions modules/custom/az_media/az_media.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ services:
arguments:
- '@file_url_generator'
- '@entity_type.manager'
- '@stream_wrapper_manager'
tags:
- { name: twig.extension }
Binary file not shown.
49 changes: 46 additions & 3 deletions modules/custom/az_media/src/Twig/ImageStyleTwigExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

Expand All @@ -22,6 +24,7 @@ class ImageStyleTwigExtension extends AbstractExtension {
public function __construct(
protected FileUrlGeneratorInterface $fileUrlGenerator,
protected EntityTypeManagerInterface $entityTypeManager,
protected StreamWrapperManagerInterface $streamWrapperManager,
) {}

/**
Expand All @@ -36,24 +39,64 @@ public function getFilters(): array {
/**
* Applies a named image style to a stream-wrapper URI.
*
* Also accepts a plain absolute URL, since some SDC props (whose
* x-allowed-schemes includes http/https, e.g. to accept a plain-URL
* example value) resolve to an absolute URL rather than a stream-wrapper
* URI even for a file that's actually stored locally on this site -
* ImageStyle::buildUri() only understands real Drupal stream-wrapper
* schemes, and silently mangles anything else (including an absolute
* URL pointing back at this site's own public files) into a bogus local
* derivative path instead of rejecting it. If the given value turns out
* to be a genuinely external URL, no image style processing is possible
* for it at all (there's nothing local to generate a derivative from),
* so it's returned unchanged rather than passed to buildUrl().
*
* @param string|null $uri
* A stream-wrapper URI (e.g. public://foo.jpg), or NULL/empty.
* A stream-wrapper URI (e.g. public://foo.jpg), an absolute URL
* pointing at this site's own public files, an arbitrary external
* absolute URL, or NULL/empty.
* @param string $style_name
* The image style's machine name.
*
* @return string
* The styled derivative's URL, or a plain file_url()-equivalent URL if
* the named style doesn't exist, or an empty string if $uri is empty.
* The styled derivative's URL; a plain file_url()-equivalent URL if
* the named style doesn't exist; the input unchanged if it's a
* non-Drupal-manageable URL (e.g. a genuinely external image); or an
* empty string if $uri is empty.
*/
public function applyImageStyle(?string $uri, string $style_name): string {
if (empty($uri)) {
return '';
}

$uri = $this->resolveToStreamWrapperUri($uri);

$scheme = StreamWrapperManager::getScheme($uri);
if ($scheme !== FALSE && !$this->streamWrapperManager->isValidScheme($scheme)) {
return $uri;
}

$style = $this->entityTypeManager->getStorage('image_style')->load($style_name);
if ($style) {
return $style->buildUrl($uri);
}
return $this->fileUrlGenerator->generateString($uri);
}

/**
* Converts a local public-files URL back into a public:// URI.
*
* Leaves anything else (a real stream-wrapper URI already, or a
* genuinely external URL) unchanged.
*/
protected function resolveToStreamWrapperUri(string $uri): string {
if (StreamWrapperManager::getScheme($uri) === FALSE) {
return $uri;
}
$public_base_url = $this->streamWrapperManager->getViaScheme('public')->getExternalUrl();
return str_starts_with($uri, $public_base_url)
? 'public://' . substr($uri, strlen($public_base_url))
: $uri;
}

}
Loading