@@ -781,6 +781,86 @@ describe("audio_src_not_found with templating tokens", () => {
781781 } ) ;
782782} ) ;
783783
784+ describe ( "a double-quoted src that contains an apostrophe" , ( ) => {
785+ const name = "Narrator's voice take 1.mp3" ;
786+
787+ it ( "finds the existing audio file instead of cutting the src at the apostrophe" , async ( ) => {
788+ const project = makeProject ( `<html><body>
789+ <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
790+ <audio id="a1" class="clip" data-start="0" data-duration="3" data-track-index="10" src="assets/${ name } "></audio>
791+ </body></html>` ) ;
792+ mkdirSync ( join ( project , "assets" ) , { recursive : true } ) ;
793+ writeFileSync ( join ( project , "assets" , name ) , "" ) ;
794+ const { results } = await lintProject ( project ) ;
795+ const codes = results . flatMap ( ( entry ) => entry . result . findings ) . map ( ( f ) => f . code ) ;
796+ expect ( codes ) . not . toContain ( "audio_src_not_found" ) ;
797+ } ) ;
798+
799+ async function lintCodes ( body : string , files : string [ ] ) : Promise < string [ ] > {
800+ const project = makeProject ( `<html><body>
801+ <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
802+ ${ body }
803+ </body></html>` ) ;
804+ mkdirSync ( join ( project , "assets" ) , { recursive : true } ) ;
805+ for ( const file of files ) writeFileSync ( join ( project , "assets" , file ) , "" ) ;
806+ const { results } = await lintProject ( project ) ;
807+ return results . flatMap ( ( entry ) => entry . result . findings ) . map ( ( f ) => f . code ) ;
808+ }
809+
810+ it ( "finds an existing audio file whose apostrophe is written as a character reference" , async ( ) => {
811+ const codes = await lintCodes (
812+ `<audio id="a1" class="clip" data-start="0" data-duration="3" data-track-index="10" src="assets/Narrator's take.mp3"></audio>` ,
813+ [ "Narrator's take.mp3" ] ,
814+ ) ;
815+ expect ( codes ) . not . toContain ( "audio_src_not_found" ) ;
816+ } ) ;
817+
818+ it ( "tells two layered files apart when their names share text up to an apostrophe" , async ( ) => {
819+ const audio = ( id : string , file : string ) =>
820+ `<audio id="${ id } " class="clip" data-start="0" data-duration="5" data-track-index="10" src="assets/${ file } "></audio>` ;
821+ const codes = await lintCodes (
822+ audio ( "a1" , "Narrator's take 1.mp3" ) + audio ( "a2" , "Narrator's take 2.mp3" ) ,
823+ [ "Narrator's take 1.mp3" , "Narrator's take 2.mp3" ] ,
824+ ) ;
825+ expect ( codes ) . toContain ( "duplicate_audio_track" ) ;
826+ } ) ;
827+
828+ it ( "still reports a missing file whose name starts with a quote character" , async ( ) => {
829+ const codes = await lintCodes (
830+ `<audio id="a1" class="clip" data-start="0" data-duration="3" data-track-index="10" src="'90s theme.mp3"></audio>` ,
831+ [ ] ,
832+ ) ;
833+ expect ( codes ) . toContain ( "audio_src_not_found" ) ;
834+ } ) ;
835+
836+ it ( "does not read an audio tag that an inline script builds as a string" , async ( ) => {
837+ const codes = await lintCodes (
838+ `<script>var s = '<audio src="' + u + '">'; var t = "<audio src='" + u + "'>";</script>` ,
839+ [ ] ,
840+ ) ;
841+ expect ( codes ) . not . toContain ( "audio_src_not_found" ) ;
842+ } ) ;
843+
844+ it ( "does not read a src out of another attribute's text" , async ( ) => {
845+ const codes = await lintCodes ( `<img id="i1" alt="x src='ghost.png'" />` , [ ] ) ;
846+ expect ( codes ) . not . toContain ( "missing_local_asset" ) ;
847+ } ) ;
848+
849+ it ( "finds the existing image through the shared src pattern too" , async ( ) => {
850+ const image = "Ann's photo.png" ;
851+ const project = makeProject ( `<html><body>
852+ <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
853+ <img id="i1" class="clip" data-start="0" data-duration="3" data-track-index="0" src="assets/${ image } ">
854+ </div>
855+ </body></html>` ) ;
856+ mkdirSync ( join ( project , "assets" ) , { recursive : true } ) ;
857+ writeFileSync ( join ( project , "assets" , image ) , "" ) ;
858+ const { results } = await lintProject ( project ) ;
859+ const codes = results . flatMap ( ( entry ) => entry . result . findings ) . map ( ( f ) => f . code ) ;
860+ expect ( codes ) . not . toContain ( "missing_local_asset" ) ;
861+ } ) ;
862+ } ) ;
863+
784864describe ( "templating tokens are checked on the raw src, before cleanAssetUrl" , ( ) => {
785865 // cleanAssetUrl splits on ?/#, which also chops inside a ${...} expression
786866 // (e.g. `${asset?.url}` -> `${asset`). The token skip must run on the RAW value or
0 commit comments